diff --git a/changelog.d/1001.added.md b/changelog.d/1001.added.md new file mode 100644 index 000000000..0ea4c09bf --- /dev/null +++ b/changelog.d/1001.added.md @@ -0,0 +1 @@ +Add HTMX version of the destinations page diff --git a/src/argus/htmx/destinations/__init__.py b/src/argus/htmx/destination/__init__.py similarity index 100% rename from src/argus/htmx/destinations/__init__.py rename to src/argus/htmx/destination/__init__.py diff --git a/src/argus/htmx/destination/forms.py b/src/argus/htmx/destination/forms.py new file mode 100644 index 000000000..9b8a63010 --- /dev/null +++ b/src/argus/htmx/destination/forms.py @@ -0,0 +1,126 @@ +from django import forms +from django.forms import ModelForm + +from argus.notificationprofile.models import DestinationConfig, Media +from argus.notificationprofile.serializers import RequestDestinationConfigSerializer +from argus.notificationprofile.media import api_safely_get_medium_object + + +class DestinationFormCreate(ModelForm): + settings = forms.CharField(required=True) + + def __init__(self, *args, **kwargs): + # Serializer request the request object + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + + class Meta: + model = DestinationConfig + fields = ["label", "media", "settings"] + labels = { + "label": "Name", + } + widgets = { + "media": forms.Select(attrs={"class": "select input-bordered w-full max-w-xs"}), + } + + def clean(self): + super().clean() + settings_key = _get_settings_key_for_media(self.cleaned_data["media"]) + # Convert settings value (e.g. email address) to be compatible with JSONField + self.cleaned_data["settings"] = {settings_key: self.cleaned_data["settings"]} + self._init_serializer() + return self._validate_serializer() + + def save(self): + # self.serializer should be initiated and validated in clean() before save() is called + self.serializer.save(user=self.request.user) + + def _init_serializer(self): + serializer = RequestDestinationConfigSerializer( + data={ + "media": self.cleaned_data["media"], + "label": self.cleaned_data.get("label", ""), + "settings": self.cleaned_data["settings"], + }, + context={"request": self.request}, + ) + self.serializer = serializer + + def _validate_serializer(self): + media = self.cleaned_data["media"] + settings_key = _get_settings_key_for_media(media) + + # Add error messages from serializer to form + if not self.serializer.is_valid(): + for error_name, error_detail in self.serializer.errors.items(): + if error_name in ["media", "label", settings_key]: + if error_name == settings_key: + error_name = "settings" + self.add_error(error_name, error_detail) + # Serializer might add more data to the JSON dict + if settings := self.serializer.data.get("settings"): + self.cleaned_data["settings"] = settings + else: + # Serializer might add more data to the JSON dict + if settings := self.serializer.validated_data.get("settings"): + self.cleaned_data["settings"] = settings + + if label := self.cleaned_data.get("label"): + destination_filter = DestinationConfig.objects.filter(label=label) + if self.instance: + destination_filter = destination_filter.exclude(pk=self.instance.pk) + if destination_filter.exists(): + self.add_error("label", "Name must be unique per media") + + return self.cleaned_data + + +class DestinationFormUpdate(DestinationFormCreate): + def __init__(self, *args, **kwargs): + if instance := kwargs.get("instance"): + settings_key = _get_settings_key_for_media(instance.media) + # Extract settings value (email address etc.) from JSONField + instance.settings = instance.settings.get(settings_key) + super().__init__(*args, **kwargs) + + class Meta: + model = DestinationConfig + fields = ["label", "media", "settings"] + labels = { + "label": "Name", + } + widgets = { + "media": forms.HiddenInput(), + } + + def _init_serializer(self): + # self.instance is modified in __init__, + # so get unmodified version here for the serializer + destination = DestinationConfig.objects.get(pk=self.instance.pk) + settings_key = _get_settings_key_for_media(destination.media) + data = {} + + if "label" in self.cleaned_data: + label = self.cleaned_data["label"] + if label != destination.label: + data["label"] = label + + settings = self.cleaned_data["settings"] + if settings.get(settings_key) != destination.settings.get(settings_key): + data["settings"] = settings + + self.serializer = RequestDestinationConfigSerializer( + destination, + data=data, + context={"request": self.request}, + partial=True, + ) + + +def _get_settings_key_for_media(media: Media) -> str: + """Returns the required settings key for the given media, + e.g. "email_address", "phone_number" + """ + medium = api_safely_get_medium_object(media.slug) + return medium.MEDIA_JSON_SCHEMA["required"][0] diff --git a/src/argus/htmx/destination/urls.py b/src/argus/htmx/destination/urls.py new file mode 100644 index 000000000..7ffea3ffa --- /dev/null +++ b/src/argus/htmx/destination/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from .views import destination_list, create_htmx, delete_htmx, update_htmx + +app_name = "htmx" +urlpatterns = [ + path("", destination_list, name="destination-list"), + path("htmx-create/", create_htmx, name="htmx-create"), + path("/htmx-delete/", delete_htmx, name="htmx-delete"), + path("/htmx-update/", update_htmx, name="htmx-update"), +] diff --git a/src/argus/htmx/destination/views.py b/src/argus/htmx/destination/views.py new file mode 100644 index 000000000..6cd52bff1 --- /dev/null +++ b/src/argus/htmx/destination/views.py @@ -0,0 +1,112 @@ +from typing import Optional, Sequence +from django.shortcuts import render, get_object_or_404 + +from django.views.decorators.http import require_http_methods +from django.http import HttpResponse + +from argus.notificationprofile.models import DestinationConfig, Media +from argus.notificationprofile.media import api_safely_get_medium_object +from argus.notificationprofile.media.base import NotificationMedium + +from .forms import DestinationFormCreate, DestinationFormUpdate + + +@require_http_methods(["GET"]) +def destination_list(request): + return _render_destination_list(request) + + +@require_http_methods(["POST"]) +def create_htmx(request) -> HttpResponse: + form = DestinationFormCreate(request.POST or None, request=request) + template = "htmx/destination/_content.html" + if form.is_valid(): + form.save() + return _render_destination_list(request, template=template) + return _render_destination_list(request, create_form=form, template=template) + + +@require_http_methods(["POST"]) +def delete_htmx(request, pk: int) -> HttpResponse: + destination = get_object_or_404(request.user.destinations.all(), pk=pk) + template = "htmx/destination/_form_list.html" + try: + medium = api_safely_get_medium_object(destination.media.slug) + medium.raise_if_not_deletable(destination) + except NotificationMedium.NotDeletableError: + error_msg = "This destination cannot be deleted." + return _render_destination_list(request, errors=[error_msg], template=template) + else: + destination.delete() + return _render_destination_list(request, template=template) + + +@require_http_methods(["POST"]) +def update_htmx(request, pk: int) -> HttpResponse: + destination = DestinationConfig.objects.get(pk=pk) + form = DestinationFormUpdate(request.POST or None, instance=destination, request=request) + template = "htmx/destination/_form_list.html" + if form.is_valid(): + form.save() + return _render_destination_list(request, template=template) + + update_forms = _get_update_forms(request.user) + for index, update_form in enumerate(update_forms): + if update_form.instance.pk == pk: + update_forms[index] = form + break + return _render_destination_list(request, update_forms=update_forms, template=template) + + +def _render_destination_list( + request, + create_form: Optional[DestinationFormCreate] = None, + update_forms: Optional[Sequence[DestinationFormUpdate]] = None, + errors: Optional[Sequence[str]] = None, + template: str = "htmx/destination/destination_list.html", +) -> HttpResponse: + """Function to render the destinations page. + + :param create_form: this is used to display the form for creating a new destination + with errors while retaining the user input. If you want a blank form, pass None. + :param update_forms: list of update forms to display. Useful for rendering forms + with error messages while retaining the user input. + If this is None, the update forms will be generated from the user's destinations. + :param errors: a list of error messages to display on the page. Will not be tied to + any form fields.""" + + if create_form is None: + create_form = DestinationFormCreate() + if update_forms is None: + update_forms = _get_update_forms(request.user) + if errors is None: + errors = [] + grouped_forms = _group_update_forms_by_media(update_forms) + context = { + "create_form": create_form, + "grouped_forms": grouped_forms, + "errors": errors, + } + return render(request, template, context=context) + + +def _get_update_forms(user) -> list[DestinationFormUpdate]: + # Sort by oldest first + destinations = user.destinations.all().order_by("pk") + return [DestinationFormUpdate(instance=destination) for destination in destinations] + + +def _group_update_forms_by_media( + destination_forms: Sequence[DestinationFormUpdate], +) -> dict[Media, list[DestinationFormUpdate]]: + grouped_destinations = {} + + # Adding a media to the dict even if there are no destinations for it + # is useful so that the template can render a section for that media + for media in Media.objects.all(): + grouped_destinations[media] = [] + + for form in destination_forms: + grouped_destinations[form.instance.media].append(form) + + return grouped_destinations diff --git a/src/argus/htmx/destinations/urls.py b/src/argus/htmx/destinations/urls.py deleted file mode 100644 index a84ddf0ef..000000000 --- a/src/argus/htmx/destinations/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.http import HttpResponse -from django.template import Template, RequestContext -from django.urls import path -from django.views.decorators.http import require_GET - - -@require_GET -def placeholder(request): - template = Template( - """{% extends "htmx/base.html" %} - {% block main %} -

