feat: Add new admin ui only for drupal and small tweaks
This commit is contained in:
55
README.md
55
README.md
@@ -1,6 +1,6 @@
|
||||
# README
|
||||
|
||||
This template scaffolds a web application with the following compnents configured:
|
||||
This template scaffolds a web application with the following components configured:
|
||||
|
||||
#### Server
|
||||
|
||||
@@ -19,15 +19,64 @@ This template scaffolds a web application with the following compnents configure
|
||||
* Fable.Remoting
|
||||
* Fable.SignalR
|
||||
|
||||
## Deployment (Drupal on Kubernetes)
|
||||
|
||||
We treat **Git as the source of truth** for any Drupal code, dependencies, and configuration. The idea is to keep the Kubernetes Drupal instance reproducible and avoid config mismatches.
|
||||
|
||||
### What must be included in the container image
|
||||
|
||||
When adding a new feature or changing configuration, these files and folders are required deployment in the Kubernetes cluster:
|
||||
|
||||
* `composer.json` and `composer.lock`
|
||||
* `drupal/config/` (exported Drupal config)
|
||||
|
||||
If any of these are missing or out of date, there will typically be missing module errors, config import failures, or mismatches between environments.
|
||||
|
||||
#### Export config locally
|
||||
|
||||
Use this when you changed configuration locally (UI changes, enabling modules locally, content type changes, views, permissions, etc).
|
||||
|
||||
```sh
|
||||
drush cex
|
||||
```
|
||||
|
||||
Commit the updated `drupal/config/` output.
|
||||
|
||||
#### Import config in Kubernetes
|
||||
|
||||
Use this when `drupal/config/` changed in Git and you deployed a new image (or synced a new revision).
|
||||
|
||||
```sh
|
||||
drush cim -y
|
||||
drush cr
|
||||
```
|
||||
|
||||
`drush cim` imports the repo config into the running site. `drush cr` clears caches so changes are reflected immediately.
|
||||
|
||||
It can also be done through the Drupal admin UI, under Configuration -> Development -> Configuration Synchronization.
|
||||
|
||||
### Composer dependencies
|
||||
|
||||
If added, removed, or updated PHP packages, `composer.lock` must be updated and the container must run a Composer install during the image build.
|
||||
|
||||
|
||||
```sh
|
||||
composer install
|
||||
```
|
||||
|
||||
### Avoid config mismatch in the cluster
|
||||
|
||||
We should try to avoid changing config directly on the Kubernetes Drupal instance because `drush cim` can overwrite manual changes.
|
||||
|
||||
## Using Dapr
|
||||
|
||||
This template inludes support for Dapr Actors. The custom `use_multiauth` and `use_oidc` authentication pipelines configure the user (principal) groups and roles (claims) via a `UserActor`, which can easily be migrated to an external Dapr service if need be.
|
||||
This template includes support for Dapr Actors. The custom `use_multiauth` and `use_oidc` authentication pipelines configure the user (principal) groups and roles (claims) via a `UserActor`, which can easily be migrated to an external Dapr service if need be.
|
||||
|
||||
Install the Dapr CLI and set up Dapr for use with actors:
|
||||
|
||||
```sh
|
||||
dapr init -s
|
||||
cp .dapr/components/* ~/.dapr/componets
|
||||
cp .dapr/components/* ~/.dapr/components
|
||||
```
|
||||
|
||||
Run the development server(s) under Dapr:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"composer/installers": "^2.3",
|
||||
"cweagans/composer-patches": "^2.0",
|
||||
"drupal/admin_toolbar": "^3.6",
|
||||
"drupal/admin_ui_only": "^1.0",
|
||||
"drupal/core": "11.2.10",
|
||||
"drupal/core-composer-scaffold": "^11.2",
|
||||
"drupal/core-project-message": "^11.2",
|
||||
|
||||
58
drupal/composer.lock
generated
58
drupal/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5517d318fe8d00ee92f5dd4bbef9eaad",
|
||||
"content-hash": "6fa38d6f6fc28f8d24501e880ba2be05",
|
||||
"packages": [
|
||||
{
|
||||
"name": "asm89/stack-cors",
|
||||
@@ -972,6 +972,62 @@
|
||||
"issues": "https://www.drupal.org/project/issues/admin_toolbar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drupal/admin_ui_only",
|
||||
"version": "1.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://git.drupalcode.org/project/admin_ui_only.git",
|
||||
"reference": "1.0.3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://ftp.drupal.org/files/projects/admin_ui_only-1.0.3.zip",
|
||||
"reference": "1.0.3",
|
||||
"shasum": "1e08434942f6631b8ce41338a01e5f2028fa23be"
|
||||
},
|
||||
"require": {
|
||||
"drupal/core": "^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "drupal-module",
|
||||
"extra": {
|
||||
"drupal": {
|
||||
"version": "1.0.3",
|
||||
"datestamp": "1716462646",
|
||||
"security-coverage": {
|
||||
"status": "covered",
|
||||
"message": "Covered by Drupal's security advisory policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packages.drupal.org/8/downloads",
|
||||
"license": [
|
||||
"GPL-2.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "alexpott",
|
||||
"homepage": "https://www.drupal.org/user/157725"
|
||||
},
|
||||
{
|
||||
"name": "skyredwang",
|
||||
"homepage": "https://www.drupal.org/user/228712"
|
||||
},
|
||||
{
|
||||
"name": "smccabe",
|
||||
"homepage": "https://www.drupal.org/user/289245"
|
||||
},
|
||||
{
|
||||
"name": "whiz11",
|
||||
"homepage": "https://www.drupal.org/user/1195358"
|
||||
}
|
||||
],
|
||||
"description": "Prevents people from accessing the non admin site. Useful if you want to have anonymous access to an API like JSON:API or GraphQL.",
|
||||
"homepage": "https://www.drupal.org/project/admin_ui_only",
|
||||
"support": {
|
||||
"source": "https://git.drupalcode.org/project/admin_ui_only"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drupal/core",
|
||||
"version": "11.2.10",
|
||||
|
||||
4
drupal/config/admin_ui_only.settings.yml
Normal file
4
drupal/config/admin_ui_only.settings.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
_core:
|
||||
default_config_hash: L_rAiCIVCIoTgqIZ9JZC5h_zZV5NyxvGNjcfXGFBIRY
|
||||
routes: { }
|
||||
error_code: 403
|
||||
@@ -2,6 +2,7 @@ _core:
|
||||
default_config_hash: 4GIX5Esnc_umpXUBj4IIocRX7Mt5fPhm4AgXfE3E56E
|
||||
module:
|
||||
admin_toolbar: 0
|
||||
admin_ui_only: 0
|
||||
announcements_feed: 0
|
||||
automated_cron: 0
|
||||
big_pipe: 0
|
||||
|
||||
@@ -65,7 +65,7 @@ resourceFields:
|
||||
enhancer:
|
||||
id: ''
|
||||
status:
|
||||
disabled: true
|
||||
disabled: false
|
||||
fieldName: status
|
||||
publicName: status
|
||||
enhancer:
|
||||
@@ -124,6 +124,12 @@ resourceFields:
|
||||
publicName: revision_translation_affected
|
||||
enhancer:
|
||||
id: ''
|
||||
moderation_state:
|
||||
disabled: false
|
||||
fieldName: moderation_state
|
||||
publicName: moderation_state
|
||||
enhancer:
|
||||
id: ''
|
||||
path:
|
||||
disabled: false
|
||||
fieldName: path
|
||||
|
||||
@@ -8,7 +8,7 @@ slogan: 'We offer a whole new perspective of the complexity of oceanography and
|
||||
page:
|
||||
403: ''
|
||||
404: ''
|
||||
front: /node/1
|
||||
front: /user/login
|
||||
admin_compact_mode: false
|
||||
weight_select_max: 100
|
||||
default_langcode: en
|
||||
|
||||
@@ -23,195 +23,199 @@ open Saturn.ReverseProxy
|
||||
open Settings
|
||||
|
||||
type Arguments =
|
||||
| Log_Level of level: int
|
||||
| Port of port: int
|
||||
| [<MainCommand; Last>] Dir of path: string
|
||||
| Log_Level of level: int
|
||||
| Port of port: int
|
||||
| [<MainCommand; Last>] Dir of path: string
|
||||
|
||||
interface IArgParserTemplate with
|
||||
member this.Usage =
|
||||
match this with
|
||||
| Log_Level _ -> "0=Error, 1=Warning, 2=Info, 3=Debug, 4=Verbose"
|
||||
| Port _ -> "listen port (default 8085)"
|
||||
| Dir _ -> "serve from dir"
|
||||
interface IArgParserTemplate with
|
||||
member this.Usage =
|
||||
match this with
|
||||
| Log_Level _ -> "0=Error, 1=Warning, 2=Info, 3=Debug, 4=Verbose"
|
||||
| Port _ -> "listen port (default 8085)"
|
||||
| Dir _ -> "serve from dir"
|
||||
|
||||
let getLayout (lang: string) =
|
||||
let mainMenuData = getJsonApiMenu lang "main"
|
||||
let footerData = getJsonApiBlock lang "footer"
|
||||
|
||||
let header =
|
||||
Header.render "https://oceanbox.io/wp-content/uploads/2022/11/Oceanbox-round-white.png" mainMenuData.data
|
||||
Header.render "https://oceanbox.io/wp-content/uploads/2022/11/Oceanbox-round-white.png" mainMenuData.data
|
||||
|
||||
let footer = Footer.render footerData.data
|
||||
header, footer
|
||||
|
||||
let notFoundHandler (lang: string) : HttpHandler =
|
||||
fun next ctx ->
|
||||
task {
|
||||
let header, footer = getLayout lang
|
||||
let pageLayout = defaultLayout "Page not found" ""
|
||||
let html = pageLayout header footer NotFoundPage.render |> Render.htmlDocument
|
||||
return! (setStatusCode 404 >=> htmlString html) next ctx
|
||||
}
|
||||
fun next ctx ->
|
||||
task {
|
||||
let header, footer = getLayout lang
|
||||
let pageLayout = defaultLayout "Page not found" ""
|
||||
let html = pageLayout header footer NotFoundPage.render |> Render.htmlDocument
|
||||
return! (setStatusCode 404 >=> htmlString html) next ctx
|
||||
}
|
||||
|
||||
let chooseDraftVersion (draftRequested: bool): string option =
|
||||
let chooseDraftVersion (draftRequested: bool) : string option =
|
||||
match draftRequested with
|
||||
| true -> Some "resourceVersion=rel:working-copy"
|
||||
| _ -> None
|
||||
|
||||
let jsonApiContent (lang: string) (page: string) (next: HttpFunc) (ctx: HttpContext) =
|
||||
task {
|
||||
let draftRequested =
|
||||
match ctx.TryGetQueryStringValue "draft" with
|
||||
| Some "true" -> true
|
||||
| _ -> false
|
||||
let header, footer = getLayout lang
|
||||
let draftFlag = chooseDraftVersion draftRequested
|
||||
let pageData = getJsonApiPageBySlug lang page draftFlag
|
||||
let content = Content.render pageData.data
|
||||
let _, moderationState = pageData.data.Status
|
||||
let jsonData = entityEncoder pageData |> Encode.toString 2
|
||||
let pageLayout = defaultLayout pageData.data.Title jsonData
|
||||
let html = pageLayout header footer content |> Render.htmlDocument
|
||||
task {
|
||||
let draftRequested =
|
||||
match ctx.TryGetQueryStringValue "draft" with
|
||||
| Some "true" -> true
|
||||
| _ -> false
|
||||
|
||||
let notFound = notFoundHandler lang
|
||||
let header, footer = getLayout lang
|
||||
let draftFlag = chooseDraftVersion draftRequested
|
||||
let pageData = getJsonApiPageBySlug lang page draftFlag
|
||||
let content = Content.render pageData.data
|
||||
let _, moderationState = pageData.data.Status
|
||||
let jsonData = entityEncoder pageData |> Encode.toString 2
|
||||
let pageLayout = defaultLayout pageData.data.Title jsonData
|
||||
let html = pageLayout header footer content |> Render.htmlDocument
|
||||
|
||||
let state =
|
||||
match moderationState with
|
||||
| Some x -> x
|
||||
| None -> "none"
|
||||
let notFound = notFoundHandler lang
|
||||
|
||||
let result =
|
||||
match draftRequested, state with
|
||||
| true, "draft"
|
||||
| false, "none"
|
||||
| _, "published" -> htmlString html next ctx
|
||||
| false, "draft"
|
||||
| _ -> notFound next ctx
|
||||
let state =
|
||||
match moderationState with
|
||||
| Some x -> x
|
||||
| None -> "none"
|
||||
|
||||
return! result
|
||||
}
|
||||
let result =
|
||||
match draftRequested, state with
|
||||
| true, "draft"
|
||||
| false, "none"
|
||||
| _, "published" -> htmlString html next ctx
|
||||
| false, "draft"
|
||||
| _ -> notFound next ctx
|
||||
|
||||
return! result
|
||||
}
|
||||
|
||||
let cacheTime = 86400
|
||||
|
||||
let unlessStartsWith
|
||||
(skipPaths: string list)
|
||||
(func: HttpFunc -> HttpFunc)
|
||||
(next: HttpFunc)
|
||||
(ctx: HttpContext)
|
||||
: HttpFuncResult =
|
||||
task {
|
||||
let skip = skipPaths |> List.tryFind (fun x -> ctx.Request.Path.Value.StartsWith x)
|
||||
if skip.IsSome then return None else return! func next ctx
|
||||
}
|
||||
(skipPaths: string list)
|
||||
(func: HttpFunc -> HttpFunc)
|
||||
(next: HttpFunc)
|
||||
(ctx: HttpContext)
|
||||
: HttpFuncResult =
|
||||
task {
|
||||
let skip = skipPaths |> List.tryFind (fun x -> ctx.Request.Path.Value.StartsWith x)
|
||||
if skip.IsSome then return None else return! func next ctx
|
||||
}
|
||||
|
||||
let appRoutes (lang: string) : HttpHandler =
|
||||
let staticPaths = [ "/favicon.png"; "/css"; "/js"; "/img" ]
|
||||
let staticPaths = [ "/favicon.png"; "/css"; "/js"; "/img" ]
|
||||
|
||||
let router =
|
||||
choose
|
||||
[ route "/" >=> jsonApiContent lang "frontpage"
|
||||
routef "/en/%s" (jsonApiContent "en")
|
||||
routef "/es/%s" (jsonApiContent "es")
|
||||
routef "/no/%s" (jsonApiContent "no")
|
||||
routef "/%s" (jsonApiContent lang) ]
|
||||
let router =
|
||||
choose
|
||||
[ route "/" >=> jsonApiContent lang "frontpage"
|
||||
routef "/en/%s" (jsonApiContent "en")
|
||||
routef "/es/%s" (jsonApiContent "es")
|
||||
routef "/no/%s" (jsonApiContent "no")
|
||||
routef "/employee/%s" (fun employeeSlug -> jsonApiContent lang $"/employee/{employeeSlug}")
|
||||
routef "/%s" (jsonApiContent lang) ]
|
||||
|
||||
unlessStartsWith staticPaths router
|
||||
unlessStartsWith staticPaths router
|
||||
|
||||
let isLangSegment (segment: string) =
|
||||
segment.Length = 2 && segment |> Seq.forall Char.IsLetter
|
||||
segment.Length = 2 && segment |> Seq.forall Char.IsLetter
|
||||
|
||||
let withLangAny (handler: string -> HttpHandler) : HttpHandler =
|
||||
fun next ctx ->
|
||||
let path =
|
||||
match ctx.Request.Path.Value with
|
||||
| null -> ""
|
||||
| v -> v
|
||||
fun next ctx ->
|
||||
let path =
|
||||
match ctx.Request.Path.Value with
|
||||
| null -> ""
|
||||
| v -> v
|
||||
|
||||
let trimmed = path.Trim '/'
|
||||
let trimmed = path.Trim '/'
|
||||
|
||||
let segments =
|
||||
if String.IsNullOrWhiteSpace trimmed then
|
||||
[||]
|
||||
else
|
||||
trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
let segments =
|
||||
if String.IsNullOrWhiteSpace trimmed then
|
||||
[||]
|
||||
else
|
||||
trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
|
||||
let lang, newPath =
|
||||
if segments.Length > 0 && isLangSegment segments[0] then
|
||||
let lang = segments[0].ToLowerInvariant()
|
||||
let rest = segments |> Array.skip 1
|
||||
let lang, newPath =
|
||||
if segments.Length > 0 && isLangSegment segments[0] then
|
||||
let lang = segments[0].ToLowerInvariant()
|
||||
let rest = segments |> Array.skip 1
|
||||
|
||||
let newPath =
|
||||
if rest.Length = 0 then
|
||||
PathString "/"
|
||||
else
|
||||
PathString("/" + String.Join("/", rest))
|
||||
let newPath =
|
||||
if rest.Length = 0 then
|
||||
PathString "/"
|
||||
else
|
||||
PathString("/" + String.Join("/", rest))
|
||||
|
||||
lang, newPath
|
||||
else
|
||||
"", ctx.Request.Path
|
||||
lang, newPath
|
||||
else
|
||||
"", ctx.Request.Path
|
||||
|
||||
ctx.Items["lang"] <- lang
|
||||
ctx.Request.Path <- newPath
|
||||
ctx.Items["lang"] <- lang
|
||||
ctx.Request.Path <- newPath
|
||||
|
||||
handler lang next ctx
|
||||
handler lang next ctx
|
||||
|
||||
let webApp = withLangAny appRoutes
|
||||
|
||||
let configureSerilog level =
|
||||
let n =
|
||||
match level with
|
||||
| 0 -> LogEventLevel.Error
|
||||
| 1 -> LogEventLevel.Warning
|
||||
| 2 -> LogEventLevel.Information
|
||||
| 3 -> LogEventLevel.Debug
|
||||
| _ -> LogEventLevel.Verbose
|
||||
let n =
|
||||
match level with
|
||||
| 0 -> LogEventLevel.Error
|
||||
| 1 -> LogEventLevel.Warning
|
||||
| 2 -> LogEventLevel.Information
|
||||
| 3 -> LogEventLevel.Debug
|
||||
| _ -> LogEventLevel.Verbose
|
||||
|
||||
LoggerConfiguration()
|
||||
.MinimumLevel.Is(n)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("System", LogEventLevel.Information)
|
||||
.Filter.ByExcluding("RequestPath like '/health%'")
|
||||
.WriteTo.Console()
|
||||
.CreateLogger()
|
||||
LoggerConfiguration()
|
||||
.MinimumLevel.Is(n)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("System", LogEventLevel.Information)
|
||||
.Filter.ByExcluding("RequestPath like '/health%'")
|
||||
.WriteTo.Console()
|
||||
.CreateLogger()
|
||||
|
||||
let configureServices (service: IServiceCollection) =
|
||||
service.AddResponseCaching().AddSerilog() |> ignore
|
||||
service
|
||||
service.AddResponseCaching().AddSerilog() |> ignore
|
||||
service
|
||||
|
||||
let configureApp (app: IApplicationBuilder) = app.UseResponseCaching()
|
||||
|
||||
let proxyConfig: ReverseProxyConfig =
|
||||
[| PathString "/sites/default/files", $"{SiteInfo.drupalUrl}/sites/default/files" |]
|
||||
[| PathString "/sites/default/files", $"{SiteInfo.drupalUrl}/sites/default/files" |]
|
||||
|
||||
let app _ =
|
||||
let metricServer = new KestrelMetricServer(port = 9091)
|
||||
metricServer.Start() |> ignore
|
||||
let metricServer = new KestrelMetricServer(port = 9091)
|
||||
metricServer.Start() |> ignore
|
||||
|
||||
application {
|
||||
url $"http://0.0.0.0:{port}"
|
||||
use_static "public"
|
||||
app_config configureApp
|
||||
use_router webApp
|
||||
use_reverse_proxy proxyConfig
|
||||
service_config configureServices
|
||||
memory_cache
|
||||
use_json_serializer (Thoth.Json.Giraffe.ThothSerializer())
|
||||
use_gzip
|
||||
logging (fun logger -> logger.AddSerilog() |> ignore)
|
||||
}
|
||||
application {
|
||||
url $"http://0.0.0.0:{port}"
|
||||
use_static "public"
|
||||
app_config configureApp
|
||||
use_router webApp
|
||||
use_reverse_proxy proxyConfig
|
||||
service_config configureServices
|
||||
memory_cache
|
||||
use_json_serializer (Thoth.Json.Giraffe.ThothSerializer())
|
||||
use_gzip
|
||||
logging (fun logger -> logger.AddSerilog() |> ignore)
|
||||
}
|
||||
|
||||
let colorizer =
|
||||
function
|
||||
| ErrorCode.HelpText -> None
|
||||
| _ -> Some ConsoleColor.Red
|
||||
function
|
||||
| ErrorCode.HelpText -> None
|
||||
| _ -> Some ConsoleColor.Red
|
||||
|
||||
let errorHandler = ProcessExiter(colorizer = colorizer)
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv =
|
||||
let parser =
|
||||
ArgumentParser.Create<Arguments>(programName = "Fornix", errorHandler = errorHandler)
|
||||
let parser =
|
||||
ArgumentParser.Create<Arguments>(programName = "Fornix", errorHandler = errorHandler)
|
||||
|
||||
let args = parser.Parse argv
|
||||
let port = args.GetResult(Port, defaultValue = 8085)
|
||||
Log.Logger <- configureSerilog (args.GetResult(Log_Level, defaultValue = 4))
|
||||
run (app port)
|
||||
0
|
||||
let args = parser.Parse argv
|
||||
let port = args.GetResult(Port, defaultValue = 8085)
|
||||
Log.Logger <- configureSerilog (args.GetResult(Log_Level, defaultValue = 4))
|
||||
run (app port)
|
||||
0
|
||||
|
||||
@@ -58,9 +58,16 @@ module Footer =
|
||||
])
|
||||
| _ -> [||]
|
||||
|
||||
let footerClasses =
|
||||
"container m-auto text-neutral-content px-6 lg:px-8 py-10 gap-12"
|
||||
|
||||
footer
|
||||
[ className "bg-neutral"
|
||||
children
|
||||
[ div
|
||||
[ className "footer sm:footer-horizontal container m-auto text-neutral-content px-6 lg:px-8 py-10 gap-12"
|
||||
children footerColumns ] ] ]
|
||||
[
|
||||
if footerColumns.Length > 0 then
|
||||
div
|
||||
[ className $"footer sm:footer-horizontal {footerClasses}"
|
||||
children footerColumns ]
|
||||
|
||||
div [ className footerClasses; children [ Html.span [ text "Copyright © 2026 oceanbox.io" ] ] ] ] ]
|
||||
|
||||
@@ -27,6 +27,11 @@ module ThothSerializerManual =
|
||||
let menuDecoder = Decode.Auto.generateDecoder<Menu> ()
|
||||
let menuEncoder = Encode.Auto.generateEncoder<Menu> ()
|
||||
|
||||
let private relationshipArray (itemDecoder: Decoder<'a>) : Decoder<'a array> =
|
||||
Decode.oneOf
|
||||
[ Decode.array itemDecoder
|
||||
Decode.field "data" (Decode.array itemDecoder) ]
|
||||
|
||||
let stringOrEmpty: Decoder<string option> =
|
||||
Decode.string
|
||||
|> Decode.map (fun s -> if System.String.IsNullOrWhiteSpace s then None else Some s)
|
||||
@@ -101,7 +106,7 @@ module ThothSerializerManual =
|
||||
Decode.object (fun get ->
|
||||
{ ``type`` = get.Required.Field "type" Decode.string
|
||||
field_display_mode = get.Required.Field "field_display_mode" Decode.string
|
||||
field_cards = get.Required.Field "field_cards" (Decode.array cardDecoder) })
|
||||
field_cards = get.Required.Field "field_cards" (relationshipArray cardDecoder) })
|
||||
|
||||
let cardsEncoder (cards: Cards) =
|
||||
Encode.object
|
||||
@@ -136,7 +141,7 @@ module ThothSerializerManual =
|
||||
path = get.Required.Field "path" pathDecoder
|
||||
created = get.Required.Field "created" Decode.string
|
||||
changed = get.Required.Field "changed" Decode.string
|
||||
field_content = get.Required.Field "field_content" (Decode.array paragraphDecoder) })
|
||||
field_content = get.Optional.Field "field_content" (relationshipArray paragraphDecoder) })
|
||||
|
||||
let basicPageEncoder (data: BasicPage) =
|
||||
Encode.object
|
||||
@@ -148,7 +153,7 @@ module ThothSerializerManual =
|
||||
"path", pathEncoder data.path
|
||||
"created", Encode.string data.created
|
||||
"changed", Encode.string data.changed
|
||||
"field_content", data.field_content |> Array.map paragraphEncoder |> Encode.array ]
|
||||
"field_content", Encode.option (fun arr -> arr |> Array.map paragraphEncoder |> Encode.array) data.field_content ]
|
||||
|
||||
let contentTypeDecoder: Decoder<ContentType> =
|
||||
Decode.field "type" Decode.string
|
||||
@@ -203,7 +208,7 @@ module ThothSerializerManual =
|
||||
let footerDecoder: Decoder<FooterBlock> =
|
||||
Decode.object (fun get ->
|
||||
{ ``type`` = get.Required.Field "type" Decode.string
|
||||
field_columns = get.Required.Field "field_columns" (Decode.array footerColumnDecoder) })
|
||||
field_columns = get.Required.Field "field_columns" (relationshipArray footerColumnDecoder) })
|
||||
|
||||
let blockDecoder: Decoder<Block> =
|
||||
Decode.field "type" Decode.string
|
||||
|
||||
@@ -99,7 +99,7 @@ module ThothTypes =
|
||||
path: Path
|
||||
created: string
|
||||
changed: string
|
||||
field_content: Paragraph[] }
|
||||
field_content: Paragraph[] option }
|
||||
|
||||
type LinkHref = { href: string }
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ open type prop
|
||||
|
||||
module BasicPage =
|
||||
let render (doc: BasicPage) =
|
||||
let paragraphs = doc.field_content |> Array.map Paragraph.render
|
||||
let paragraphs =
|
||||
match doc.field_content with
|
||||
| Some x -> x |> Array.map Paragraph.render
|
||||
| _ -> [||]
|
||||
|
||||
div [
|
||||
children (elems = paragraphs)
|
||||
|
||||
@@ -29,9 +29,7 @@ module EmployeePage =
|
||||
[ img
|
||||
[ src imageUrl
|
||||
alt imageAlt
|
||||
className "rounded-2xl shadow-xl w-full object-cover" ]
|
||||
div [ className "absolute -top-4 -left-4 w-10 h-10 bg-blue-400 rounded-lg" ]
|
||||
div [ className "absolute -bottom-4 -right-4 w-10 h-10 bg-blue-400 rounded-lg" ] ] ]
|
||||
className "rounded-2xl shadow-xl w-full object-cover" ] ] ]
|
||||
div
|
||||
[ children
|
||||
[ h2 [ className "text-4xl font-bold text-gray-900"; text employee.title ]
|
||||
|
||||
Reference in New Issue
Block a user