Brought over changes from zedshaw.games but need to do some more refinement.

master
Zed A. Shaw 4 days ago
parent 64af525bf8
commit 49946e7f26
  1. 52
      .air.toml
  2. 2
      .ozai.json
  3. 14
      Makefile
  4. 1
      admin/handlers.go
  5. 6
      api/auth.go
  6. 3
      api/handlers.go
  7. 8
      common/api.go
  8. 62
      common/web.go
  9. 6
      data/models.go
  10. 2
      go.mod
  11. 2
      pages/index.md
  12. 50
      static/input_style.css
  13. 103
      static/style.css
  14. 0
      tests/admin_ui_test.go
  15. 4
      views/admin/table/contents.html
  16. 2
      views/admin/table/view.html
  17. 23
      views/layouts/main.html

@ -1,52 +0,0 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "webapp"
cmd = "make build"
delay = 1000
exclude_dir = ["assets", "pages", "static", "views", "public", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 7001
enabled = true
proxy_port = 7002
[screen]
clear_on_rebuild = false
keep_scroll = true

@ -14,7 +14,7 @@
"tailwind": { "tailwind": {
"URL": "/tailwind", "URL": "/tailwind",
"Command": "tailwindcss", "Command": "tailwindcss",
"Args": ["--input", "./static/input_style.css", "--output", "./static/style.css","--watch"] "Args": ["--input", "./static/input_style.css", "--output", "./static/style.css","--watch=always"]
} }
} }
} }

@ -1,9 +1,3 @@
GO_IS_STUPID_EXE=
ifeq '$(OS)' 'Windows_NT'
GO_IS_STUPID_EXE=.exe
endif
all: build all: build
ifeq '$(OS)' 'Windows_NT' ifeq '$(OS)' 'Windows_NT'
powershell -ExecutionPolicy bypass "./tools/restart.ps1" powershell -ExecutionPolicy bypass "./tools/restart.ps1"
@ -18,12 +12,12 @@ site:
go tool ssgod go tool ssgod
test: site test: site
go test . -c -o runtests$(GO_IS_STUPID_EXE) go test MY/webapp/tests -c
./runtests$(GO_IS_STUPID_EXE) ./tests.test
test_only: test_only:
go test . -c -o runtests$(GO_IS_STUPID_EXE) go test . -c -o
./runtests$(GO_IS_STUPID_EXE) -test.run TestGamePage ./tests.test -test.run TestSomePage
migrate_up: migrate_up:
goose sqlite3 db.sqlite3 -dir migrations up goose sqlite3 db.sqlite3 -dir migrations up

@ -169,4 +169,5 @@ func Setup(app *fiber.App) {
app.Post("/api/admin/new/table/:table", PostApiInsert) app.Post("/api/admin/new/table/:table", PostApiInsert)
app.Get("/api/admin/table/:table/:id", GetApiSelectOne) app.Get("/api/admin/table/:table/:id", GetApiSelectOne)
app.Post("/api/admin/table/:table/:id", PostApiUpdate) app.Post("/api/admin/table/:table/:id", PostApiUpdate)
app.Delete("/api/admin/table/:table/:id", DeleteApi)
} }