DESTINATION PLACEHOLDER

- {% endblock main %} - """ - ) - context = RequestContext(request) - return HttpResponse(template.render(context)) - - -app_name = "htmx" -urlpatterns = [ - path("", placeholder, name="destination-placeholder"), -] diff --git a/src/argus/htmx/static/styles.css b/src/argus/htmx/static/styles.css index b304384f8..7d09d7250 100644 --- a/src/argus/htmx/static/styles.css +++ b/src/argus/htmx/static/styles.css @@ -1,5 +1,5 @@ /* -! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com +! tailwindcss v3.4.11 | MIT License | https://tailwindcss.com */ /* @@ -853,6 +853,40 @@ html { --tw-contain-style: ; } +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + .alert { display: grid; width: 100%; @@ -934,7 +968,38 @@ html { color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); } +.breadcrumbs { + max-width: 100%; + overflow-x: auto; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.breadcrumbs > ul, + .breadcrumbs > ol { + display: flex; + align-items: center; + white-space: nowrap; + min-height: -moz-min-content; + min-height: min-content; +} + +.breadcrumbs > ul > li, .breadcrumbs > ol > li { + display: flex; + align-items: center; +} + +.breadcrumbs > ul > li > a, .breadcrumbs > ol > li > a { + display: flex; + cursor: pointer; + align-items: center; +} + @media (hover:hover) { + .breadcrumbs > ul > li > a:hover, .breadcrumbs > ol > li > a:hover { + text-decoration-line: underline; + } + .checkbox-primary:hover { --tw-border-opacity: 1; border-color: var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity))); @@ -1127,6 +1192,70 @@ html { --tw-border-opacity: 0.2; } +.collapse:not(td):not(tr):not(colgroup) { + visibility: visible; +} + +.collapse { + position: relative; + display: grid; + overflow: hidden; + grid-template-rows: auto 0fr; + transition: grid-template-rows 0.2s; + width: 100%; + border-radius: var(--rounded-box, 1rem); +} + +.collapse-title, +.collapse > input[type="checkbox"], +.collapse > input[type="radio"], +.collapse-content { + grid-column-start: 1; + grid-row-start: 1; +} + +.collapse > input[type="checkbox"], +.collapse > input[type="radio"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + opacity: 0; +} + +.collapse-content { + visibility: hidden; + grid-column-start: 1; + grid-row-start: 2; + min-height: 0px; + transition: visibility 0.2s; + transition: padding 0.2s ease-out, + background-color 0.2s ease-out; + padding-left: 1rem; + padding-right: 1rem; + cursor: unset; +} + +.collapse[open], +.collapse-open, +.collapse:focus:not(.collapse-close) { + grid-template-rows: auto 1fr; +} + +.collapse:not(.collapse-close):has(> input[type="checkbox"]:checked), +.collapse:not(.collapse-close):has(> input[type="radio"]:checked) { + grid-template-rows: auto 1fr; +} + +.collapse[open] > .collapse-content, +.collapse-open > .collapse-content, +.collapse:focus:not(.collapse-close) > .collapse-content, +.collapse:not(.collapse-close) > input[type="checkbox"]:checked ~ .collapse-content, +.collapse:not(.collapse-close) > input[type="radio"]:checked ~ .collapse-content { + visibility: visible; + min-height: -moz-fit-content; + min-height: fit-content; +} + .divider { display: flex; flex-direction: row; @@ -1238,6 +1367,16 @@ html { opacity: 1; } + .btm-nav > *.disabled:hover, + .btm-nav > *[disabled]:hover { + pointer-events: none; + --tw-border-opacity: 0; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + --tw-bg-opacity: 0.1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + --tw-text-opacity: 0.2; + } + .btn:hover { --tw-border-opacity: 1; border-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity))); @@ -1294,6 +1433,18 @@ html { } } + .btn-outline.btn-secondary:hover { + --tw-text-opacity: 1; + color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-secondary:hover { + background-color: color-mix(in oklab, var(--fallback-s,oklch(var(--s)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-s,oklch(var(--s)/1)) 90%, black); + } + } + .btn-outline.btn-accent:hover { --tw-text-opacity: 1; color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); @@ -1306,6 +1457,30 @@ html { } } + .btn-outline.btn-success:hover { + --tw-text-opacity: 1; + color: var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-success:hover { + background-color: color-mix(in oklab, var(--fallback-su,oklch(var(--su)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-su,oklch(var(--su)/1)) 90%, black); + } + } + + .btn-outline.btn-info:hover { + --tw-text-opacity: 1; + color: var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-info:hover { + background-color: color-mix(in oklab, var(--fallback-in,oklch(var(--in)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-in,oklch(var(--in)/1)) 90%, black); + } + } + .btn-disabled:hover, .btn[disabled]:hover, .btn:disabled:hover { @@ -1438,6 +1613,12 @@ html { margin-inline-end: -1rem; } +.input-xs[type="number"]::-webkit-inner-spin-button { + margin-top: -0.25rem; + margin-bottom: -0.25rem; + margin-inline-end: -0px; +} + .join { display: inline-flex; align-items: stretch; @@ -1928,6 +2109,23 @@ input.tab:checked + .tab-content, background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); } +.textarea { + min-height: 3rem; + flex-shrink: 1; + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + line-height: 2; + border-radius: var(--rounded-btn, 0.5rem); + border-width: 1px; + border-color: transparent; + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); +} + .toast { position: fixed; display: flex; @@ -1939,6 +2137,30 @@ input.tab:checked + .tab-content, padding: 1rem; } +.toggle { + flex-shrink: 0; + --tglbg: var(--fallback-b1,oklch(var(--b1)/1)); + --handleoffset: 1.5rem; + --handleoffsetcalculator: calc(var(--handleoffset) * -1); + --togglehandleborder: 0 0; + height: 1.5rem; + width: 3rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-radius: var(--rounded-badge, 1.9rem); + border-width: 1px; + border-color: currentColor; + background-color: currentColor; + color: var(--fallback-bc,oklch(var(--bc)/0.5)); + transition: background, + box-shadow var(--animation-input, 0.2s) ease-out; + box-shadow: var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset, + var(--togglehandleborder); +} + .avatar-group :where(.avatar) { overflow: hidden; border-radius: 9999px; @@ -1989,11 +2211,57 @@ input.tab:checked + .tab-content, color: var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity))); } +.btm-nav > *:where(.active) { + border-top-width: 2px; + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); +} + +.btm-nav > *.disabled, + .btm-nav > *[disabled] { + pointer-events: none; + --tw-border-opacity: 0; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + --tw-bg-opacity: 0.1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + --tw-text-opacity: 0.2; +} + .btm-nav > * .label { font-size: 1rem; line-height: 1.5rem; } +.breadcrumbs > ul > li > a:focus, .breadcrumbs > ol > li > a:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.breadcrumbs > ul > li > a:focus-visible, .breadcrumbs > ol > li > a:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.breadcrumbs > ul > li + *:before, .breadcrumbs > ol > li + *:before { + content: ""; + margin-left: 0.5rem; + margin-right: 0.75rem; + display: block; + height: 0.375rem; + width: 0.375rem; + --tw-rotate: 45deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + opacity: 0.4; + border-top: 1px solid; + border-right: 1px solid; + background-color: transparent; +} + +[dir="rtl"] .breadcrumbs > ul > li + *:before, +[dir="rtl"] .breadcrumbs > ol > li + *:before { + --tw-rotate: -135deg; +} + @media (prefers-reduced-motion: no-preference) { .btn { animation: button-pop var(--animation-btn, 0.25s) ease-out; @@ -2016,6 +2284,10 @@ input.tab:checked + .tab-content, --btn-color: var(--fallback-p); } + .btn-secondary { + --btn-color: var(--fallback-s); + } + .btn-accent { --btn-color: var(--fallback-a); } @@ -2023,6 +2295,14 @@ input.tab:checked + .tab-content, .btn-neutral { --btn-color: var(--fallback-n); } + + .btn-info { + --btn-color: var(--fallback-in); + } + + .btn-success { + --btn-color: var(--fallback-su); + } } @supports (color: color-mix(in oklab, black, black)) { @@ -2092,6 +2372,10 @@ input.tab:checked + .tab-content, --btn-color: var(--p); } + .btn-secondary { + --btn-color: var(--s); + } + .btn-accent { --btn-color: var(--a); } @@ -2099,6 +2383,20 @@ input.tab:checked + .tab-content, .btn-neutral { --btn-color: var(--n); } + + .btn-info { + --btn-color: var(--in); + } + + .btn-success { + --btn-color: var(--su); + } +} + +.btn-secondary { + --tw-text-opacity: 1; + color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); + outline-color: var(--fallback-s,oklch(var(--s)/1)); } .btn-accent { @@ -2113,6 +2411,18 @@ input.tab:checked + .tab-content, outline-color: var(--fallback-n,oklch(var(--n)/1)); } +.btn-info { + --tw-text-opacity: 1; + color: var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity))); + outline-color: var(--fallback-in,oklch(var(--in)/1)); +} + +.btn-success { + --tw-text-opacity: 1; + color: var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity))); + outline-color: var(--fallback-su,oklch(var(--su)/1)); +} + .btn.glass { --tw-shadow: 0 0 #0000; --tw-shadow-colored: 0 0 #0000; @@ -2166,6 +2476,11 @@ input.tab:checked + .tab-content, color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); } +.btn-outline.btn-secondary { + --tw-text-opacity: 1; + color: var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity))); +} + .btn-outline.btn-secondary.btn-active { --tw-text-opacity: 1; color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); @@ -2181,11 +2496,21 @@ input.tab:checked + .tab-content, color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); } +.btn-outline.btn-success { + --tw-text-opacity: 1; + color: var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity))); +} + .btn-outline.btn-success.btn-active { --tw-text-opacity: 1; color: var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity))); } +.btn-outline.btn-info { + --tw-text-opacity: 1; + color: var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity))); +} + .btn-outline.btn-info.btn-active { --tw-text-opacity: 1; color: var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity))); @@ -2390,6 +2715,130 @@ input.tab:checked + .tab-content, } } +details.collapse { + width: 100%; +} + +details.collapse summary { + position: relative; + display: block; + outline: 2px solid transparent; + outline-offset: 2px; +} + +details.collapse summary::-webkit-details-marker { + display: none; +} + +.collapse:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/1)); +} + +.collapse:has(.collapse-title:focus-visible), +.collapse:has(> input[type="checkbox"]:focus-visible), +.collapse:has(> input[type="radio"]:focus-visible) { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/1)); +} + +.collapse-arrow > .collapse-title:after { + position: absolute; + display: block; + height: 0.5rem; + width: 0.5rem; + --tw-translate-y: -100%; + --tw-rotate: 45deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 150ms; + transition-duration: 0.2s; + top: 1.9rem; + inset-inline-end: 1.4rem; + content: ""; + transform-origin: 75% 75%; + box-shadow: 2px 2px; + pointer-events: none; +} + +.collapse-plus > .collapse-title:after { + position: absolute; + display: block; + height: 0.5rem; + width: 0.5rem; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 300ms; + top: 0.9rem; + inset-inline-end: 1.4rem; + content: "+"; + pointer-events: none; +} + +.collapse:not(.collapse-open):not(.collapse-close) > input[type="checkbox"], +.collapse:not(.collapse-open):not(.collapse-close) > input[type="radio"]:not(:checked), +.collapse:not(.collapse-open):not(.collapse-close) > .collapse-title { + cursor: pointer; +} + +.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open]) > .collapse-title { + cursor: unset; +} + +.collapse-title { + position: relative; +} + +:where(.collapse > input[type="checkbox"]), +:where(.collapse > input[type="radio"]) { + z-index: 1; +} + +.collapse-title, +:where(.collapse > input[type="checkbox"]), +:where(.collapse > input[type="radio"]) { + width: 100%; + padding: 1rem; + padding-inline-end: 3rem; + min-height: 3.75rem; + transition: background-color 0.2s ease-out; +} + +.collapse[open] > :where(.collapse-content), +.collapse-open > :where(.collapse-content), +.collapse:focus:not(.collapse-close) > :where(.collapse-content), +.collapse:not(.collapse-close) > :where(input[type="checkbox"]:checked ~ .collapse-content), +.collapse:not(.collapse-close) > :where(input[type="radio"]:checked ~ .collapse-content) { + padding-bottom: 1rem; + transition: padding 0.2s ease-out, + background-color 0.2s ease-out; +} + +.collapse[open].collapse-arrow > .collapse-title:after, +.collapse-open.collapse-arrow > .collapse-title:after, +.collapse-arrow:focus:not(.collapse-close) > .collapse-title:after, +.collapse-arrow:not(.collapse-close) > input[type="checkbox"]:checked ~ .collapse-title:after, +.collapse-arrow:not(.collapse-close) > input[type="radio"]:checked ~ .collapse-title:after { + --tw-translate-y: -50%; + --tw-rotate: 225deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.collapse[open].collapse-plus > .collapse-title:after, +.collapse-open.collapse-plus > .collapse-title:after, +.collapse-plus:focus:not(.collapse-close) > .collapse-title:after, +.collapse-plus:not(.collapse-close) > input[type="checkbox"]:checked ~ .collapse-title:after, +.collapse-plus:not(.collapse-close) > input[type="radio"]:checked ~ .collapse-title:after { + content: "−"; +} + .divider:not(:empty) { gap: 1rem; } @@ -2415,6 +2864,13 @@ input.tab:checked + .tab-content, color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); } +.label-text-alt { + font-size: 0.75rem; + line-height: 1rem; + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); +} + .input input { --tw-bg-opacity: 1; background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity))); @@ -2636,6 +3092,12 @@ input.tab:checked + .tab-content, color: var(--fallback-bc,oklch(var(--bc)/0.4)); } +.mockup-phone .display { + overflow: hidden; + border-radius: 40px; + margin-top: -25px; +} + .mockup-browser .mockup-browser-toolbar .input { position: relative; margin-left: auto; @@ -3063,6 +3525,13 @@ input.tab:checked + .tab-content, background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); } +.table-zebra tr.active, + .table-zebra tr.active:nth-child(even), + .table-zebra-zebra tbody tr:nth-child(even) { + --tw-bg-opacity: 1; + background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity))); +} + .table :where(thead tr, tbody tr:not(:last-child), tbody tr:first-child:last-child) { border-bottom-width: 1px; --tw-border-opacity: 1; @@ -3083,6 +3552,38 @@ input.tab:checked + .tab-content, border-top-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); } +.textarea:focus { + box-shadow: none; + border-color: var(--fallback-bc,oklch(var(--bc)/0.2)); + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + +.textarea-disabled, + .textarea:disabled, + .textarea[disabled] { + cursor: not-allowed; + --tw-border-opacity: 1; + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); + color: var(--fallback-bc,oklch(var(--bc)/0.4)); +} + +.textarea-disabled::-moz-placeholder, .textarea:disabled::-moz-placeholder, .textarea[disabled]::-moz-placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))); + --tw-placeholder-opacity: 0.2; +} + +.textarea-disabled::placeholder, + .textarea:disabled::placeholder, + .textarea[disabled]::placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))); + --tw-placeholder-opacity: 0.2; +} + .toast > * { animation: toast-pop 0.25s ease-out; } @@ -3099,6 +3600,57 @@ input.tab:checked + .tab-content, } } +[dir="rtl"] .toggle { + --handleoffsetcalculator: calc(var(--handleoffset) * 1); +} + +.toggle:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + +.toggle:hover { + background-color: currentColor; +} + +.toggle:checked, + .toggle[aria-checked="true"] { + background-image: none; + --handleoffsetcalculator: var(--handleoffset); + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); +} + +[dir="rtl"] .toggle:checked, [dir="rtl"] .toggle[aria-checked="true"] { + --handleoffsetcalculator: calc(var(--handleoffset) * -1); +} + +.toggle:indeterminate { + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset; +} + +[dir="rtl"] .toggle:indeterminate { + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset; +} + +.toggle:disabled { + cursor: not-allowed; + --tw-border-opacity: 1; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))); + background-color: transparent; + opacity: 0.3; + --togglehandleborder: 0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset, + var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset; +} + .glass, .glass.btn-active { border: none; @@ -3142,6 +3694,30 @@ input.tab:checked + .tab-content, } } +.btm-nav-xs > *:where(.active) { + border-top-width: 1px; +} + +.btm-nav-sm > *:where(.active) { + border-top-width: 2px; +} + +.btm-nav-md > *:where(.active) { + border-top-width: 2px; +} + +.btm-nav-lg > *:where(.active) { + border-top-width: 4px; +} + +.btn-xs { + height: 1.5rem; + min-height: 1.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + font-size: 0.75rem; +} + .btn-sm { height: 2rem; min-height: 2rem; @@ -3154,6 +3730,12 @@ input.tab:checked + .tab-content, width: 100%; } +.btn-square:where(.btn-xs) { + height: 1.5rem; + width: 1.5rem; + padding: 0px; +} + .btn-square:where(.btn-sm) { height: 2rem; width: 2rem; @@ -3265,6 +3847,15 @@ input.tab:checked + .tab-content, transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +.input-xs { + height: 1.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + font-size: 0.75rem; + line-height: 1rem; + line-height: 1.625; +} + .input-md { height: 3rem; padding-left: 1rem; @@ -3693,6 +4284,11 @@ input.tab:checked + .tab-content, background-color: color-mix(in srgb, oklch(var(--er)), oklch(var(--b1)) 85%); } +.alert.autoclosing { + transition: opacity 0.2s ease-out; + opacity: 0; +} + /* * Example usage: *
@@ -3746,10 +4342,38 @@ input.tab:checked + .tab-content, visibility: visible; } +.collapse { + visibility: collapse; +} + .static { position: static; } +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.top-5 { + top: 1.25rem; +} + +.z-10 { + z-index: 10; +} + .z-\[1\] { z-index: 1; } @@ -3772,6 +4396,10 @@ input.tab:checked + .tab-content, margin-left: -0.5rem; } +.mb-auto { + margin-bottom: auto; +} + .ml-2 { margin-left: 0.5rem; } @@ -3788,6 +4416,10 @@ input.tab:checked + .tab-content, display: block; } +.inline { + display: inline; +} + .flex { display: flex; } @@ -3796,6 +4428,14 @@ input.tab:checked + .tab-content, display: table; } +.grid { + display: grid; +} + +.contents { + display: contents; +} + .hidden { display: none; } @@ -3814,6 +4454,10 @@ input.tab:checked + .tab-content, height: 3.5rem; } +.h-4 { + height: 1rem; +} + .h-6 { height: 1.5rem; } @@ -3822,6 +4466,11 @@ input.tab:checked + .tab-content, height: auto; } +.h-fit { + height: -moz-fit-content; + height: fit-content; +} + .max-h-16 { max-height: 4rem; } @@ -3838,6 +4487,10 @@ input.tab:checked + .tab-content, max-height: 100svh; } +.min-h-4 { + min-height: 1rem; +} + .min-h-8 { min-height: 2rem; } @@ -3871,6 +4524,10 @@ input.tab:checked + .tab-content, width: 1.5rem; } +.w-72 { + width: 18rem; +} + .w-fit { width: -moz-fit-content; width: fit-content; @@ -3905,6 +4562,10 @@ input.tab:checked + .tab-content, flex-grow: 1; } +.border-collapse { + border-collapse: collapse; +} + .border-separate { border-collapse: separate; } @@ -3915,6 +4576,10 @@ input.tab:checked + .tab-content, border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y); } +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .cursor-pointer { cursor: pointer; } @@ -3941,6 +4606,10 @@ input.tab:checked + .tab-content, flex-wrap: nowrap; } +.content-start { + align-content: flex-start; +} + .items-end { align-items: flex-end; } @@ -4017,6 +4686,10 @@ input.tab:checked + .tab-content, border-radius: 9999px; } +.rounded-lg { + border-radius: 0.5rem; +} + .border { border-width: 2px; } @@ -4115,6 +4788,10 @@ input.tab:checked + .tab-content, padding-bottom: 0.5rem; } +.text-center { + text-align: center; +} + .text-start { text-align: start; } @@ -4176,6 +4853,11 @@ input.tab:checked + .tab-content, color: var(--fallback-bc,oklch(var(--bc)/0.5)); } +.text-error { + --tw-text-opacity: 1; + color: var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity))); +} + .text-info { --tw-text-opacity: 1; color: var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity))); @@ -4248,6 +4930,10 @@ input.tab:checked + .tab-content, filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + .\[--tab-border-color\:theme\(colors\.primary\)\] { --tab-border-color: var(--fallback-p,oklch(var(--p)/1)); } diff --git a/src/argus/htmx/templates/htmx/base.html b/src/argus/htmx/templates/htmx/base.html index 3b366edb3..e93af2b02 100644 --- a/src/argus/htmx/templates/htmx/base.html +++ b/src/argus/htmx/templates/htmx/base.html @@ -48,6 +48,9 @@
  • Preferences…
  • +
  • + Destinations +
  • {% csrf_token %} diff --git a/src/argus/htmx/templates/htmx/destination/_content.html b/src/argus/htmx/templates/htmx/destination/_content.html new file mode 100644 index 000000000..b9be838c4 --- /dev/null +++ b/src/argus/htmx/templates/htmx/destination/_content.html @@ -0,0 +1,5 @@ +
    + {% include "htmx/destination/_create_form.html" %} + {% for error in errors %}

    {{ error }}

    {% endfor %} + {% include "htmx/destination/_form_list.html" %} +
    diff --git a/src/argus/htmx/templates/htmx/destination/_create_form.html b/src/argus/htmx/templates/htmx/destination/_create_form.html new file mode 100644 index 000000000..03cfbdd2f --- /dev/null +++ b/src/argus/htmx/templates/htmx/destination/_create_form.html @@ -0,0 +1,28 @@ + + {% csrf_token %} +
    + Create destination + {% for field in create_form %} + + {% empty %} +

    Something went wrong

    + {% endfor %} + +
    +
    diff --git a/src/argus/htmx/templates/htmx/destination/_delete_form.html b/src/argus/htmx/templates/htmx/destination/_delete_form.html new file mode 100644 index 000000000..cea67b243 --- /dev/null +++ b/src/argus/htmx/templates/htmx/destination/_delete_form.html @@ -0,0 +1,8 @@ +
    + {% csrf_token %} + +
    diff --git a/src/argus/htmx/templates/htmx/destination/_edit_form.html b/src/argus/htmx/templates/htmx/destination/_edit_form.html new file mode 100644 index 000000000..981d3c378 --- /dev/null +++ b/src/argus/htmx/templates/htmx/destination/_edit_form.html @@ -0,0 +1,25 @@ +
    + {% csrf_token %} +
    + {% for hidden_field in form.hidden_fields %}{{ hidden_field }}{% endfor %} + {% for field in form.visible_fields %} + + {% empty %} +

    Something went wrong

    + {% endfor %} +
    + +
    diff --git a/src/argus/htmx/templates/htmx/destination/_form_list.html b/src/argus/htmx/templates/htmx/destination/_form_list.html new file mode 100644 index 000000000..f73f681d4 --- /dev/null +++ b/src/argus/htmx/templates/htmx/destination/_form_list.html @@ -0,0 +1,15 @@ +
    + {% for media, forms in grouped_forms.items %} +
    + {{ media.name }} ({{ forms|length }}) +
    + {% for form in forms %} +
    + {% include "htmx/destination/_edit_form.html" %} + {% include "htmx/destination/_delete_form.html" %} +
    + {% endfor %} +
    +
    + {% endfor %} +
    diff --git a/src/argus/htmx/templates/htmx/destination/destination_list.html b/src/argus/htmx/templates/htmx/destination/destination_list.html new file mode 100644 index 000000000..5329ffb92 --- /dev/null +++ b/src/argus/htmx/templates/htmx/destination/destination_list.html @@ -0,0 +1,4 @@ +{% extends "htmx/base.html" %} +{% block main %} + {% include "htmx/destination/_content.html" %} +{% endblock main %} diff --git a/src/argus/htmx/urls.py b/src/argus/htmx/urls.py index 3b0e3715a..b01ab0d4b 100644 --- a/src/argus/htmx/urls.py +++ b/src/argus/htmx/urls.py @@ -5,7 +5,7 @@ from .incidents.urls import urlpatterns as incident_urls from .timeslots.urls import urlpatterns as timeslot_urls from .notificationprofiles.urls import urlpatterns as notificationprofile_urls -from .destinations.urls import urlpatterns as destination_urls +from .destination.urls import urlpatterns as destination_urls from .themes.urls import urlpatterns as theme_urls from .dateformat.urls import urlpatterns as dateformat_urls from .user.urls import urlpatterns as user_urls