First version of new ui based on tailwind

This drops fomantic-ui as css toolkit and introduces tailwindcss. With
tailwind there are no predefined components, but it's very easy to
create those. So customizing the look&feel is much simpler, most of
the time no additional css is needed.

This requires a complete rewrite of the markup + styles. Luckily all
logic can be kept as is. The now old ui is not removed, it is still
available by using a request header `Docspell-Ui` with a value of `1`
for the old ui and `2` for the new ui.

Another addition is "dev mode", where docspell serves assets with a
no-cache header, to disable browser caching. This makes developing a
lot easier.
This commit is contained in:
Eike Kettner
2021-01-29 20:48:27 +01:00
parent 442b76c5af
commit dd935454c9
140 changed files with 15077 additions and 214 deletions

View File

@ -5,7 +5,7 @@ import java.nio.file.{Files, Paths}
import cats.effect._
import cats.implicits._
import docspell.common.{Banner, Pools, ThreadFactories}
import docspell.common._
import org.log4s._
@ -57,6 +57,10 @@ object Main extends IOApp {
} yield Pools(cec, bec, blocker, rec)
logger.info(s"\n${banner.render("***>")}")
if (EnvMode.current.isDev) {
logger.warn(">>>>> Docspell is running in DEV mode! <<<<<")
}
pools.use(p =>
RestServer
.stream[IO](cfg, p)

View File

@ -5,7 +5,8 @@ import cats.implicits._
import fs2.Stream
import docspell.backend.auth.AuthToken
import docspell.common.Pools
import docspell.common._
import docspell.restserver.http4s.EnvMiddleware
import docspell.restserver.routes._
import docspell.restserver.webapp._
@ -39,9 +40,9 @@ object RestServer {
adminRoutes(cfg, restApp)
},
"/api/doc" -> templates.doc,
"/app/assets" -> WebjarRoutes.appRoutes[F](pools.blocker),
"/app" -> templates.app,
"/sw.js" -> templates.serviceWorker,
"/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F](pools.blocker)),
"/app" -> EnvMiddleware(templates.app),
"/sw.js" -> EnvMiddleware(templates.serviceWorker),
"/" -> redirectTo("/app")
).orNotFound

View File

@ -0,0 +1,13 @@
package docspell.restserver.http4s
import cats.Functor
import docspell.common._
import org.http4s._
object EnvMiddleware {
def apply[F[_]: Functor](in: HttpRoutes[F]): HttpRoutes[F] =
NoCacheMiddleware.route(EnvMode.current.isDev)(in)
}

View File

@ -0,0 +1,30 @@
package docspell.restserver.http4s
import cats.Functor
import cats.data.Kleisli
import cats.data.NonEmptyList
import docspell.common._
import org.http4s._
import org.http4s.headers._
object NoCacheMiddleware {
private val noCacheHeader: Header =
`Cache-Control`(
NonEmptyList.of(
CacheDirective.`max-age`(Duration.zero.toScala),
CacheDirective.`no-store`
)
)
def apply[F[_]: Functor, G[_], A](
noCache: Boolean
)(in: Kleisli[F, A, Response[F]]): Kleisli[F, A, Response[F]] =
if (noCache) in.map(_.putHeaders(noCacheHeader))
else in
def route[F[_]: Functor](noCache: Boolean)(in: HttpRoutes[F]): HttpRoutes[F] =
if (noCache) in.map(_.putHeaders(noCacheHeader))
else in
}

View File

