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:
olivier
2025-12-16 08:28:38 +01:00
25 changed files with 627 additions and 315 deletions

View File

@@ -5,6 +5,7 @@
- TOTP support
- Forwarded authentication
- mTLS for HTTPS listener
- Bookmark properties editor
## [0.21.4] - 2025-12-09
### Added

View File

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

View File

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

View 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 -}}

View File

@@ -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") }}&nbsp;{{ gettext("Undo") }}</button>
data-controller="turbo-form">
{{- yield icon(name="o-undo") }}&nbsp;{{ 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 -}}

View File

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

View File

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

View File

@@ -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>&nbsp;{{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>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,7 +81,8 @@ body.js {
scrollbar-width: auto;
}
*::-webkit-scrollbar {
*::-webkit-scrollbar,
*::-webkit-scrollbar:xr-overlay {
width: 4px;
}

View File

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

View File

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