@ -15,6 +15,12 @@ import (
. "MY/webapp/common" . "MY/webapp/common"
) )
func GetApiAuthCheck(c *fiber.Ctx) error {
_, err := CheckAuthed(c, false)
// auth failure or not authed is determined by err, with nil meaning YES AUTHED
return c.JSON(fiber.Map{"is_authed": err == nil})
}
func GetApiLogout(c *fiber.Ctx) error { func GetApiLogout(c *fiber.Ctx) error {
err := LogoutUser(c) err := LogoutUser(c)
if err != nil { return IfErrNil(err, c) } if err != nil { return IfErrNil(err, c) }

@ -20,9 +20,10 @@ func Setup(app *fiber.App) {
}) })
// api/auth.go // api/auth.go
app.Get("/api/authcheck", GetApiAuthCheck)
app.Get("/api/logout", GetApiLogout) app.Get("/api/logout", GetApiLogout)
app.Post("/api/login", PostApiLogin)
app.Post("/api/register", PostApiRegister) app.Post("/api/register", PostApiRegister)
app.Post("/api/login", PostApiLogin)
} }
func Shutdown() { func Shutdown() {

@ -48,7 +48,9 @@ func ReflectOnPost(typeOf reflect.Type, c *fiber.Ctx) (reflect.Value, error) {
result_val = reflect.New(typeOf) result_val = reflect.New(typeOf)
result := result_val.Interface() result := result_val.Interface()
if err := c.BodyParser(result); err != nil { if err := c.BodyParser(result)
err != nil {
log.Println(err); log.Println(err);
return result_val, err return result_val, err
} }
@ -56,7 +58,9 @@ func ReflectOnPost(typeOf reflect.Type, c *fiber.Ctx) (reflect.Value, error) {
var validate *validator.Validate var validate *validator.Validate
validate = validator.New(validator.WithRequiredStructEnabled()) validate = validator.New(validator.WithRequiredStructEnabled())
if err := validate.Struct(result); err != nil { err := validate.Struct(result)
if err != nil {
validationErrors := err.(validator.ValidationErrors) validationErrors := err.(validator.ValidationErrors)
log.Println(validationErrors) log.Println(validationErrors)
return result_val, err return result_val, err

@ -1,70 +1,14 @@
package common package common
import ( import (
"log"
"strings" "strings"
"io/fs"
"path/filepath"
"os"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/template/html/v2"
) )
func Page(path string) (func(c *fiber.Ctx) error) { func Page(path string) (func(c *fiber.Ctx) error) {
page_id := strings.ReplaceAll(path, "/", "-") + "-page"
return func (c *fiber.Ctx) error { return func (c *fiber.Ctx) error {
return c.Render(path, fiber.Map{}) return c.Render(path, fiber.Map{"PageId": page_id})
} }
} }
func RenderPages(pages_path string, target string, layout string) {
engine := html.New(pages_path, ".html")
engine.Load()
err := filepath.WalkDir(pages_path,
func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
if err != nil { return Fail(err, "path: %s", path); }
dir := filepath.Dir(path)
err = os.MkdirAll(dir, 0750)
if err != nil {
return Fail(err, "making dir %s", dir);
}
split_path := strings.Split(path, string(os.PathSeparator))[1:]
source_name := strings.Join(split_path, "/") // Render wants / even on windows
ext := filepath.Ext(source_name)
template_name, found := strings.CutSuffix(source_name, ext)
if found && ext == ".html" && template_name != layout {
prefixed_path := append([]string{target}, split_path...)
target_path := filepath.Join(prefixed_path...)
_, err := os.Stat(target_path)
if os.IsNotExist(err) {
target_dir := filepath.Dir(target_path)
log.Println("MAKING: ", target_dir)
os.MkdirAll(target_dir, 0750)
}
// TODO: compare time stamps and skip if not newer
out, err := os.OpenFile(target_path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil { return Fail(err, "writing file %s", target_path) }
// generate a data-testid for all pages based on template name
page_id := strings.ReplaceAll(template_name, "/", "-") + "-page"
err = engine.Render(out, template_name, fiber.Map{"PageId": page_id}, layout)
if err != nil { return Fail(err, "failed to render %s", path) }
log.Printf("RENDER: %s -> %s", template_name, target_path)
out.Close()
}
}
return nil
})
if err != nil { log.Fatalf("can't walk content") }
}

@ -11,7 +11,7 @@ type Login struct {
} }
type User struct { type User struct {
Id int `db:"id" json:"id" validate:"numeric"` Id int `db:"id" validate:"numeric"`
Username string `db:"username" validate:"required,max=30"` Username string `db:"username" validate:"required,max=30"`
Email string `db:"email" validate:"required,email,max=128"` Email string `db:"email" validate:"required,email,max=128"`
Password string `db:"password" validate:"required,min=8,max=64"` Password string `db:"password" validate:"required,min=8,max=64"`
@ -21,8 +21,8 @@ type User struct {
* Example of using the null library to do optional fields. * Example of using the null library to do optional fields.
*/ */
type NullExample struct { type NullExample struct {
Id int `db:"id" json:"id" validate:"numeric"` Id int `db:"id" validate:"numeric"`
HasMaybe null.Int `db:"replying_to" json:"replying_to" validate:"omitempty,numeric"` HasMaybe null.Int `db:"replying_to" validate:"omitempty,numeric"`
} }
func Models() map[string]reflect.Type { func Models() map[string]reflect.Type {

@ -1,6 +1,6 @@
module MY/webapp module MY/webapp
go 1.24.2 go 1.25.3
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0

@ -61,12 +61,14 @@ Then on Linux/OSX you want to delete the `.git` with this:
```shell ```shell
rm -rf .git rm -rf .git
mv LICENSE LICENSE.old # it's MIT
``` ```
And on Windows you use the ever more clear and totally easier: And on Windows you use the ever more clear and totally easier:
```shell ```shell
rm -recurse -force .git rm -recurse -force .git
mv LICENSE LICENSE.old # it's MIT
``` ```
Once you have that you can make it your own: Once you have that you can make it your own:

@ -6,15 +6,15 @@ body {
} }
main { main {
@apply flex flex-col gap-4 p-0 min-h-screen bg-gray-100 text-gray-950 dark:bg-gray-900 dark:text-gray-50; @apply flex flex-col justify-stretch items-center gap-4 p-0 min-h-screen bg-gray-200 text-black dark:bg-gray-900 dark:text-gray-50;
} }
header { header {
@apply flex flex-col justify-stretch; @apply flex flex-col justify-stretch items-center bg-gray-950;
} }
footer { footer {
@apply bg-gray-950 text-gray-50 text-lg flex p-1; @apply bg-gray-950 text-gray-50 text-lg flex justify-center p-1;
} }
@utility sticky-bottom { @utility sticky-bottom {
@ -22,7 +22,11 @@ footer {
} }
nav { nav {
@apply flex justify-center items-center bg-gray-950 *:text-gray-50 *:flex-1 *:text-xl w-full justify-evenly; @apply flex lg:w-4xl justify-center items-center *:text-gray-50 *:flex-1 *:text-xl w-full justify-evenly;
}
a {
@apply underline;
} }
nav > a { nav > a {
@ -44,23 +48,23 @@ pre > code {
h1 { h1 {
@apply text-6xl mb-2 mt-4; @apply text-5xl mb-2 mt-4 sm:text-6xl;
} }
h2 { h2 {
@apply text-5xl mb-2 mt-4; @apply text-4xl mb-2 mt-4 sm:text-5xl;
} }
h3 { h3 {
@apply text-4xl mb-2 mt-4; @apply text-3xl mb-2 mt-4 sm:text-4xl;
} }
h4 { h4 {
@apply text-3xl mb-2 mt-4; @apply text-2xl mb-2 mt-4 sm:text-3xl;
} }
h5 { h5 {
@apply text-2xl mb-2 mt-4; @apply text-2xl mb-2 mt-4 sm:text-2xl;
} }
details { details {
@ -68,7 +72,7 @@ details {
} }
aside { aside {
@apply p-2 rounded-lg bg-gray-100 text-gray-950; @apply flex rounded-none flex-col p-4 gap-4 bg-gray-300 text-gray-950 text-2xl dark:text-gray-50 font-semibold dark:bg-gray-800;
} }
aside > mark { aside > mark {
@ -187,8 +191,24 @@ block {
@apply flex flex-col p-4 gap-4; @apply flex flex-col p-4 gap-4;
} }
block.center-horizontal {
@apply items-center;
}
block.center-vertical {
@apply justify-center;
}
bar { bar {
@apply flex flex-row p-4 gap-4; @apply flex flex-col sm:flex-row p-4 gap-4;
}
bar.center-horizontal {
@apply justify-center;
}
bar.center-vertical {
@apply items-center;
} }
stack { stack {
@ -232,3 +252,11 @@ table {
@utility table-row { @utility table-row {
@apply *:border-2 *:border-black *:p-1 hover:bg-gray-100 hover:text-gray-950; @apply *:border-2 *:border-black *:p-1 hover:bg-gray-100 hover:text-gray-950;
} }
@utility two-panel {
@apply grid-rows-2 grid-cols-1 sm:grid-cols-2 sm:grid-rows-1;
}
@utility debug {
@apply !border-1 !border-red-900;
}

@ -36,6 +36,7 @@
--container-md: 28rem; --container-md: 28rem;
--container-lg: 32rem; --container-lg: 32rem;
--container-xl: 36rem; --container-xl: 36rem;
--container-4xl: 56rem;
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875); --text-sm--line-height: calc(1.25 / 0.875);
--text-lg: 1.125rem; --text-lg: 1.125rem;
@ -53,6 +54,7 @@
--text-6xl: 3.75rem; --text-6xl: 3.75rem;
--text-6xl--line-height: 1; --text-6xl--line-height: 1;
--font-weight-light: 300; --font-weight-light: 300;
--font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.375rem;
@ -456,6 +458,9 @@
.bg-green-400\! { .bg-green-400\! {
background-color: var(--color-green-400) !important; background-color: var(--color-green-400) !important;
} }
.\!p-1 {
padding: calc(var(--spacing) * 1) !important;
}
.\!p-4 { .\!p-4 {
padding: calc(var(--spacing) * 4) !important; padding: calc(var(--spacing) * 4) !important;
} }
@ -582,6 +587,16 @@
} }
} }
} }
.sm\:p-0 {
@media (width >= 40rem) {
padding: calc(var(--spacing) * 0);
}
}
.lg\:max-w-4xl {
@media (width >= 64rem) {
max-width: var(--container-4xl);
}
}
.dark\:bg-gray-900 { .dark\:bg-gray-900 {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
background-color: var(--color-gray-900); background-color: var(--color-gray-900);
@ -604,10 +619,12 @@ main {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: stretch;
gap: calc(var(--spacing) * 4); gap: calc(var(--spacing) * 4);
background-color: var(--color-gray-100); background-color: var(--color-gray-200);
padding: calc(var(--spacing) * 0); padding: calc(var(--spacing) * 0);
color: var(--color-gray-950); color: var(--color-black);
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
background-color: var(--color-gray-900); background-color: var(--color-gray-900);
} }
@ -618,10 +635,13 @@ main {
header { header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: stretch; justify-content: stretch;
background-color: var(--color-gray-950);
} }
footer { footer {
display: flex; display: flex;
justify-content: center;
background-color: var(--color-gray-950); background-color: var(--color-gray-950);
padding: calc(var(--spacing) * 1); padding: calc(var(--spacing) * 1);
font-size: var(--text-lg); font-size: var(--text-lg);
@ -634,7 +654,6 @@ nav {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
justify-content: space-evenly; justify-content: space-evenly;
background-color: var(--color-gray-950);
:is(& > *) { :is(& > *) {
flex: 1; flex: 1;
} }
@ -645,6 +664,12 @@ nav {
:is(& > *) { :is(& > *) {
color: var(--color-gray-50); color: var(--color-gray-50);
} }
@media (width >= 64rem) {
width: var(--container-4xl);
}
}
a {
text-decoration-line: underline;
} }
nav > a { nav > a {
display: flex; display: flex;
@ -671,34 +696,54 @@ pre > code {
padding: calc(var(--spacing) * 1); padding: calc(var(--spacing) * 1);
} }
h1 { h1 {
margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 2);
font-size: var(--text-6xl);
line-height: var(--tw-leading, var(--text-6xl--line-height));
}
h2 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
font-size: var(--text-5xl); font-size: var(--text-5xl);
line-height: var(--tw-leading, var(--text-5xl--line-height)); line-height: var(--tw-leading, var(--text-5xl--line-height));
@media (width >= 40rem) {
font-size: var(--text-6xl);
line-height: var(--tw-leading, var(--text-6xl--line-height));
}
} }
h3 { h2 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
font-size: var(--text-4xl); font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height)); line-height: var(--tw-leading, var(--text-4xl--line-height));
@media (width >= 40rem) {
font-size: var(--text-5xl);
line-height: var(--tw-leading, var(--text-5xl--line-height));
}
} }
h4 { h3 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
font-size: var(--text-3xl); font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height)); line-height: var(--tw-leading, var(--text-3xl--line-height));
@media (width >= 40rem) {
font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height));
}
}
h4 {
margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 2);
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
@media (width >= 40rem) {
font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height));
}
} }
h5 { h5 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
font-size: var(--text-2xl); font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height)); line-height: var(--tw-leading, var(--text-2xl--line-height));
@media (width >= 40rem) {
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
}
} }
details { details {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@ -709,10 +754,23 @@ details {
color: var(--color-gray-950); color: var(--color-gray-950);
} }
aside { aside {
border-radius: var(--radius-lg); display: flex;
background-color: var(--color-gray-100); flex-direction: column;
padding: calc(var(--spacing) * 2); gap: calc(var(--spacing) * 4);
border-radius: 0;
background-color: var(--color-gray-300);
padding: calc(var(--spacing) * 4);
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
color: var(--color-gray-950); color: var(--color-gray-950);
@media (prefers-color-scheme: dark) {
background-color: var(--color-gray-800);
}
@media (prefers-color-scheme: dark) {
color: var(--color-gray-50);
}
} }
aside > mark { aside > mark {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@ -896,11 +954,26 @@ block {
gap: calc(var(--spacing) * 4); gap: calc(var(--spacing) * 4);
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
block.center-horizontal {
align-items: center;
}
block.center-vertical {
justify-content: center;
}
bar { bar {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
gap: calc(var(--spacing) * 4); gap: calc(var(--spacing) * 4);
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
@media (width >= 40rem) {
flex-direction: row;
}
}
bar.center-horizontal {
justify-content: center;
}
bar.center-vertical {
align-items: center;
} }
stack { stack {
display: grid; display: grid;

@ -19,10 +19,10 @@
<th x-text="header"></th> <th x-text="header"></th>
</template> </template>
</tr> </tr>
<template x-for="item in contents" :key="item.id"> <template x-for="item in contents" :key="item.Id">
<tr class="table-row"> <tr class="table-row">
<td> <td>
<a x-bind:href="'/admin/table/{{ .Table }}/' + item.id + '/'"> <a x-bind:href="'/admin/table/{{ .Table }}/' + item.Id + '/'">
# #
</a> </a>
</td> </td>

@ -16,7 +16,7 @@
</middle> </middle>
<bottom> <bottom>
<button type="button"><a href="/admin/table/{{ .Table }}/">Back</a></button> <button type="button"><a href="/admin/table/{{ .Table }}/">Back</a></button>
<button class="hover:btn-alert" type="button" @click.prevent="ConfirmDelete('{{ .Table }}', item.id)">Delete</button> <button class="hover:btn-alert" type="button" @click.prevent="ConfirmDelete('{{ .Table }}', item.Id)">Delete</button>
<button class="hover:btn-hover" type="submit">Update</button> <button class="hover:btn-hover" type="submit">Update</button>
</bottom> </bottom>
</card> </card>

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta name="viewport" content="initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="Zed A. Shaw" /> <meta name="author" content="Zed A. Shaw" />
<meta name="description" content="My Go learning project, which is a Twitch support thing." /> <meta name="description" content="My Go learning project, which is a Twitch support thing." />
<link rel="icon" href="/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/favicon.ico" type="image/x-icon" />
@ -16,7 +16,8 @@
<title>Go Web Dev Starter Kit</title> <title>Go Web Dev Starter Kit</title>
</head> </head>
<body id="top" data-testid="{{.PageId}}"> <body id="top" data-testid="{{.PageId}}">
<header> <header x-data="{auth: false}"
x-init="auth = await GetJson('/api/authcheck')">
<nav> <nav>
<a id="home" href="/"> <a id="home" href="/">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
@ -34,19 +35,19 @@
<use href="/icons/user-plus.svg#img" /> <use href="/icons/user-plus.svg#img" />
</svg> </svg>
</a> </a>
<a id="login" href="/login/"> <template x-if="auth.is_authed">
<svg xmlns="http://www.w3.org/2000/svg" <a id="logout" href="/api/logout">Logout</a>
width="2rem" </template>
height="2rem" <template x-if="!auth.is_authed">
viewBox="0 0 2rem 2rem"> <a id="login" href="/login/">Login</a>
<use href="/icons/log-in.svg#img" /> </template>
</svg>
</a>
</nav> </nav>
</header> </header>
<main> <main>
{{embed}} <block class="w-full lg:max-w-4xl center-horizontal !p-1 sm:p-0">
{{embed}}
</block>
</main> </main>
<footer class="sticky-bottom"> <footer class="sticky-bottom">

Loading…
Cancel
Save