@ -18,11 +18,12 @@ case class Flags(
fullTextSearchEnabled: Boolean,
maxPageSize: Int,
maxNoteLength: Int,
showClassificationSettings: Boolean
showClassificationSettings: Boolean,
uiVersion: Int
)
object Flags {
def apply(cfg: Config): Flags =
def apply(cfg: Config, uiVersion: Int): Flags =
Flags(
cfg.appName,
getBaseUrl(cfg),
@ -32,7 +33,8 @@ object Flags {
cfg.fullTextSearch.enabled,
cfg.maxItemPageSize,
cfg.maxNoteLength,
cfg.showClassificationSettings
cfg.showClassificationSettings,
uiVersion
)
private def getBaseUrl(cfg: Config): String =

View File

@ -14,6 +14,8 @@ import org.http4s.HttpRoutes
import org.http4s._
import org.http4s.dsl.Http4sDsl
import org.http4s.headers._
import org.http4s.util.CaseInsensitiveString
import org.http4s.util.Writer
import org.log4s._
import yamusca.implicits._
import yamusca.imports._
@ -24,6 +26,27 @@ object TemplateRoutes {
val `text/html` = new MediaType("text", "html")
val `application/javascript` = new MediaType("application", "javascript")
val ui2Header = CaseInsensitiveString("Docspell-Ui2")
case class UiVersion(version: Int) extends Header.Parsed {
val key = UiVersion
def renderValue(writer: Writer): writer.type =
writer.append(version)
}
object UiVersion extends HeaderKey.Singleton {
type HeaderT = UiVersion
val name = CaseInsensitiveString("Docspell-Ui")
override def parse(s: String): ParseResult[UiVersion] =
Either
.catchNonFatal(s.trim.toInt)
.leftMap(ex => ParseFailure("Invalid int header", ex.getMessage))
.map(UiVersion.apply)
override def matchHeader(h: Header): Option[UiVersion] =
if (h.name == name) parse(h.value).toOption
else None
}
trait InnerRoutes[F[_]] {
def doc: HttpRoutes[F]
def app: HttpRoutes[F]
@ -53,22 +76,24 @@ object TemplateRoutes {
} yield resp
}
def app =
HttpRoutes.of[F] { case GET -> _ =>
HttpRoutes.of[F] { case req @ GET -> _ =>
for {
templ <- indexTemplate
uiv = req.headers.get(UiVersion).map(_.version).getOrElse(1)
resp <- Ok(
IndexData(cfg).render(templ),
IndexData(cfg, uiv).render(templ),
`Content-Type`(`text/html`, Charset.`UTF-8`)
)
} yield resp
}
def serviceWorker =
HttpRoutes.of[F] { case GET -> _ =>
HttpRoutes.of[F] { case req @ GET -> _ =>
for {
templ <- swTemplate
uiv = req.headers.get(UiVersion).map(_.version).getOrElse(1)
resp <- Ok(
IndexData(cfg).render(templ),
IndexData(cfg, uiv).render(templ),
`Content-Type`(`application/javascript`, Charset.`UTF-8`)
)
} yield resp
@ -134,22 +159,28 @@ object TemplateRoutes {
object IndexData {
def apply(cfg: Config): IndexData =
def apply(cfg: Config, uiVersion: Int): IndexData =
IndexData(
Flags(cfg),
Seq(
"/app/assets" + Webjars.fomanticslimdefault + "/semantic.min.css",
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.css"
),
Flags(cfg, uiVersion),
chooseUi(uiVersion),
Seq(
"/app/assets" + Webjars.clipboardjs + "/clipboard.min.js",
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js"
),
s"/app/assets/docspell-webapp/${BuildInfo.version}/favicon",
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js",
Flags(cfg).asJson.spaces2
Flags(cfg, uiVersion).asJson.spaces2
)
private def chooseUi(uiVersion: Int): Seq[String] =
if (uiVersion == 2)
Seq(s"/app/assets/docspell-webapp/${BuildInfo.version}/css/styles.css")
else
Seq(
"/app/assets" + Webjars.fomanticslimdefault + "/semantic.min.css",
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.css"
)
implicit def yamuscaValueConverter: ValueConverter[IndexData] =
ValueConverter.deriveConverter[IndexData]
}

View File

@ -4,10 +4,9 @@ import cats.data.Kleisli
import cats.data.OptionT
import cats.effect._
import org.http4s.HttpRoutes
import org.http4s.Method
import org.http4s.Response
import org.http4s.StaticFile
import docspell.common._
import org.http4s._
object WebjarRoutes {
@ -37,12 +36,13 @@ object WebjarRoutes {
if (p.contains("..") || !suffixes.exists(p.endsWith(_)))
OptionT.pure(Response.notFound[F])
else
StaticFile.fromResource(
s"/META-INF/resources/webjars$p",
blocker,
Some(req),
true
)
StaticFile
.fromResource(
s"/META-INF/resources/webjars$p",
blocker,
Some(req),
EnvMode.current.isProd
)
case _ =>
OptionT.none
}

View File

@ -33,9 +33,8 @@
</head>
<body>
<div id="docspell-app">
</div>
<body id="docspell-app">
<!-- everything in here gets replaced by elm, including the body tag -->
<script type="application/javascript">
var storedAccount = localStorage.getItem('account');