mirror of
https://codeberg.org/readeck/readeck.git
synced 2025-12-22 13:17:10 +00:00
Merge pull request 'Extend bookmark update' (#993) from feature/bookmark-update-fields into main
Reviewed-on: https://codeberg.org/readeck/readeck/pulls/993
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
- TOTP support
|
||||
- Forwarded authentication
|
||||
- mTLS for HTTPS listener
|
||||
- Bookmark properties editor
|
||||
|
||||
## [0.21.4] - 2025-12-09
|
||||
### Added
|
||||
|
||||
@@ -92,6 +92,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
inputClass=inputClass) }}
|
||||
{{- end -}}
|
||||
|
||||
{{- block textAreaField(
|
||||
field, value=nil, type="text", name, label, class="", required=false, help,
|
||||
controlAttrs=attrList(), inputAttrs=attrList(), inputClass="form-textarea w-full",
|
||||
rows=3
|
||||
) -}}
|
||||
{{ inputAttrs.Set("rows", rows) }}
|
||||
{{- if value == nil -}}
|
||||
{{ value = field.String() }}
|
||||
{{- end -}}
|
||||
{{- if required -}}
|
||||
{{ inputAttrs.Set("required", true) }}
|
||||
{{- end -}}
|
||||
{{- yield ariaAttrs(attrs=inputAttrs, field=field, name=name, help=help) -}}
|
||||
|
||||
{{- yield formField(field=field, name=name, label=label, help=help, class=class,
|
||||
required=required, controlAttrs=controlAttrs) content -}}
|
||||
<textarea id="{{ name }}" name="{{ name }}" class="{{ inputClass }}" {{ inputAttrs }}>
|
||||
{{- value -}}
|
||||
</textarea>
|
||||
{{- yield content -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- block checkboxField(
|
||||
field, checked=nil, name, label, class="", help
|
||||
|
||||
@@ -46,8 +46,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<form class="hidden"
|
||||
data-controller="request"
|
||||
data-request-turbo-value="1"
|
||||
data-request-url-value="{{ urlFor(`/api/bookmarks`, .Item.ID) }}"
|
||||
data-request-method-value="patch"
|
||||
data-request-url-value="{{ urlFor(`/bookmarks`, .Item.ID, `update`) }}"
|
||||
data-request-method-value="post"
|
||||
data-scroll-progress-target="trigger"
|
||||
data-action="scroll-progress:progress->request#fetch"
|
||||
>
|
||||
@@ -106,7 +106,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
{{- yield icon(name="o-chevron-l", class="inline-block", svgClass="w-6 h-6") -}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
<a href="{{ urlFor(`/bookmarks`, .Item.UID, `update`) }}"
|
||||
data-controller="dialog"
|
||||
data-dialog-select-value="#bookmark-update-dialog"
|
||||
data-action="scroll-progress#skip dialog#open:prevent">
|
||||
{{- yield icon(name="o-pencil", class="inline-block", svgClass="w-6 h-6") -}}
|
||||
<span class="sr-only">{{- gettext("Edit") -}}</span>
|
||||
</a>
|
||||
|
||||
{{- if .Item.HasArticle -}}
|
||||
{{- include "./components/reader_control" -}}
|
||||
{{- end -}}
|
||||
@@ -134,12 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
{* title and description *}
|
||||
<div class="bookmark-header mt-4 print:mt-2 print:max-w-none mb-8 {{ preferences.ReaderWidth().Class }} {{ preferences.ReaderFont().Class }}"
|
||||
data-controller="styler">
|
||||
{{ include "./components/title_form" .Item }}
|
||||
|
||||
{{- if !empty(.Item.Description) && empty(.Item.OmitDescription) -}}
|
||||
<p class="mt-2 text-lg leading-tight text-justify italic"
|
||||
dir="{{ default(.Item.TextDirection, `ltr`) }}">{{ .Item.Description }}</p>
|
||||
{{- end -}}
|
||||
{{ include "./components/title_block" }}
|
||||
</div>
|
||||
|
||||
{* content *}
|
||||
@@ -156,7 +160,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</turbo-frame>
|
||||
</div>
|
||||
|
||||
<dialog class="dialog font-sans" id="debug-info">
|
||||
<dialog class="dialog" id="debug-info">
|
||||
<form class="dialog--top" method="dialog">
|
||||
<button class="dialog--close" title="{{ gettext(`Close dialog`) }}">
|
||||
{{ yield icon(name="o-close", class="", svgClass="h-8 w-8") }}
|
||||
|
||||
20
assets/templates/bookmarks/bookmark_update.jet.html
Normal file
20
assets/templates/bookmarks/bookmark_update.jet.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{*
|
||||
SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*}
|
||||
{{ extends "./base" }}
|
||||
{{ import "/_libs/forms" }}
|
||||
|
||||
{{- block title() -}}{{ gettext("Edit bookmark") }}{{- end -}}
|
||||
|
||||
{{- block mainContent() -}}
|
||||
<div class="max-w-std">
|
||||
{{ yield breadcrumbs() }}
|
||||
<h1 class="text-h2 title">{{ gettext("Edit: %s", .Title) }}</h1>
|
||||
{{ include "./components/bookmark_update" }}
|
||||
<p class="btn-block">
|
||||
<button class="btn btn-primary" type="submit" form="bookmark-update-form">{{ gettext("Save") }}</button>
|
||||
</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
@@ -14,20 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span class="text-red-700" data-deleted="true">
|
||||
{{ gettext("This bookmark will be removed in a few seconds.") }}
|
||||
</span>
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `delete`) }}" method="post">
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `update`) }}" method="post">
|
||||
<input type="hidden" name="cancel" value="1" />
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<button class="btn btn-primary ml-2" type="submit" name="is_deleted" value="0"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch">{{ yield icon(name="o-undo") }} {{ gettext("Undo") }}</button>
|
||||
data-controller="turbo-form">
|
||||
{{- yield icon(name="o-undo") }} {{ gettext("Undo") -}}
|
||||
</button>
|
||||
</form>
|
||||
{{- else -}}
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID) }}" method="post"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch"
|
||||
class="relative">
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `update`) }}" method="post"
|
||||
data-controller="turbo-form"
|
||||
class="relative">
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<div class="btn-group btn-primary rounded-full">
|
||||
<button class="btn-outlined btn-primary pl-3" name="read_progress"
|
||||
value="{{ .ReadProgress == 100 ? 0 : 100 }}"
|
||||
@@ -70,15 +69,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `delete`) }}" method="post"
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `update`) }}" method="post"
|
||||
class="ml-auto">
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<button class="btn-outlined btn-danger leading-none rounded-full w-9 h-9 px-2" name="is_deleted" value="1"
|
||||
title="{{ gettext(`Delete this bookmark`) }}"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch">
|
||||
{{ yield icon(name="o-trash") }}
|
||||
>
|
||||
{{- yield icon(name="o-trash") -}}
|
||||
</button>
|
||||
</form>
|
||||
{{- end -}}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{*
|
||||
SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*}
|
||||
{{- import "/_libs/common" -}}
|
||||
{{ import "/_libs/forms" }}
|
||||
|
||||
<turbo-frame id="bookmark-update-{{ .ID }}">
|
||||
<form id="bookmark-update-form" method="post" action="{{ urlFor(`/bookmarks`, .ID, `update`) }}">
|
||||
{{ yield formErrors(form=.Form) }}
|
||||
|
||||
{{- yield textField(
|
||||
field=.Form.Get("title"),
|
||||
required=true,
|
||||
label=gettext("Title:"),
|
||||
class="field-h",
|
||||
) -}}
|
||||
{{- yield textAreaField(
|
||||
field=.Form.Get("description"),
|
||||
label=gettext("Description:"),
|
||||
rows=5,
|
||||
class="field-h",
|
||||
) -}}
|
||||
{{- yield textField(
|
||||
field=.Form.Get("site_name"),
|
||||
required=true,
|
||||
label=gettext("Site Name:"),
|
||||
class="field-h",
|
||||
) -}}
|
||||
{{- yield textAreaField(
|
||||
field=.Form.Get("authors"),
|
||||
value=join(.Form.Get("authors").V(), "\n"),
|
||||
label=gettext("Authors:"),
|
||||
help=gettext("One value per line"),
|
||||
rows=3,
|
||||
class="field-h",
|
||||
) -}}
|
||||
{{- yield dateField(
|
||||
field=.Form.Get("published"),
|
||||
label=gettext("Published:"),
|
||||
class="field-h",
|
||||
) -}}
|
||||
{{- yield textField(
|
||||
field=.Form.Get("lang"),
|
||||
label=gettext("Language:"),
|
||||
class="field-h",
|
||||
) -}}
|
||||
{{- yield selectField(
|
||||
field=.Form.Get("text_direction"),
|
||||
label=gettext("Text Direction:"),
|
||||
class="field-h",
|
||||
) -}}
|
||||
</form>
|
||||
</turbo-frame>
|
||||
@@ -8,11 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
{{- textFav := .IsMarked ? gettext("Remove from favorites") : gettext("Add to favorites") -}}
|
||||
|
||||
<turbo-frame id="bookmark-bottom-actions-{{ .ID }}">
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID) }}" method="post"
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `update`) }}" method="post"
|
||||
class="flex flex-row max-sm:flex-col justify-center"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch">
|
||||
data-controller="turbo-form">
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<button class="btn-outlined btn-primary rounded-full whitespace-nowrap mb-0 mr-4 max-sm:mr-0 max-sm:mb-2"
|
||||
name="is_marked" value="{{ .IsMarked ? 0 : 1 }}">
|
||||
{{ yield icon(name=(.IsMarked ? "o-favorite-on" : "o-favorite-off")) }}
|
||||
|
||||
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
data-controller="turbo-refresh"
|
||||
data-turbo-refresh-interval-value="2"
|
||||
data-turbo-refresh-on-value="[data-loading]"
|
||||
data-turbo-refresh-src-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-refresh-src-value="{{ urlFor(`/bookmarks`, .ID, `card`) }}"
|
||||
{{ end }}
|
||||
{{- if .IsDeleted }}
|
||||
data-bookmark-deleted="true"
|
||||
@@ -70,7 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div class="bookmark-card--meta">
|
||||
<strong title="{{ .SiteName }}"
|
||||
dir="{{ default(.TextDirection, `ltr`) }}">{{ shortText(.SiteName, 50) }}</strong>
|
||||
dir="{{ default(.TextDirection, `ltr`) }}">{{ shortText(.SiteName, 50) }}</strong>
|
||||
{{- if .ReadingTime > 0 -}}
|
||||
<span> • {{npgettext("abbr", "%d min", "%d min", .ReadingTime, .ReadingTime)}}</span>
|
||||
{{- end -}}
|
||||
@@ -91,29 +91,27 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span>
|
||||
{{ gettext("This bookmark will be removed in a few seconds.") }}
|
||||
</span>
|
||||
<form action="{{ _url }}" method="post">
|
||||
<form action="{{ _url }}/update" method="post">
|
||||
<input type="hidden" name="cancel" value="1" />
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<button type="submit" name="is_deleted" value="0"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch">{{ yield icon(name="o-undo") }} {{ gettext("Cancel") }}</button>
|
||||
data-controller="turbo-form">
|
||||
{{- yield icon(name="o-undo") }} {{ gettext("Cancel") -}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{- else -}}
|
||||
<div class="bookmark-card--actions">
|
||||
<form action="{{ _url }}" method="post"
|
||||
data-controller="turbo-form turbo-reload"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch">
|
||||
<form action="{{ _url }}/update" method="post"
|
||||
data-controller="turbo-form turbo-reload">
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
{{- if .Loaded -}}
|
||||
<button name="is_marked" value="{{ .IsMarked ? 0 : 1 }}"
|
||||
title="{{ textFav }}">
|
||||
title="{{ textFav }}">
|
||||
{{ yield icon(name=(.IsMarked ? "o-favorite-on" : "o-favorite-off")) }}
|
||||
</button>
|
||||
<button name="is_archived" value="{{ .IsArchived ? 0 : 1 }}"
|
||||
title="{{ textArch }}">
|
||||
title="{{ textArch }}">
|
||||
{{ yield icon(name=(.IsArchived ? "o-archive-on" : "o-archive-off")) }}
|
||||
</button>
|
||||
{{- end -}}
|
||||
@@ -122,12 +120,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `delete`) }}" method="post">
|
||||
<form action="{{ _url }}/update" method="post">
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<button name="is_deleted" value="1"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch"
|
||||
title="{{ gettext(`Delete this bookmark`) }}">
|
||||
{{ yield icon(name="o-trash") }}
|
||||
</button>
|
||||
|
||||
@@ -31,11 +31,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
{{- if .Item.HasArticle -}}
|
||||
<turbo-frame id="bookmark-content-{{ .Item.ID }}"
|
||||
src="{{ urlFor(`/api/bookmarks`, .Item.ID) }}/article"
|
||||
src="{{ urlFor(`/api/bookmarks`, .Item.ID, `article`) }}"
|
||||
disabled
|
||||
data-controller="annotations"
|
||||
data-action="request:annotation-removed@document->annotations#reload"
|
||||
data-annotations-api-url-value="{{ urlFor(`/api/bookmarks`, .Item.ID) }}/annotations"
|
||||
data-annotations-api-url-value="{{ urlFor(`/api/bookmarks`, .Item.ID, `annotations`) }}"
|
||||
data-annotations-hidden-class="hidden"
|
||||
>
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ SPDX-FileCopyrightText: © 2021 Olivier Meunier <olivier@neokraft.net>
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*}
|
||||
<turbo-frame id="bookmark-labels-{{ .ID }}" class="group block mb-4">
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID) }}" method="post"
|
||||
data-controller="turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch">
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID, `update`) }}" method="post"
|
||||
data-controller="turbo-form">
|
||||
<input type="hidden" name="_to" value="{{ currentPath }}" />
|
||||
<button type="submit" class="hidden"></button>
|
||||
<div>
|
||||
|
||||
33
assets/templates/bookmarks/components/title_block.jet.html
Normal file
33
assets/templates/bookmarks/components/title_block.jet.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{*
|
||||
SPDX-FileCopyrightText: © 2022 Olivier Meunier <olivier@neokraft.net>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*}
|
||||
|
||||
{{ import "/_libs/common" }}
|
||||
|
||||
<turbo-frame id="bookmark-title-{{ .Item.ID }}" class="print:hidden leading-normal">
|
||||
<h1 class="text-3xl max-sm:text-2xl">{{ .Item.Title }}</h1>
|
||||
{{- if !empty(.Item.Description) && empty(.Item.OmitDescription) -}}
|
||||
<p class="mt-2 text-lg leading-tight text-justify italic"
|
||||
dir="{{ default(.Item.TextDirection, `ltr`) }}">{{ .Item.Description }}</p>
|
||||
{{- end -}}
|
||||
|
||||
<dialog class="dialog max-w-std" id="bookmark-update-dialog" data-turbo="true">
|
||||
<form class="dialog--top" method="dialog">
|
||||
<button class="dialog--close" title="{{ gettext(`Close dialog`) }}">
|
||||
{{ yield icon(name="o-close", class="", svgClass="h-8 w-8") }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="dialog--content">
|
||||
<turbo-frame id="bookmark-update-{{ .Item.ID }}" src="{{ urlFor(`/bookmarks`, .Item.ID, `update`) }}" loading="lazy">
|
||||
</turbo-frame>
|
||||
</div>
|
||||
|
||||
<div class="dialog--footer">
|
||||
<button class="btn btn-primary ml-auto" form="bookmark-update-form" type="submit">{{ gettext("Save") }}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
</turbo-frame>
|
||||
@@ -1,29 +0,0 @@
|
||||
{*
|
||||
SPDX-FileCopyrightText: © 2022 Olivier Meunier <olivier@neokraft.net>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*}
|
||||
{{- import "/_libs/common" -}}
|
||||
|
||||
<turbo-frame id="bookmark-title-{{ .ID }}" class="print:hidden leading-normal">
|
||||
<form action="{{ urlFor(`/bookmarks`, .ID) }}" method="post"
|
||||
class="flex gap-2 items-baseline group"
|
||||
data-controller="inplace-input turbo-form"
|
||||
data-turbo-form-action-value="{{ urlFor(`/api/bookmarks`, .ID) }}"
|
||||
data-turbo-form-method-value="patch"
|
||||
data-inplace-input-hidden-class="hidden">
|
||||
|
||||
<h1 class="no-js:hidden text-3xl max-sm:text-2xl flex-grow mr-2 cursor-pointer hover:outline-std"
|
||||
dir="{{ default(.TextDirection, `ltr`) }}"
|
||||
data-inplace-input-target="editable">{{ .Title }}</h1>
|
||||
{* Classic input field shown in no-JS *}
|
||||
<input type="text" name="title" value="{{ .Title }}"
|
||||
class="js:hidden form-input font-semibold min-w-full flex-grow"
|
||||
data-inplace-input-target="value" />
|
||||
<button type="submit" class="text-h2 text-gray-300 group-hover:text-primary group-fw:text-primary"
|
||||
data-inplace-input-target="button">
|
||||
{{- yield icon(name="o-pencil", svgClass="h-6 w-6", attrs=attrList("data-inplace-input-target", "iconOff")) -}}
|
||||
{{- yield icon(name="o-check-on", class="hidden", svgClass="h-6 w-6", attrs=attrList("data-inplace-input-target", "iconOn")) -}}
|
||||
</button>
|
||||
</form>
|
||||
</turbo-frame>
|
||||
@@ -155,6 +155,13 @@ Removable message
|
||||
class="field-h",
|
||||
) }}
|
||||
|
||||
{{ yield textAreaField(
|
||||
field=.Form.Get("text"),
|
||||
name="textarea",
|
||||
label="Textarea",
|
||||
class="field-h",
|
||||
) }}
|
||||
|
||||
{{ yield selectField(
|
||||
field=.Form.Get("select"),
|
||||
name="select",
|
||||
|
||||
@@ -63,7 +63,7 @@ schemas:
|
||||
description: Language Code
|
||||
text_direction:
|
||||
type: string
|
||||
enum: [rtl, ltr]
|
||||
enum: [ltr, rtl]
|
||||
description: |
|
||||
Direction of the article's text. It can be empty when it's unknown.
|
||||
document_type:
|
||||
@@ -306,6 +306,29 @@ schemas:
|
||||
type: string
|
||||
maxLength: 1024
|
||||
description: New bookmark's title
|
||||
description:
|
||||
type: string
|
||||
description: Bookmark's description
|
||||
site_name:
|
||||
type: string
|
||||
description: Bookmark's site name
|
||||
authors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Bookmark's author list
|
||||
published:
|
||||
type: [string, "null"]
|
||||
format: date-time
|
||||
description: Bookmark's publication date
|
||||
lang:
|
||||
type: string
|
||||
description: Language Code
|
||||
text_direction:
|
||||
type: string
|
||||
enum: [ltr, rtl]
|
||||
description: |
|
||||
Direction of the article's text.
|
||||
is_marked:
|
||||
type: boolean
|
||||
description: Favortie state
|
||||
|
||||
@@ -50,13 +50,6 @@ func (api *apiRouter) bookmarkInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
item.Errors = b.Errors
|
||||
|
||||
if server.IsTurboRequest(r) {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/card", "replace",
|
||||
"bookmark-card-"+b.UID, item, nil)
|
||||
return
|
||||
}
|
||||
|
||||
server.Render(w, r, http.StatusOK, item)
|
||||
}
|
||||
|
||||
@@ -231,47 +224,13 @@ func (api *apiRouter) bookmarkUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated["href"] = urls.AbsoluteURL(r).String()
|
||||
|
||||
// On a turbo request, we'll return the updated components.
|
||||
if server.IsTurboRequest(r) {
|
||||
item := dataset.NewBookmark(r.Context(), b)
|
||||
|
||||
_, withTitle := updated["title"]
|
||||
_, withLabels := updated["labels"]
|
||||
_, withMarked := updated["is_marked"]
|
||||
_, withArchived := updated["is_archived"]
|
||||
_, withDeleted := updated["is_deleted"]
|
||||
_, withProgress := updated["read_progress"]
|
||||
|
||||
if withTitle {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/title_form", "replace",
|
||||
"bookmark-title-"+b.UID, item, nil)
|
||||
}
|
||||
if withLabels {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/labels", "replace",
|
||||
"bookmark-label-list-"+b.UID, item, nil)
|
||||
}
|
||||
if withMarked || withArchived || withDeleted || withProgress {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/actions", "replace",
|
||||
"bookmark-actions-"+b.UID, item, nil)
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/card", "replace",
|
||||
"bookmark-card-"+b.UID, item, nil)
|
||||
}
|
||||
if withMarked || withArchived {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/bottom_actions", "replace",
|
||||
"bookmark-bottom-actions-"+b.UID, item, nil)
|
||||
}
|
||||
// We don't want to render any content on a turbo request.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add(
|
||||
"Location",
|
||||
updated["href"].(string),
|
||||
)
|
||||
w.Header().Add("Location", updated["href"].(string))
|
||||
server.Render(w, r, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
goquexp "github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/wneessen/go-mail"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"codeberg.org/readeck/readeck/internal/auth"
|
||||
"codeberg.org/readeck/readeck/internal/auth/users"
|
||||
@@ -202,7 +203,30 @@ type updateForm struct {
|
||||
func newUpdateForm(tr forms.Translator) *updateForm {
|
||||
return &updateForm{forms.Must(
|
||||
forms.WithTranslator(context.Background(), tr),
|
||||
forms.NewTextField("title", forms.Trim, forms.MaxLen(1024)),
|
||||
forms.NewTextField("title", forms.Trim, forms.RequiredOrNil, forms.MaxLen(1024)),
|
||||
forms.NewTextField("description", forms.Trim),
|
||||
forms.NewTextField("site_name", forms.Trim, forms.RequiredOrNil),
|
||||
forms.NewTextListField("authors", forms.Trim,
|
||||
forms.DiscardEmpty, forms.SplitLines,
|
||||
),
|
||||
forms.NewDatetimeField("published", forms.Trim),
|
||||
forms.NewTextField("lang", forms.Trim, forms.CleanerFunc(func(v any) any {
|
||||
if v, ok := v.(string); ok {
|
||||
return strings.ToLower(v)
|
||||
}
|
||||
return v
|
||||
}), forms.TypedValidator(func(v string) bool {
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
_, err := language.Parse(v)
|
||||
return err == nil
|
||||
}, forms.Gettext("invalid language code"))),
|
||||
forms.NewTextField("text_direction", forms.Choices(
|
||||
forms.Choice("", ""),
|
||||
forms.Choice(tr.Gettext("Left to right"), "ltr"),
|
||||
forms.Choice(tr.Gettext("Right to left"), "rtl"),
|
||||
)),
|
||||
forms.NewBooleanField("is_marked"),
|
||||
forms.NewBooleanField("is_archived"),
|
||||
forms.NewBooleanField("is_deleted"),
|
||||
@@ -215,21 +239,48 @@ func newUpdateForm(tr forms.Translator) *updateForm {
|
||||
)}
|
||||
}
|
||||
|
||||
// nolint:gocyclo
|
||||
func (f *updateForm) update(b *bookmarks.Bookmark) (updated map[string]interface{}, err error) {
|
||||
updated = map[string]interface{}{}
|
||||
var deleted *bool
|
||||
labelsChanged := false
|
||||
|
||||
for _, field := range f.Fields() {
|
||||
if field.Name() == "published" && field.IsBound() && field.IsEmpty() {
|
||||
b.Published = nil
|
||||
updated["published"] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if !field.IsBound() || field.IsNil() {
|
||||
continue
|
||||
}
|
||||
switch n := field.Name(); n {
|
||||
case "title":
|
||||
if field.String() != "" {
|
||||
b.Title = utils.NormalizeSpaces(field.String())
|
||||
updated[n] = field.String()
|
||||
b.Title = utils.NormalizeSpaces(field.String())
|
||||
updated[n] = field.String()
|
||||
case "description":
|
||||
b.Description = utils.NormalizeSpaces(field.String())
|
||||
updated[n] = field.String()
|
||||
case "site_name":
|
||||
b.SiteName = utils.NormalizeSpaces(field.String())
|
||||
updated[n] = field.String()
|
||||
case "published":
|
||||
d := field.(*forms.DatetimeField).V()
|
||||
if !d.IsZero() {
|
||||
d = d.UTC()
|
||||
b.Published = &d
|
||||
updated[n] = d
|
||||
}
|
||||
case "lang":
|
||||
b.Lang = field.String()
|
||||
updated[n] = b.Lang
|
||||
case "text_direction":
|
||||
b.TextDirection = field.String()
|
||||
updated[n] = b.TextDirection
|
||||
case "authors":
|
||||
b.Authors = field.(*forms.TextListField).V()
|
||||
updated[n] = b.Authors
|
||||
case "is_marked":
|
||||
b.IsMarked = field.(forms.TypedField[bool]).V()
|
||||
updated[n] = field.Value()
|
||||
@@ -282,11 +333,20 @@ func (f *updateForm) update(b *bookmarks.Bookmark) (updated map[string]interface
|
||||
}()
|
||||
|
||||
if len(updated) > 0 || deleted != nil {
|
||||
if _, ok := updated["text_direction"]; ok {
|
||||
updated["dir"] = updated["text_direction"]
|
||||
delete(updated, "text_direction")
|
||||
}
|
||||
|
||||
updated["updated"] = time.Now().UTC()
|
||||
if err = b.Update(updated); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := updated["dir"]; ok {
|
||||
updated["text_direction"] = updated["dir"]
|
||||
delete(updated, "dir")
|
||||
}
|
||||
}
|
||||
|
||||
if deleted != nil {
|
||||
|
||||
@@ -189,6 +189,7 @@ func newViewsRouter(api *apiRouter) *viewsRouter {
|
||||
api.withBookmark,
|
||||
).Route("/{uid:[a-zA-Z0-9]{18,22}}", func(r chi.Router) {
|
||||
r.Get("/", h.bookmarkInfo)
|
||||
r.Get("/card", h.bookmarkCard)
|
||||
r.Get("/diagnosis", h.diagnosis)
|
||||
r.With(server.WithPermission("bookmarks", "export")).Route(
|
||||
"/share", func(r chi.Router) {
|
||||
@@ -226,8 +227,8 @@ func newViewsRouter(api *apiRouter) *viewsRouter {
|
||||
r.With(h.withBaseContext, api.withDefaultLimit(listDefaultLimit)).Group(func(r chi.Router) {
|
||||
r.With(api.withBookmarkList).Post("/", h.bookmarkList)
|
||||
r.With(api.withBookmark).Group(func(r chi.Router) {
|
||||
r.Post("/{uid:[a-zA-Z0-9]{18,22}}", h.bookmarkUpdate)
|
||||
r.Post("/{uid:[a-zA-Z0-9]{18,22}}/delete", h.bookmarkDelete)
|
||||
r.Get("/{uid:[a-zA-Z0-9]{18,22}}/update", h.bookmarkUpdate)
|
||||
r.Post("/{uid:[a-zA-Z0-9]{18,22}}/update", h.bookmarkUpdate)
|
||||
})
|
||||
|
||||
r.With(api.withLabel, api.withBookmarkList).Group(func(r chi.Router) {
|
||||
|
||||
@@ -345,6 +345,21 @@ func TestPermissions(t *testing.T) {
|
||||
}
|
||||
}),
|
||||
),
|
||||
RT(
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID+"/card"),
|
||||
WithHeader("x-turbo", "1"),
|
||||
WithAssert(func(t *testing.T, r *Response) {
|
||||
switch user {
|
||||
case "admin", "staff", "user":
|
||||
r.AssertStatus(t, 200)
|
||||
case "disabled":
|
||||
r.AssertStatus(t, 403)
|
||||
default:
|
||||
r.AssertStatus(t, 303)
|
||||
r.AssertRedirect(t, "/login")
|
||||
}
|
||||
}),
|
||||
),
|
||||
RT(
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID+"/diagnosis"),
|
||||
WithHeader("x-turbo", "1"),
|
||||
@@ -375,13 +390,12 @@ func TestPermissions(t *testing.T) {
|
||||
}),
|
||||
),
|
||||
RT(
|
||||
WithMethod(http.MethodPost),
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID),
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID+"/update"),
|
||||
WithBody(url.Values{}),
|
||||
WithAssert(func(t *testing.T, r *Response) {
|
||||
switch user {
|
||||
case "admin", "staff", "user":
|
||||
r.AssertStatus(t, 303)
|
||||
r.AssertStatus(t, 200)
|
||||
case "disabled":
|
||||
r.AssertStatus(t, 403)
|
||||
default:
|
||||
@@ -390,12 +404,9 @@ func TestPermissions(t *testing.T) {
|
||||
}
|
||||
}),
|
||||
),
|
||||
RT(
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID),
|
||||
),
|
||||
RT(
|
||||
WithMethod(http.MethodPost),
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID+"/delete"),
|
||||
WithTarget("/bookmarks/"+u.Bookmarks[0].UID+"/update"),
|
||||
WithBody(url.Values{}),
|
||||
WithAssert(func(t *testing.T, r *Response) {
|
||||
switch user {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"codeberg.org/readeck/readeck/internal/server/urls"
|
||||
"codeberg.org/readeck/readeck/pkg/forms"
|
||||
"codeberg.org/readeck/readeck/pkg/http/csp"
|
||||
"codeberg.org/readeck/readeck/pkg/utils"
|
||||
)
|
||||
|
||||
const listDefaultLimit = 36
|
||||
@@ -128,6 +129,13 @@ func (h *viewsRouter) bookmarkInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
item.Errors = b.Errors
|
||||
|
||||
if server.IsTurboRequest(r) {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/card", "replace",
|
||||
"bookmark-card-"+b.UID, item, nil)
|
||||
return
|
||||
}
|
||||
|
||||
tc := getBaseContext(r.Context())
|
||||
tc["Item"] = item
|
||||
|
||||
@@ -137,30 +145,6 @@ func (h *viewsRouter) bookmarkInfo(w http.ResponseWriter, r *http.Request) {
|
||||
server.Log(r).Error("", slog.Any("err", err))
|
||||
}
|
||||
|
||||
// Load bookmark debug information if the user needs them.
|
||||
if auth.GetRequestUser(r).Settings.DebugInfo {
|
||||
c, err := b.OpenContainer()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
server.Err(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
defer c.Close()
|
||||
|
||||
for k, x := range map[string]string{
|
||||
"_props": "props.json",
|
||||
"_log": "log",
|
||||
} {
|
||||
if r, err := c.GetFile(x); err != nil {
|
||||
tc[k] = err.Error()
|
||||
} else {
|
||||
tc[k] = string(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set CSP for video playback
|
||||
if item.Type == "video" && item.EmbedHostname != "" {
|
||||
policy := server.GetCSPHeader(r).Clone()
|
||||
@@ -171,11 +155,27 @@ func (h *viewsRouter) bookmarkInfo(w http.ResponseWriter, r *http.Request) {
|
||||
server.RenderTemplate(w, r, 200, "/bookmarks/bookmark", tc)
|
||||
}
|
||||
|
||||
func (h *viewsRouter) bookmarkCard(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.IsTurboRequest(r) {
|
||||
server.TextMsg(w, r, http.StatusBadRequest, "not a turbo request")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
b := getBookmark(r.Context())
|
||||
item := dataset.NewBookmark(r.Context(), b)
|
||||
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/card", "replace",
|
||||
"bookmark-card-"+b.UID, item, nil)
|
||||
}
|
||||
|
||||
func (h *viewsRouter) diagnosis(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.IsTurboRequest(r) {
|
||||
server.TextMsg(w, r, http.StatusBadRequest, "not a turbo request")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
b := getBookmark(r.Context())
|
||||
tc := getBaseContext(r.Context())
|
||||
@@ -196,50 +196,142 @@ func (h *viewsRouter) diagnosis(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *viewsRouter) bookmarkUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
tr := server.Locale(r)
|
||||
b := getBookmark(r.Context())
|
||||
|
||||
tc := getBaseContext(r.Context())
|
||||
tc.SetBreadcrumbs([][2]string{
|
||||
{tr.Gettext("Bookmarks"), urls.AbsoluteURL(r, "/bookmarks").String()},
|
||||
{utils.ShortText(b.Title, 50), urls.AbsoluteURL(r, "/bookmarks", b.UID).String()},
|
||||
{tr.Gettext("Update")},
|
||||
})
|
||||
|
||||
tc["Title"] = b.Title
|
||||
tc["ID"] = b.UID
|
||||
|
||||
f := newUpdateForm(server.Locale(r))
|
||||
forms.Bind(f, r)
|
||||
tc["Form"] = f
|
||||
|
||||
if !f.IsValid() {
|
||||
server.Render(w, r, http.StatusBadRequest, f)
|
||||
status := http.StatusOK
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Fields the user can update on the UI
|
||||
f.Get("title").Set(b.Title)
|
||||
f.Get("description").Set(b.Description)
|
||||
f.Get("site_name").Set(b.SiteName)
|
||||
f.Get("authors").Set([]string(b.Authors))
|
||||
f.Get("published").Set(b.Published)
|
||||
f.Get("lang").Set(b.Lang)
|
||||
f.Get("text_direction").Set(b.TextDirection)
|
||||
|
||||
case http.MethodPost:
|
||||
forms.Bind(f, r)
|
||||
status = http.StatusUnprocessableEntity
|
||||
|
||||
if !f.IsValid() {
|
||||
break
|
||||
}
|
||||
|
||||
before := new(bookmarks.Bookmark)
|
||||
*before = *b
|
||||
|
||||
updated, err := f.update(b)
|
||||
if err != nil {
|
||||
server.Log(r).Error("bookmark update", slog.Any("err", err))
|
||||
break
|
||||
}
|
||||
|
||||
// On a turbo request, we'll return the updated components.
|
||||
if server.IsTurboRequest(r) {
|
||||
item := dataset.NewBookmark(r.Context(), b)
|
||||
|
||||
_, withTitle := updated["title"]
|
||||
_, withDescription := updated["description"]
|
||||
_, withMarked := updated["is_marked"]
|
||||
_, withArchived := updated["is_archived"]
|
||||
_, withDeleted := updated["is_deleted"]
|
||||
_, withProgress := updated["read_progress"]
|
||||
|
||||
if withTitle || withDescription {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/title_block", "replace",
|
||||
"bookmark-title-"+b.UID, server.TC{"Item": item}, nil)
|
||||
}
|
||||
|
||||
var beforeP, afterP time.Time
|
||||
if before.Published != nil {
|
||||
beforeP = *before.Published
|
||||
}
|
||||
if b.Published != nil {
|
||||
afterP = *b.Published
|
||||
}
|
||||
|
||||
if before.SiteName != b.SiteName || beforeP != afterP || !slices.Equal(before.Authors, b.Authors) {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/sidebar", "replace",
|
||||
"bookmark-sidebar-"+b.UID, server.TC{"Item": item}, nil)
|
||||
}
|
||||
|
||||
if before.Lang != b.Lang || before.TextDirection != b.TextDirection {
|
||||
buf, err := item.GetArticle()
|
||||
if err != nil {
|
||||
server.Log(r).Error("", slog.Any("err", err))
|
||||
}
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/content_block", "replace",
|
||||
"bookmark-content-"+b.UID, map[string]interface{}{
|
||||
"Item": item,
|
||||
"HTML": buf,
|
||||
"Out": w,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
if !slices.Equal(before.Labels, b.Labels) {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/labels", "replace",
|
||||
"bookmark-label-list-"+b.UID, item, nil)
|
||||
}
|
||||
|
||||
if withMarked || withArchived || withDeleted || withProgress {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/actions", "replace",
|
||||
"bookmark-actions-"+b.UID, item, nil)
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/card", "replace",
|
||||
"bookmark-card-"+b.UID, item, nil)
|
||||
}
|
||||
if withMarked || withArchived {
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/bottom_actions", "replace",
|
||||
"bookmark-bottom-actions-"+b.UID, item, nil)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
server.AddFlash(w, r, "success", tr.Gettext("Bookmark updated"))
|
||||
|
||||
redir := "/bookmarks/" + b.UID + "/update"
|
||||
if f.Get("_to").String() != "" {
|
||||
redir = f.Get("_to").String()
|
||||
}
|
||||
server.Redirect(w, r, redir)
|
||||
|
||||
}
|
||||
|
||||
if server.IsTurboRequest(r) {
|
||||
w.WriteHeader(status)
|
||||
server.RenderTurboStream(w, r,
|
||||
"/bookmarks/components/bookmark_update", "replace",
|
||||
"bookmark-update-"+b.UID, tc, nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
b := getBookmark(r.Context())
|
||||
|
||||
if _, err := f.update(b); err != nil {
|
||||
server.Err(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
redir := "/bookmarks/" + b.UID
|
||||
if f.Get("_to").String() != "" {
|
||||
redir = f.Get("_to").String()
|
||||
}
|
||||
|
||||
server.Redirect(w, r, redir)
|
||||
}
|
||||
|
||||
func (h *viewsRouter) bookmarkDelete(w http.ResponseWriter, r *http.Request) {
|
||||
b := getBookmark(r.Context())
|
||||
f := newDeleteForm(server.Locale(r))
|
||||
forms.Bind(f, r)
|
||||
|
||||
if err := b.Update(map[string]interface{}{}); err != nil {
|
||||
server.Err(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := f.trigger(b); err != nil {
|
||||
server.Err(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
redir := "/bookmarks"
|
||||
if f.Get("_to").String() != "" {
|
||||
redir = f.Get("_to").String()
|
||||
}
|
||||
|
||||
server.Redirect(w, r, redir)
|
||||
server.RenderTemplate(w, r, status, "bookmarks/bookmark_update", tc)
|
||||
}
|
||||
|
||||
func (h *viewsRouter) bookmarkShareLink(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -87,6 +88,12 @@ func RenderTurboStream(
|
||||
panic(err)
|
||||
}
|
||||
fmt.Fprint(w, "</template></turbo-stream>\n\n")
|
||||
|
||||
Log(r).Debug("turbo stream",
|
||||
slog.String("name", name),
|
||||
slog.String("action", action),
|
||||
slog.String("target", target),
|
||||
)
|
||||
}
|
||||
|
||||
// initTemplates add global functions to the views.
|
||||
|
||||
@@ -363,6 +363,27 @@ func Len(n int) ValueValidator[string] {
|
||||
}, Gettext("text must contain %d characters", n))
|
||||
}
|
||||
|
||||
// SplitLines works on [ListField] (of string) and populates
|
||||
// its values after spliting each line.
|
||||
// It will trim spaces on each value.
|
||||
var SplitLines = FieldValidatorFunc(func(f Field) error {
|
||||
field, ok := f.(*ListField[string])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := []string{}
|
||||
for _, x := range field.V() {
|
||||
for l := range strings.Lines(x) {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
res = append(res, strings.TrimSpace(l))
|
||||
}
|
||||
}
|
||||
}
|
||||
field.value.V = res
|
||||
return nil
|
||||
})
|
||||
|
||||
// ValueChoice is a key/value pair.
|
||||
type ValueChoice[T comparable] struct {
|
||||
Name string
|
||||
|
||||
@@ -453,6 +453,34 @@ func TestValidators(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("split lines", runValidatorTests([]fieldValidatorTest{
|
||||
{
|
||||
f: forms.NewTextListField("", forms.SplitLines),
|
||||
data: `[]`,
|
||||
expect: []string{},
|
||||
},
|
||||
{
|
||||
f: forms.NewTextListField("", forms.SplitLines),
|
||||
data: `["a\nb"]`,
|
||||
expect: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
f: forms.NewTextListField("", forms.SplitLines),
|
||||
data: `["a\r\nb"]`,
|
||||
expect: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
f: forms.NewTextListField("", forms.SplitLines),
|
||||
data: `["a\n\n\nb\n\n"]`,
|
||||
expect: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
f: forms.NewTextListField("", forms.SplitLines),
|
||||
data: `["a\nb\n", "\nc \nd"]`,
|
||||
expect: []string{"a", "b", "c", "d"},
|
||||
},
|
||||
}))
|
||||
|
||||
t.Run("choices", runValidatorTests([]fieldValidatorTest{
|
||||
{
|
||||
f: forms.NewTextField("", forms.Choices(
|
||||
|
||||
@@ -81,7 +81,8 @@ body.js {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
*::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar:xr-overlay {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,143 +2,143 @@
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
margin: 0;
|
||||
@layer components {
|
||||
.dialog {
|
||||
--dialog-spacing: 0px;
|
||||
|
||||
--dialog-spacing: 0px;
|
||||
width: calc(100% - var(--dialog-spacing));
|
||||
max-width: calc(100% - var(--dialog-spacing));
|
||||
height: calc(100vh - var(--dialog-spacing));
|
||||
max-height: calc(100vh - var(--dialog-spacing));
|
||||
@screen lg {
|
||||
--dialog-spacing: theme("spacing.16");
|
||||
left: 50%;
|
||||
margin-left: calc(-50% + var(--dialog-spacing) / 2);
|
||||
top: 50%;
|
||||
margin-top: calc(-50vh + var(--dialog-spacing) / 2);
|
||||
@apply rounded-lg shadow-lg shadow-black;
|
||||
@apply font-sans text-base bg-app-bg text-app-fg;
|
||||
overscroll-behavior: contain;
|
||||
width: 100%;
|
||||
max-width: calc(100% - var(--dialog-spacing) * 2);
|
||||
max-height: calc(100vh - var(--dialog-spacing) * 2);
|
||||
|
||||
@screen lg {
|
||||
--dialog-spacing: theme("spacing.16");
|
||||
@apply rounded-lg shadow-lg shadow-black;
|
||||
}
|
||||
|
||||
@at-root body:has(&[open]) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root body:has(&:open) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
@apply bg-app-bg text-app-fg;
|
||||
|
||||
&:open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
& &--top {
|
||||
@apply bg-gray-200 text-gray-50 sticky top-0;
|
||||
@apply p-2;
|
||||
@apply flex;
|
||||
z-index: 60;
|
||||
.dialog--top {
|
||||
@apply bg-gray-100 text-gray-50;
|
||||
@apply p-2 flex;
|
||||
@apply border-b;
|
||||
|
||||
@screen lg {
|
||||
@apply rounded-t-lg;
|
||||
}
|
||||
}
|
||||
|
||||
& &--top &--close {
|
||||
.dialog--top .dialog--close {
|
||||
@apply ml-auto;
|
||||
@apply text-gray-700 hf:text-gray-900;
|
||||
}
|
||||
|
||||
& &--content {
|
||||
.dialog--content {
|
||||
@apply p-4 overflow-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-image {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
margin: 0;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
|
||||
@at-root body:has(&:open) {
|
||||
@apply overflow-hidden;
|
||||
.dialog--footer {
|
||||
@apply bg-gray-100;
|
||||
@apply p-2 flex;
|
||||
@apply border-t;
|
||||
}
|
||||
|
||||
.dialog--close {
|
||||
position: fixed;
|
||||
top: theme("spacing.4");
|
||||
right: theme("spacing.4");
|
||||
@apply text-white/70 hf:text-white;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
@apply flex items-center;
|
||||
min-height: 100vh;
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-video {
|
||||
--dialog-spacing: 0px;
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
width: auto;
|
||||
max-height: calc(100dvh - var(--dialog-spacing));
|
||||
margin: auto;
|
||||
background-color: #000;
|
||||
|
||||
@apply rounded-md shadow-lg shadow-black;
|
||||
|
||||
@screen lg {
|
||||
--dialog-spacing: theme("spacing.20");
|
||||
}
|
||||
|
||||
@at-root body:has(&:open) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
& > form {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
& > div,
|
||||
& > div > iframe {
|
||||
.dialog-image {
|
||||
@apply font-sans text-base bg-app-bg text-app-fg;
|
||||
overscroll-behavior: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
|
||||
@at-root body:has(&[open]) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.dialog--close {
|
||||
position: fixed;
|
||||
top: theme("spacing.4");
|
||||
right: theme("spacing.4");
|
||||
@apply text-white/70 hf:text-white;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
@apply flex items-center;
|
||||
min-height: 100vh;
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog--close {
|
||||
position: fixed;
|
||||
top: theme("spacing.4");
|
||||
right: theme("spacing.4");
|
||||
@apply text-white/70 hf:text-white;
|
||||
.dialog-video {
|
||||
--dialog-spacing: 0px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
@apply font-sans text-base bg-app-bg text-app-fg;
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - var(--dialog-spacing));
|
||||
margin: auto;
|
||||
background-color: #000;
|
||||
|
||||
@apply rounded-md shadow-lg shadow-black;
|
||||
|
||||
@screen lg {
|
||||
--dialog-spacing: theme("spacing.20");
|
||||
}
|
||||
|
||||
@at-root body:has(&[open]) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
& > form {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
& > div,
|
||||
& > div > iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dialog--close {
|
||||
position: fixed;
|
||||
top: theme("spacing.4");
|
||||
right: theme("spacing.4");
|
||||
@apply text-white/70 hf:text-white;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
// Horizontal field
|
||||
.field-h {
|
||||
@apply flex gap-2 items-baseline max-w-std;
|
||||
@apply flex gap-2 items-start max-w-std;
|
||||
|
||||
@screen max-sm {
|
||||
@apply block;
|
||||
@@ -164,6 +164,7 @@
|
||||
|
||||
& > label:first-child,
|
||||
& > .field-spacer:first-child {
|
||||
padding-top: theme("spacing.2");
|
||||
flex-basis: theme("spacing.40");
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -171,6 +172,10 @@
|
||||
flex-basis: theme("spacing.60");
|
||||
}
|
||||
}
|
||||
|
||||
& > div > ul {
|
||||
padding-top: theme("spacing.2");
|
||||
}
|
||||
}
|
||||
|
||||
.field-h--compact {
|
||||
|
||||
Reference in New Issue
Block a user