feat: Add new admin ui only for drupal and small tweaks

This commit is contained in:
ismaelabujadur
2025-12-18 15:38:59 +01:00
parent dad9a996cf
commit 1f123e8e35
13 changed files with 283 additions and 149 deletions

View File

@@ -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:

View File

@@ -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
View File

@@ -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",

View File

@@ -0,0 +1,4 @@
_core:
default_config_hash: L_rAiCIVCIoTgqIZ9JZC5h_zZV5NyxvGNjcfXGFBIRY
routes: { }
error_code: 403

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" ] ] ] ] ]

View File

@@ -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

View File

@@ -99,7 +99,7 @@ module ThothTypes =
path: Path
created: string
changed: string
field_content: Paragraph[] }
field_content: Paragraph[] option }
type LinkHref = { href: string }

View File

@@ -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)

View File

@@ -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 ]