You've already forked hotpocket
This commit is contained in:
12
services/backend/hotpocket_backend/apps/ui/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/ui/apps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class UIConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
label = 'ui'
|
||||
name = 'hotpocket_backend.apps.ui'
|
||||
verbose_name = _('UI')
|
||||
17
services/backend/hotpocket_backend/apps/ui/constants.py
Normal file
17
services/backend/hotpocket_backend/apps/ui/constants.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class MessageLevelAlertClass(enum.Enum):
|
||||
debug = 'alert-secondary'
|
||||
info = 'alert-info'
|
||||
success = 'alert-success'
|
||||
warning = 'alert-warning'
|
||||
error = 'alert-danger'
|
||||
|
||||
|
||||
class StarUnstarAssociationViewMode(enum.Enum):
|
||||
STAR = 'STAR'
|
||||
UNSTAR = 'UNSTAR'
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_backend._meta import version as backend_version
|
||||
from hotpocket_backend.apps.core.conf import settings
|
||||
from hotpocket_backend.apps.core.context import get_request_id
|
||||
|
||||
|
||||
def site_title(request: HttpRequest) -> dict:
|
||||
return {
|
||||
'SITE_TITLE': settings.SITE_TITLE,
|
||||
}
|
||||
|
||||
|
||||
def image_tag(request: HttpRequest) -> dict:
|
||||
return {
|
||||
'IMAGE_ID': settings.IMAGE_ID,
|
||||
}
|
||||
|
||||
|
||||
def request_id(request: HttpRequest) -> dict:
|
||||
return {
|
||||
'REQUEST_ID': get_request_id(),
|
||||
}
|
||||
|
||||
|
||||
def htmx(request: HttpRequest) -> dict:
|
||||
return {
|
||||
'HTMX': (
|
||||
request.htmx
|
||||
if hasattr(request, 'htmx')
|
||||
else False
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def debug(request: HttpRequest) -> dict:
|
||||
return {
|
||||
'DEBUG': settings.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
def version(request: HttpRequest) -> dict:
|
||||
return {
|
||||
'VERSION': backend_version,
|
||||
}
|
||||
28
services/backend/hotpocket_backend/apps/ui/dto/base.py
Normal file
28
services/backend/hotpocket_backend/apps/ui/dto/base.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from django.http import HttpRequest
|
||||
import pydantic
|
||||
|
||||
|
||||
class BrowseParams(pydantic.BaseModel):
|
||||
view_name: str
|
||||
account_uuid: uuid.UUID
|
||||
search: str | None = pydantic.Field(default=None)
|
||||
before: uuid.UUID | None = pydantic.Field(default=None)
|
||||
after: uuid.UUID | None = pydantic.Field(default=None)
|
||||
limit: int = pydantic.Field(default=10)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls: type[typing.Self],
|
||||
*,
|
||||
request: HttpRequest,
|
||||
) -> typing.Self:
|
||||
return cls.model_validate({
|
||||
'view_name': request.resolver_match.url_name,
|
||||
'account_uuid': request.user.pk,
|
||||
**request.GET.dict(),
|
||||
})
|
||||
13
services/backend/hotpocket_backend/apps/ui/dto/saves.py
Normal file
13
services/backend/hotpocket_backend/apps/ui/dto/saves.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import pydantic
|
||||
|
||||
from hotpocket_common.constants import AssociationsSearchMode
|
||||
|
||||
from .base import BrowseParams as BaseBrowseParams
|
||||
|
||||
|
||||
class BrowseParams(BaseBrowseParams):
|
||||
limit: int = pydantic.Field(default=12)
|
||||
mode: AssociationsSearchMode = pydantic.Field(default=AssociationsSearchMode.DEFAULT)
|
||||
215
services/backend/hotpocket_backend/apps/ui/forms/accounts.py
Normal file
215
services/backend/hotpocket_backend/apps/ui/forms/accounts.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import (
|
||||
AuthenticationForm as BaseAuthenticationForm,
|
||||
PasswordChangeForm as BasePasswordChangeForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class LoginForm(BaseAuthenticationForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
'username',
|
||||
'password',
|
||||
FormActions(
|
||||
Submit('submit', _('Log in'), css_class='btn btn-primary'),
|
||||
template='ui/ui/forms/formactions.html',
|
||||
css_class='mb-0',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProfileForm(Form):
|
||||
INCLUDE_CANCEL = False
|
||||
|
||||
username = forms.CharField(
|
||||
label=_('Username'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
first_name = forms.CharField(
|
||||
label=_('First name'),
|
||||
max_length=150,
|
||||
required=True,
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label=_('Last name'),
|
||||
max_length=150,
|
||||
required=True,
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail address'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
]
|
||||
|
||||
|
||||
class FederatedProfileForm(ProfileForm):
|
||||
username = forms.CharField(
|
||||
label=_('Username'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
first_name = forms.CharField(
|
||||
label=_('First name'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label=_('Last name'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail address'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary',
|
||||
disabled=True,
|
||||
)
|
||||
|
||||
def clean(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
class PasswordForm(BasePasswordChangeForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3 col-form-label'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
'old_password',
|
||||
'new_password1',
|
||||
'new_password2',
|
||||
FormActions(
|
||||
self.get_submit_button(),
|
||||
template='ui/ui/forms/formactions-horizontal.html',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FederatedPasswordForm(PasswordForm):
|
||||
current_password = forms.CharField(
|
||||
label=_('Old password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password = forms.CharField(
|
||||
label=_('New password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password_again = forms.CharField(
|
||||
label=_('New password confirmation'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary disable',
|
||||
disabled=True,
|
||||
)
|
||||
|
||||
def clean(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
class SettingsForm(Form):
|
||||
INCLUDE_CANCEL = False
|
||||
|
||||
theme = forms.ChoiceField(
|
||||
label=_('Theme'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
choices=[
|
||||
(None, _('Bootstrap')),
|
||||
('cosmo', _('Cosmo')),
|
||||
],
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
auto_load_embeds = forms.ChoiceField(
|
||||
label=_('Auto load embedded content'),
|
||||
required=False,
|
||||
choices=[
|
||||
(None, _('---')),
|
||||
(True, _('Yes')),
|
||||
(False, _('No')),
|
||||
],
|
||||
help_text=_((
|
||||
'Auto loading embedded content (e.g. YouTube videos) means that '
|
||||
'you allow cookies from these sites.'
|
||||
)),
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'theme',
|
||||
'auto_load_embeds',
|
||||
]
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit(
|
||||
'submit',
|
||||
_('Save'),
|
||||
css_class='btn btn-primary',
|
||||
)
|
||||
|
||||
def clean(self) -> dict:
|
||||
result = super().clean()
|
||||
|
||||
theme = result.get('theme', None)
|
||||
if not theme:
|
||||
result['theme'] = None
|
||||
|
||||
auto_load_embeds = result.get('auto_load_embeds', None)
|
||||
if not auto_load_embeds:
|
||||
result['auto_load_embeds'] = None
|
||||
else:
|
||||
result['auto_load_embeds'] = (auto_load_embeds == 'True')
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class AssociationForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class ConfirmationForm(AssociationForm):
|
||||
canhazconfirm = forms.CharField(
|
||||
label='',
|
||||
required=True,
|
||||
widget=forms.HiddenInput,
|
||||
)
|
||||
title = forms.CharField(
|
||||
label=_('Title'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
url = forms.CharField(
|
||||
label=_('URL'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'canhazconfirm',
|
||||
'title',
|
||||
'url',
|
||||
]
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Confirm'), css_class='btn btn-primary')
|
||||
|
||||
|
||||
class EditForm(AssociationForm):
|
||||
url = forms.CharField(
|
||||
label=_('URL'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
target_title = forms.CharField(
|
||||
label=_('Title'),
|
||||
required=False,
|
||||
)
|
||||
target_description = forms.CharField(
|
||||
label=_('Description'),
|
||||
required=False,
|
||||
widget=forms.Textarea,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'url',
|
||||
'target_title',
|
||||
'target_description',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
result = {}
|
||||
|
||||
if cleaned_data['target_title']:
|
||||
result['target_title'] = cleaned_data['target_title']
|
||||
|
||||
if cleaned_data['target_description']:
|
||||
result['target_description'] = cleaned_data['target_description']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class RefreshForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Refresh'), css_class='btn btn-warning')
|
||||
|
||||
|
||||
class ArchiveForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Archive'), css_class='btn btn-danger')
|
||||
|
||||
|
||||
class DeleteForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Delete'), css_class='btn btn-danger')
|
||||
63
services/backend/hotpocket_backend/apps/ui/forms/base.py
Normal file
63
services/backend/hotpocket_backend/apps/ui/forms/base.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .layout import CancelButton
|
||||
|
||||
|
||||
class Form(forms.Form):
|
||||
HORIZONTAL = True
|
||||
INCLUDE_CANCEL = True
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Save'), css_class='btn btn-primary')
|
||||
|
||||
def get_extra_actions(self) -> typing.Any:
|
||||
result = []
|
||||
|
||||
if self.INCLUDE_CANCEL is True:
|
||||
result.append(CancelButton(_('Cancel')))
|
||||
|
||||
return result
|
||||
|
||||
def get_form_actions_template(self) -> str:
|
||||
if self.HORIZONTAL is True:
|
||||
return 'ui/ui/forms/formactions-horizontal.html'
|
||||
|
||||
return 'ui/ui/forms/formactions.html'
|
||||
|
||||
def get_form_helper_form_class(self) -> str:
|
||||
if self.HORIZONTAL is True:
|
||||
return 'form-horizontal'
|
||||
|
||||
return ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.form_class = self.get_form_helper_form_class()
|
||||
self.helper.label_class = 'col-md-3 col-form-label'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
*self.get_layout_fields(),
|
||||
FormActions(
|
||||
self.get_submit_button(),
|
||||
*self.get_extra_actions(),
|
||||
template=self.get_form_actions_template(),
|
||||
),
|
||||
)
|
||||
21
services/backend/hotpocket_backend/apps/ui/forms/imports.py
Normal file
21
services/backend/hotpocket_backend/apps/ui/forms/imports.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class PocketImportForm(Form):
|
||||
csv = forms.FileField(
|
||||
label=_('Export CSV'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return ['csv']
|
||||
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Import'), css_class='btn btn-primary')
|
||||
41
services/backend/hotpocket_backend/apps/ui/forms/layout.py
Normal file
41
services/backend/hotpocket_backend/apps/ui/forms/layout.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.utils import TEMPLATE_PACK
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
class CancelButton:
|
||||
CSS_CLASS = 'btn btn-secondary ui-form-cancel-button'
|
||||
|
||||
def __init__(self,
|
||||
label: str,
|
||||
*,
|
||||
css_id: str | None = None,
|
||||
css_class: str | None = None,
|
||||
**attributes,
|
||||
):
|
||||
self.label = label
|
||||
self.css_id = css_id
|
||||
self.css_class = css_class
|
||||
self.attributes = attributes
|
||||
|
||||
def render(self, form, context, template_pack: TEMPLATE_PACK, **kwargs):
|
||||
final_attributes = {
|
||||
'class': self.css_class or self.CSS_CLASS,
|
||||
**self.attributes,
|
||||
'type': 'button',
|
||||
'value': self.label,
|
||||
}
|
||||
|
||||
if self.css_id:
|
||||
final_attributes['id'] = self.css_id
|
||||
|
||||
context = {
|
||||
'attributes': final_attributes,
|
||||
}
|
||||
|
||||
return render_to_string(
|
||||
'ui/ui/forms/cancel_button.html',
|
||||
context=context,
|
||||
)
|
||||
21
services/backend/hotpocket_backend/apps/ui/forms/saves.py
Normal file
21
services/backend/hotpocket_backend/apps/ui/forms/saves.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django import forms
|
||||
from django.core import validators
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class CreateForm(Form):
|
||||
url = forms.CharField(
|
||||
label=_('URL'),
|
||||
required=True,
|
||||
validators=[
|
||||
validators.URLValidator(schemes=['http', 'https']),
|
||||
],
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return ['url']
|
||||
@@ -0,0 +1,3 @@
|
||||
from .associations import UIAssociationsService # noqa: F401
|
||||
from .imports import UIImportsService # noqa: F401
|
||||
from .saves import UISavesService # noqa: F401
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
|
||||
from hotpocket_soa.dto.associations import (
|
||||
AssociationOut,
|
||||
AssociationWithTargetOut,
|
||||
)
|
||||
from hotpocket_soa.services import AssociationsService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UIAssociationsService:
|
||||
def __init__(self):
|
||||
self.associations_service = AssociationsService()
|
||||
|
||||
def get_or_404(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID | None,
|
||||
pk: uuid.UUID,
|
||||
with_target: bool = False,
|
||||
allow_archived: bool = False,
|
||||
) -> AssociationOut | AssociationWithTargetOut:
|
||||
try:
|
||||
return AssociationsService().get(
|
||||
account_uuid=account_uuid,
|
||||
pk=pk,
|
||||
with_target=True,
|
||||
allow_archived=allow_archived,
|
||||
)
|
||||
except AssociationsService.AssociationNotFound as exception:
|
||||
LOGGER.error(
|
||||
'Association not found: account_uuid=`%s` pk=`%s`',
|
||||
account_uuid,
|
||||
pk,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise Http404()
|
||||
except AssociationsService.AssociationAccessDenied as exception:
|
||||
LOGGER.error(
|
||||
'Association access denied: account_uuid=`%s` pk=`%s`',
|
||||
account_uuid,
|
||||
pk,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise PermissionDenied()
|
||||
|
||||
def star_on_unstar(self,
|
||||
*,
|
||||
association: AssociationOut,
|
||||
star: bool,
|
||||
) -> AssociationWithTargetOut:
|
||||
if star is True:
|
||||
_ = AssociationsService().star(association=association)
|
||||
else:
|
||||
_ = AssociationsService().unstar(association=association)
|
||||
|
||||
return AssociationsService().get( # type: ignore[return-value]
|
||||
account_uuid=association.account_uuid,
|
||||
pk=association.pk,
|
||||
with_target=True,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from django import db
|
||||
from django.utils.timezone import get_current_timezone
|
||||
|
||||
from hotpocket_backend.apps.ui.services.workflows import ImportSaveWorkflow
|
||||
from hotpocket_backend.apps.ui.tasks import import_from_pocket
|
||||
from hotpocket_common.uuid import uuid7_from_timestamp
|
||||
|
||||
|
||||
class UIImportsService:
|
||||
def import_from_pocket(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
csv_path: str,
|
||||
) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
||||
result = []
|
||||
|
||||
with db.transaction.atomic():
|
||||
try:
|
||||
with open(csv_path, 'r', encoding='utf-8') as csv_file:
|
||||
csv_reader = csv.DictReader(
|
||||
csv_file,
|
||||
fieldnames=['title', 'url', 'time_added', 'tags', 'status'],
|
||||
)
|
||||
|
||||
current_timezone = get_current_timezone()
|
||||
|
||||
is_header = False
|
||||
for row in csv_reader:
|
||||
if is_header is False:
|
||||
is_header = True
|
||||
continue
|
||||
|
||||
timestamp = int(row['time_added'])
|
||||
|
||||
save, association = ImportSaveWorkflow().run(
|
||||
account_uuid=account_uuid,
|
||||
url=row['url'],
|
||||
title=row['title'],
|
||||
pk=uuid7_from_timestamp(timestamp),
|
||||
created_at=datetime.datetime.fromtimestamp(
|
||||
timestamp, tz=current_timezone,
|
||||
),
|
||||
)
|
||||
|
||||
result.append((save.pk, association.pk))
|
||||
finally:
|
||||
os.unlink(csv_path)
|
||||
|
||||
return result
|
||||
|
||||
def schedule_import_from_pocket(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
csv_path: str,
|
||||
) -> AsyncResult:
|
||||
return import_from_pocket.apply_async(
|
||||
kwargs={
|
||||
'account_uuid': account_uuid,
|
||||
'csv_path': csv_path,
|
||||
},
|
||||
)
|
||||
26
services/backend/hotpocket_backend/apps/ui/services/saves.py
Normal file
26
services/backend/hotpocket_backend/apps/ui/services/saves.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.http import Http404
|
||||
|
||||
from hotpocket_soa.dto.saves import SaveOut
|
||||
from hotpocket_soa.services import SavesService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UISavesService:
|
||||
def __init__(self):
|
||||
self.saves_service = SavesService()
|
||||
|
||||
def get_or_404(self, *, pk: uuid.UUID) -> SaveOut:
|
||||
try:
|
||||
return SavesService().get(pk=pk)
|
||||
except SavesService.SaveNotFound as exception:
|
||||
LOGGER.error(
|
||||
'Save not found: pk=`%s`', pk, exc_info=exception,
|
||||
)
|
||||
raise Http404()
|
||||
@@ -0,0 +1 @@
|
||||
from .saves import CreateSaveWorkflow, ImportSaveWorkflow # noqa: F401
|
||||
@@ -0,0 +1,2 @@
|
||||
from .create import CreateSaveWorkflow # noqa: F401
|
||||
from .import_ import ImportSaveWorkflow # noqa: F401
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from hotpocket_soa.dto.associations import AssociationOut
|
||||
from hotpocket_soa.dto.celery import AsyncResultOut
|
||||
from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SaveOut
|
||||
from hotpocket_soa.services import (
|
||||
AssociationsService,
|
||||
BotService,
|
||||
SaveProcessorService,
|
||||
SavesService,
|
||||
)
|
||||
|
||||
|
||||
class SaveWorkflow:
|
||||
def __init__(self):
|
||||
self.saves_service = SavesService()
|
||||
self.bot_service = BotService()
|
||||
self.save_processor_service = SaveProcessorService()
|
||||
self.associations_service = AssociationsService()
|
||||
|
||||
def create(self,
|
||||
account_uuid: uuid.UUID,
|
||||
save: ImportedSaveIn | SaveIn,
|
||||
) -> SaveOut:
|
||||
if save.is_netloc_banned is None:
|
||||
save.is_netloc_banned = self.bot_service.is_netloc_banned(
|
||||
url=save.url,
|
||||
)
|
||||
|
||||
return self.saves_service.create(
|
||||
account_uuid=account_uuid,
|
||||
save=save,
|
||||
)
|
||||
|
||||
def associate(self,
|
||||
account_uuid: uuid.UUID,
|
||||
target: SaveOut,
|
||||
pk: uuid.UUID | None = None,
|
||||
created_at: datetime.datetime | None = None,
|
||||
) -> AssociationOut:
|
||||
return self.associations_service.create(
|
||||
account_uuid=account_uuid,
|
||||
target=target,
|
||||
pk=pk,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
def schedule_processing(self, save: SaveOut) -> AsyncResultOut:
|
||||
return self.save_processor_service.schedule_process_save( # noqa: F841
|
||||
save=save,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.accounts.types import PAccount
|
||||
from hotpocket_soa.dto.saves import SaveIn
|
||||
|
||||
from .base import SaveWorkflow
|
||||
|
||||
|
||||
class CreateSaveWorkflow(SaveWorkflow):
|
||||
def run(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
force_post_save: bool = False,
|
||||
) -> HttpResponse:
|
||||
save = self.create(
|
||||
account.pk,
|
||||
SaveIn(url=url),
|
||||
)
|
||||
|
||||
association = self.associate(account.pk, save)
|
||||
|
||||
if save.last_processed_at is None:
|
||||
processing_result = self.schedule_processing(save) # noqa: F841
|
||||
|
||||
response = redirect(reverse('ui.associations.browse'))
|
||||
if force_post_save is True or save.is_netloc_banned is True:
|
||||
response = redirect(reverse(
|
||||
'ui.associations.post_save',
|
||||
args=(association.pk,),
|
||||
))
|
||||
else:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.SUCCESS,
|
||||
_('Your link has been saved!'),
|
||||
)
|
||||
|
||||
response.headers['X-HotPocket-Testing-Save-PK'] = save.pk
|
||||
response.headers['X-HotPocket-Testing-Association-PK'] = association.pk
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from hotpocket_soa.dto.associations import AssociationOut
|
||||
from hotpocket_soa.dto.saves import ImportedSaveIn, SaveOut
|
||||
|
||||
from .base import SaveWorkflow
|
||||
|
||||
|
||||
class ImportSaveWorkflow(SaveWorkflow):
|
||||
def run(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
url: str,
|
||||
title: str,
|
||||
pk: uuid.UUID,
|
||||
created_at: datetime.datetime,
|
||||
) -> tuple[SaveOut, AssociationOut]:
|
||||
save = self.create(
|
||||
account_uuid,
|
||||
ImportedSaveIn(
|
||||
url=url,
|
||||
title=title,
|
||||
),
|
||||
)
|
||||
|
||||
association = self.associate(
|
||||
account_uuid,
|
||||
save,
|
||||
pk=pk,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
return save, association
|
||||
5
services/backend/hotpocket_backend/apps/ui/static/ui/css/bootstrap-icons.min.css
vendored
Normal file
5
services/backend/hotpocket_backend/apps/ui/static/ui/css/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6
services/backend/hotpocket_backend/apps/ui/static/ui/css/bootstrap.min.css
vendored
Normal file
6
services/backend/hotpocket_backend/apps/ui/static/ui/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
html {
|
||||
background-color: var(--bs-tertiary-body-bg);
|
||||
}
|
||||
|
||||
body, html {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body > .ui-viewport {
|
||||
padding-bottom: 3.5rem;
|
||||
padding-top: 3.5rem;
|
||||
}
|
||||
|
||||
body.ui-js-enabled .ui-noscript-show {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body:not(.ui-js-enabled) .ui-noscript-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#navbar .ui-navbar-brand > img {
|
||||
border-radius: 0.25rem;
|
||||
height: 1.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ui-save-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ui-save-card .card-footer .spinner-border {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-save-card .card-footer .spinner-border.htmx-request {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui-actions-menu-trigger {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
outline-color: transparent;
|
||||
}
|
||||
|
||||
.ui-actions-menu-trigger::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#button-bar .nav-link {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
#button-bar .navbar-nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
body, html {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
}
|
||||
|
||||
body.ui-mode-standalone #button-bar {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
body.ui-mode-standalone #offcanvas-controls .offcanvas-body {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ui-messages:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-uname {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
#BrowseSavesView-Search .form-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
7
services/backend/hotpocket_backend/apps/ui/static/ui/js/bootstrap.bundle.min.js
vendored
Normal file
7
services/backend/hotpocket_backend/apps/ui/static/ui/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,49 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket) => {
|
||||
class HotPocketHTMXConfirmPlugin {
|
||||
constructor (app) {
|
||||
this.app = app;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener('htmx:confirm', this.onHTMXConfirm);
|
||||
};
|
||||
onHTMXConfirm = (event) => {
|
||||
if (!event.detail.question) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const modal = this.app.plugin('UI.Modal')({
|
||||
triggerElement: event.detail.elt,
|
||||
confirm: true,
|
||||
onHide: (action) => {
|
||||
if (action === 'confirm') {
|
||||
event.detail.issueRequest(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
modal.show();
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('htmx.confirm', (app) => {
|
||||
return new HotPocketHTMXConfirmPlugin(app);
|
||||
});
|
||||
})(window.HotPocket);
|
||||
@@ -0,0 +1,29 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket, htmx) => {
|
||||
class HotPocketHTMXPlugin {
|
||||
constructor (app) {
|
||||
if (app.options.debug) {
|
||||
htmx.logAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.HotPocket.addPlugin('htmx', (app) => {
|
||||
return new HotPocketHTMXPlugin(app);
|
||||
});
|
||||
})(window.HotPocket, window.htmx);
|
||||
@@ -0,0 +1,75 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
(() => {
|
||||
const DEFAULT_OPTIONS = {
|
||||
debug: false,
|
||||
};
|
||||
|
||||
class HotPocketApp {
|
||||
constructor (options) {
|
||||
this.plugins = [];
|
||||
this.options = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
initPlugins = (plugins) => {
|
||||
for (let pluginSpec of plugins) {
|
||||
const [name, pluginFactory] = pluginSpec;
|
||||
this.plugins.push([name, pluginFactory(this)]);
|
||||
}
|
||||
}
|
||||
plugin = (name) => {
|
||||
const pluginSpec = this.plugins.find((pluginSpec) => {
|
||||
return pluginSpec[0] === name;
|
||||
});
|
||||
|
||||
if (pluginSpec !== undefined) {
|
||||
return pluginSpec[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
console.log('HotPocketApp.onLoad()', event);
|
||||
for (let pluginSpec of this.plugins) {
|
||||
if (pluginSpec[1].onLoad) {
|
||||
pluginSpec[1].onLoad(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = [];
|
||||
|
||||
window.HotPocket = {
|
||||
app: null,
|
||||
addPlugin: (name, pluginFactory) => {
|
||||
plugins.push([name, pluginFactory]);
|
||||
},
|
||||
run: (options) => {
|
||||
if (window.HotPocket.app === null) {
|
||||
window.HotPocket.app = new HotPocketApp(options);
|
||||
window.HotPocket.app.initPlugins(plugins);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', (event) => {
|
||||
window.HotPocket.app.onLoad(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket, htmx) => {
|
||||
class BrowseSavesView {
|
||||
constructor (app) {
|
||||
this.app = app;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener('HotPocket:BrowseSavesView:updateLoadMoreButton', (event) => {
|
||||
const button = document.querySelector('#BrowseSavesView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
htmx.process('#BrowseSavesView .ui-load-more-button');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI.BrowseSavesView', (app) => {
|
||||
return new BrowseSavesView(app);
|
||||
});
|
||||
})(window.HotPocket, window.htmx);
|
||||
@@ -0,0 +1,39 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket) => {
|
||||
class HotPocketUIFormPlugin {
|
||||
constructor (app) {
|
||||
}
|
||||
onLoad (event) {
|
||||
for (let cancelButton of document.querySelectorAll('.ui-form-cancel-button')) {
|
||||
cancelButton.addEventListener('click', this.onCancelButtonClick);
|
||||
}
|
||||
}
|
||||
onCancelButtonClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
window.history.back();
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI.Form', (app) => {
|
||||
return new HotPocketUIFormPlugin(app);
|
||||
});
|
||||
})(window.HotPocket);
|
||||
@@ -0,0 +1,63 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket, bootstrap) => {
|
||||
const LEVEL_TO_BOOTSTRAP_CLASS = {
|
||||
'debug': 'alert-secondary',
|
||||
'info': 'alert-info',
|
||||
'success': 'alert-success',
|
||||
'warning': 'alert-warning',
|
||||
'error': 'alert-danger',
|
||||
};
|
||||
|
||||
class HotPocketUIMessagesPlugin {
|
||||
constructor (app) {
|
||||
this.containerElement = null;
|
||||
this.templateElement = null;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
this.containerElement = document.querySelector('.ui-messages');
|
||||
this.templateElement = document.querySelector('#Messages-Alert');
|
||||
|
||||
for (let element of this.containerElement.querySelectorAll('.alert')) {
|
||||
element.classList.add('alert-dismissible');
|
||||
element.classList.add('fade');
|
||||
element.classList.add('show');
|
||||
|
||||
new bootstrap.Alert(element);
|
||||
}
|
||||
|
||||
document.addEventListener('HotPocket:UI:Messages:addMessage', this.onAddMessage);
|
||||
};
|
||||
onAddMessage = (event) => {
|
||||
const alertContentElement = this.templateElement.content.cloneNode(true);
|
||||
|
||||
const alertElement = alertContentElement.querySelector('.alert');
|
||||
alertElement.classList.add(LEVEL_TO_BOOTSTRAP_CLASS[event.detail.level] || 'alert-secondary');
|
||||
if (event.detail.extra_tags) {
|
||||
alertElement.classList.add(event.detail.extra_tags);
|
||||
}
|
||||
alertElement.querySelector('.ui-alert-message').innerHTML = event.detail.message;
|
||||
new bootstrap.Alert(alertElement);
|
||||
|
||||
this.containerElement.appendChild(alertContentElement);
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI.Messages', (app) => {
|
||||
return new HotPocketUIMessagesPlugin(app);
|
||||
});
|
||||
})(window.HotPocket, window.bootstrap);
|
||||
@@ -0,0 +1,89 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket, bootstrap) => {
|
||||
const DEFAULT_OPTIONS = {
|
||||
triggerElement: null,
|
||||
confirm: false,
|
||||
onHide: () => {},
|
||||
};
|
||||
|
||||
class HotPocketModal {
|
||||
constructor (app, options) {
|
||||
this.app = app;
|
||||
this.options = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
this.action = 'dismiss';
|
||||
|
||||
this.triggerElement = this.options.triggerElement;
|
||||
|
||||
if (options.confirm === false) {
|
||||
this.triggerElement.addEventListener('click', this.onTriggerElementClick);
|
||||
}
|
||||
|
||||
this.templateElement = document.querySelector(
|
||||
this.triggerElement.dataset.uiModal,
|
||||
);
|
||||
|
||||
this.modalElement = null;
|
||||
|
||||
this.modal = null;
|
||||
|
||||
this.triggerElement.HotPocketModal = this;
|
||||
}
|
||||
show = () => {
|
||||
this.modalElement = this.templateElement.content.cloneNode(true).querySelector('.modal');
|
||||
|
||||
this.modalElement.querySelector('[data-ui-modal-action]').addEventListener(
|
||||
'click', this.onActionClick,
|
||||
);
|
||||
|
||||
this.modalElement.addEventListener('hidden.bs.modal', this.onModalHidden);
|
||||
|
||||
this.modal = new bootstrap.Modal(this.modalElement);
|
||||
|
||||
document.body.appendChild(this.modalElement);
|
||||
this.modal.show();
|
||||
};
|
||||
onTriggerElementClick = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.show();
|
||||
|
||||
return false;
|
||||
};
|
||||
onActionClick = (event) => {
|
||||
this.action = event.target.dataset['uiModalAction'];
|
||||
this.modal.hide();
|
||||
};
|
||||
onModalHidden = (event) => {
|
||||
document.body.removeChild(this.modalElement);
|
||||
|
||||
this.modal = null;
|
||||
this.modalElement = null;
|
||||
|
||||
this.options.onHide(this.action);
|
||||
};
|
||||
};
|
||||
|
||||
HotPocket.addPlugin('UI.Modal', (app) => {
|
||||
return (options) => {
|
||||
return new HotPocketModal(app, options);
|
||||
};
|
||||
});
|
||||
})(window.HotPocket, window.bootstrap);
|
||||
@@ -0,0 +1,77 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket) => {
|
||||
class ViewAssociationView {
|
||||
constructor (app) {
|
||||
this.app = app;
|
||||
this.onWindowResizeTimeout = null;
|
||||
}
|
||||
clearOnWindowResizeTimeout () {
|
||||
if (this.onWindowResizeTimeout) {
|
||||
window.clearTimeout(this.onWindowResizeTimeout);
|
||||
this.onWindowResizeTimeout = null;
|
||||
}
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener('HotPocket:ViewAssociationView:loadEmbed', this.onWindowResize);
|
||||
|
||||
window.addEventListener('resize', (event) => {
|
||||
this.clearOnWindowResizeTimeout();
|
||||
this.onWindowResizeTimeout = window.setTimeout(
|
||||
this.onWindowResize, 300,
|
||||
);
|
||||
});
|
||||
this.onWindowResize();
|
||||
|
||||
for (let shareButton of document.querySelectorAll('#ViewAssociationView .ui-share-button')) {
|
||||
if (navigator.share) {
|
||||
shareButton.addEventListener('click', this.onShareButtonClick);
|
||||
} else {
|
||||
shareButton.addClass('d-none');
|
||||
}
|
||||
}
|
||||
};
|
||||
onWindowResize = (event) => {
|
||||
const widthReference = document.querySelector('#ViewAssociationView .ui-width-reference');
|
||||
|
||||
const youtubeIFrame = document.querySelector('#ViewAssociationView .ui-youtube-iframe');
|
||||
if (youtubeIFrame) {
|
||||
const width = widthReference.offsetWidth - 8;
|
||||
const height = Math.ceil(width / 1.6);
|
||||
youtubeIFrame.setAttribute('width', width);
|
||||
youtubeIFrame.setAttribute('height', height);
|
||||
}
|
||||
};
|
||||
onShareButtonClick = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const shareUrl = new URL(window.location.href);
|
||||
shareUrl.searchParams.set('share', 'true');
|
||||
|
||||
navigator.share({
|
||||
title: document.title,
|
||||
url: shareUrl.toString(),
|
||||
});
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI.ViewAssociationView', (app) => {
|
||||
return new ViewAssociationView(app);
|
||||
});
|
||||
})(window.HotPocket);
|
||||
@@ -0,0 +1,33 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket) => {
|
||||
class HotPocketUIPlugin {
|
||||
constructor (app) {
|
||||
document.body.classList.add('ui-js-enabled');
|
||||
|
||||
if (window.navigator.standalone === true) {
|
||||
document.querySelector('body').classList.add('ui-mode-standalone');
|
||||
} else {
|
||||
document.querySelector('body').classList.remove('ui-mode-standalone');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI', (app) => {
|
||||
return new HotPocketUIPlugin(app);
|
||||
});
|
||||
})(window.HotPocket);
|
||||
1
services/backend/hotpocket_backend/apps/ui/static/ui/js/htmx.min.js
vendored
Normal file
1
services/backend/hotpocket_backend/apps/ui/static/ui/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
26
services/backend/hotpocket_backend/apps/ui/tasks.py
Normal file
26
services/backend/hotpocket_backend/apps/ui/tasks.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def import_from_pocket(*,
|
||||
account_uuid: uuid.UUID,
|
||||
csv_path: str,
|
||||
) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
||||
from hotpocket_backend.apps.ui.services import UIImportsService
|
||||
|
||||
try:
|
||||
return UIImportsService().import_from_pocket(
|
||||
account_uuid=account_uuid,
|
||||
csv_path=csv_path,
|
||||
)
|
||||
except Exception as exception:
|
||||
LOGGER.error('Unhandled exception: %s', exception, exc_info=exception)
|
||||
raise exception
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n ui %}
|
||||
|
||||
{% block title %}{% translate 'Log in' %}{% endblock %}
|
||||
|
||||
{% block body_class %}d-flex justify-content-center flex-column{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<p class="fs-3 mb-0">{{ SITE_TITLE }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if not MODEL_AUTH_IS_DISABLED %}
|
||||
{% crispy form %}
|
||||
{% endif %}
|
||||
|
||||
{% if not MODEL_AUTH_IS_DISABLED and HOTPOCKET_OIDC_IS_ENABLED %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
{% if HOTPOCKET_OIDC_IS_ENABLED %}
|
||||
<a
|
||||
class="btn btn-primary d-block"
|
||||
href="{% url 'social:begin' 'hotpocket_oidc' %}"
|
||||
>
|
||||
{% blocktranslate %}Log in with {{ HOTPOCKET_OIDC_DISPLAY_NAME }}{% endblocktranslate %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "ui/ui/partials/uname.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n ui %}
|
||||
|
||||
{% block body_class %}d-flex justify-content-center flex-column{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<p class="fs-3 mb-0">{{ SITE_TITLE }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="lead">{% translate "See you later!" %}</p>
|
||||
<p class="mb-0">
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
href="{% url 'ui.accounts.login' %}"
|
||||
>
|
||||
{% translate 'Log in' %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% include "ui/ui/partials/uname.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<ul class="nav nav-tabs my-3">
|
||||
<li class="nav-item">
|
||||
{% if active_tab == 'profile' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.profile' %}">
|
||||
{% endif %}
|
||||
{% translate 'Profile' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if active_tab == 'password' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.password' %}">
|
||||
{% endif %}
|
||||
{% translate 'Password' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if active_tab == 'settings' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.settings' %}">
|
||||
{% endif %}
|
||||
{% translate 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{{ title }} | {% translate 'Account' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Account' %}</p>
|
||||
|
||||
{% include 'ui/accounts/partials/nav.html' with active_tab=active_tab %}
|
||||
|
||||
{% if is_federated %}
|
||||
<div class="alert alert-info my-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Heads up!' %}</h4>
|
||||
<p class="lead mb-0">
|
||||
{% blocktranslate %}
|
||||
Your account is federated from an external provider. You can update
|
||||
your details in your Identity Provider.
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Archive a save?' %} | {% translate 'My Saves' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Archive a save?' %}</p>
|
||||
|
||||
{% include 'ui/associations/partials/archive_confirmation.html' %}
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'My Saves' %}{% endblock %}
|
||||
|
||||
{% block top_nav_title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div id="BrowseSavesView" class="container">
|
||||
<div class="row mt-3">
|
||||
<div class="col col-12 d-md-flex align-items-center mb-1 mb-md-3">
|
||||
<form action="{% url 'ui.associations.browse' %}" class="mb-2 mb-md-0" method="GET">
|
||||
<input type="hidden" name="search" value="{{ params.search|default_if_none:'' }}">
|
||||
<input type="hidden" name="limit" value="{{ params.limit }}">
|
||||
<input type="hidden" name="mode" value="{{ params.mode.value }}">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
{% translate 'First page' %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action="{% url 'ui.associations.browse' %}"
|
||||
class="ms-auto mb-2 mb-md-0 d-md-flex"
|
||||
id="BrowseSavesView-Search"
|
||||
method="GET"
|
||||
>
|
||||
<div class="mb-2 mb-md-0 me-0 me-md-2">
|
||||
<select class="form-select form-select-sm" aria-label="{% translate 'Browse mode' %}" name="mode">
|
||||
<option value="" {% if params.mode.value == 'DEFAULT' %}selected{% endif %}>
|
||||
{% translate 'Default' %}
|
||||
</option>
|
||||
<option value="STARRED" {% if params.mode.value == 'STARRED' %}selected{% endif %}>
|
||||
{% translate 'Starred' %}
|
||||
</option>
|
||||
<option value="ARCHIVED" {% if params.mode.value == 'ARCHIVED' %}selected{% endif %}>
|
||||
{% translate 'Archived' %}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2 mb-md-0 me-0 me-md-2">
|
||||
<input
|
||||
aria-label="Search"
|
||||
class="form-control form-control-sm"
|
||||
name="search"
|
||||
placeholder="{% translate 'Search' %}"
|
||||
type="text"
|
||||
value="{{ params.search|default_if_none:'' }}"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2 mb-md-0 me-0 me-md-2">
|
||||
<button class="btn btn-primary btn-sm" type="submit">
|
||||
{% translate 'Apply' %}
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-link btn-sm"
|
||||
href="{% url 'ui.associations.browse' %}"
|
||||
>
|
||||
{% translate 'Reset' %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row ui-saves">
|
||||
{% include "ui/associations/partials/associations.html" with associations=associations params=params %}
|
||||
</div>
|
||||
|
||||
<div class="row {% if not associations and params.before is None %}d-none{% endif %}">
|
||||
<div class="col col-12">
|
||||
<p class="mb-3 text-center">
|
||||
<button
|
||||
class="btn btn-primary {% if not before %}disabled{% endif %} ui-noscript-hide ui-load-more-button"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-push-url="true"
|
||||
hx-swap="beforeend"
|
||||
hx-target="#BrowseSavesView .ui-saves"
|
||||
>
|
||||
{% translate 'Load more' %}
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="btn btn-primary {% if not before %}disabled{% endif %} ui-noscript-show"
|
||||
href="{{ next_url }}"
|
||||
>
|
||||
{% translate 'Load more' %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="BrowseSavesView-ArchiveModal">
|
||||
<div class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% translate 'Archive a save?' %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% include 'ui/associations/partials/archive_confirmation.html' with alert_class="mb-0" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'Cancel' %}</button>
|
||||
<button type="button" class="btn btn-danger" data-ui-modal-action="confirm">
|
||||
{% translate 'Archive' %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="BrowseSavesView-RefreshModal">
|
||||
<div class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% translate 'Refresh a save?' %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% include 'ui/associations/partials/refresh_confirmation.html' with alert_class="mb-0" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'Cancel' %}</button>
|
||||
<button type="button" class="btn btn-warning" data-ui-modal-action="confirm">
|
||||
{% translate 'Refresh' %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="BrowseSavesView-DeleteModal">
|
||||
<div class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% translate 'Delete a save?' %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% include 'ui/associations/partials/delete_confirmation.html' with alert_class="mb-0" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'Cancel' %}</button>
|
||||
<button type="button" class="btn btn-danger" data-ui-modal-action="confirm">
|
||||
{% translate 'Delete' %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Delete a save?' %} | {% translate 'My Saves' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Delete a save?' %}</p>
|
||||
|
||||
{% include 'ui/associations/partials/delete_confirmation.html' %}
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Edit a link' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Edit a link' %}</p>
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,6 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<div class="alert alert-danger {{ alert_class|default_if_none:'' }}">
|
||||
<h4 class="alert-heading">{% translate 'Danger Zone' %}</h4>
|
||||
<p class="mb-0">{% translate 'Are you sure you want to archive this save?' %}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<div class="card ui-save-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="{% url 'ui.associations.view' pk=association.pk %}">
|
||||
{% if association.title %}
|
||||
{{ association.title }}
|
||||
{% else %}
|
||||
{% translate 'Untitled' %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</h5>
|
||||
{% if association.description %}
|
||||
<p class="card-text">{{ association.description|truncatechars:125 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center">
|
||||
<a href="{{ association.target.url }}" target="_blank" rel="noopener noreferer"><small>{{ association.target.url|render_url_domain }}</small></a>
|
||||
<div class="ms-auto flex-shrink-0 d-flex align-items-center">
|
||||
{% if not association.archived_at %}
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">{% translate 'Processing' %}</span>
|
||||
</div>
|
||||
{% if association.is_starred %}
|
||||
<i class="bi bi-star-fill ms-2"></i>
|
||||
{% endif %}
|
||||
<div class="btn-group dropup ms-2">
|
||||
<button class="btn btn-secondary btn-sm dropdown-toggle ui-actions-menu-trigger" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if association.is_starred %}
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
hx-get="{% url 'ui.associations.unstar' pk=association.pk %}"
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-swap="innerHTML"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
href="{% url 'ui.associations.unstar' pk=association.pk %}"
|
||||
>
|
||||
<i class="bi bi-star"></i> {% translate 'Unstar' %}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
hx-get="{% url 'ui.associations.star' pk=association.pk %}"
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-swap="innerHTML"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
href="{% url 'ui.associations.star' pk=association.pk %}"
|
||||
>
|
||||
<i class="bi bi-star-fill"></i> {% translate 'Star' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
href="{% url 'ui.associations.edit' pk=association.pk %}"
|
||||
>
|
||||
<i class="bi bi-pencil"> </i> {% translate 'Edit' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item text-warning"
|
||||
data-ui-modal="#BrowseSavesView-RefreshModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-post="{% url 'ui.associations.refresh' pk=association.pk %}"
|
||||
hx-swap="none"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
hx-vars='{"canhazconfirm":true}'
|
||||
href="{% url 'ui.associations.refresh' pk=association.pk %}"
|
||||
>
|
||||
<i class="bi bi-arrow-repeat"> </i> {% translate 'Refresh' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item text-danger"
|
||||
data-ui-modal="#BrowseSavesView-ArchiveModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-post="{% url 'ui.associations.archive' pk=association.pk %}"
|
||||
hx-swap="delete"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
hx-vars='{"canhazconfirm":true}'
|
||||
href="{% url 'ui.associations.archive' pk=association.pk %}"
|
||||
>
|
||||
<i class="bi bi-archive"></i> {% translate 'Archive' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<i class="bi bi-archive-fill"></i>
|
||||
<a
|
||||
class="btn btn-sm btn-danger ms-2"
|
||||
data-ui-modal="#BrowseSavesView-DeleteModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-post="{% url 'ui.associations.delete' pk=association.pk %}"
|
||||
hx-swap="delete"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
hx-vars='{"canhazconfirm":true}'
|
||||
href="{% url 'ui.associations.delete' pk=association.pk %}"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% for association in associations %}
|
||||
<div class="col col-12 col-md-6 col-lg-4 col-xl-3 mb-3" data-association="{{ association.pk }}">
|
||||
{% include 'ui/associations/partials/association_card.html' with association=association %}
|
||||
</div>
|
||||
{% empty %}
|
||||
{% if not HTMX %}
|
||||
<div class="col col-12 mb-4 text-center">
|
||||
<p class="display-4">¯\_(ツ)_/¯</p>
|
||||
{% if params.before is None %}
|
||||
<p>{% translate "It's empty. Too empty..." %}</p>
|
||||
{% if params.mode == 'DEFAULT' %}
|
||||
<p class="mb-0">
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
href="{% url 'ui.saves.create' %}"
|
||||
>
|
||||
<i class="bi bi-plus"></i> {% translate 'Save a link' %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="mb-0">{% translate "You've reached the end of the line." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<div class="alert alert-danger {{ alert_class|default_if_none:'' }}">
|
||||
<h4 class="alert-heading">{% translate 'Point of no return' %}</h4>
|
||||
<p class="lead mb-0">{% translate 'Are you sure you want to delete this save?' %}</p>
|
||||
<p class="mb-0"><strong>This operation cannot be undone.</strong></p>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<div class="alert alert-warning {{ alert_class|default_if_none:'' }}">
|
||||
<h4 class="alert-heading">{% translate 'Caution!' %}</h4>
|
||||
<p class="mb-0">{% translate 'Are you sure you want to refresh this save?' %}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Saved!' %}{% endblock %}
|
||||
|
||||
{% block button_bar_class %}d-none{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
{% if association.target.is_netloc_banned %}
|
||||
<div class="alert alert-warning mt-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Heads up!' %}</h4>
|
||||
<p class="lead mb-2">
|
||||
{% translate "Your link has been saved, but..." %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktranslate %}
|
||||
The link is for a site that makes it difficult to extract metadata from. As such, sane defaults were used. This could make the save hard to identify.
|
||||
{% endblocktranslate %}
|
||||
<br>
|
||||
{% blocktranslate %}
|
||||
Click the button below to edit the save's metadata to your liking.
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<a
|
||||
class="btn btn-primary btn-sm"
|
||||
href="{% url 'ui.associations.edit' pk=association.pk %}"
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-pencil"></i> {% translate 'Edit' %}
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-success btn-sm"
|
||||
href="{% url 'ui.associations.browse' %}"
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-house-heart-fill"></i> {% translate 'Home' %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success mt-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Saved!' %}</h4>
|
||||
<p class="lead">
|
||||
{% translate "Your link has been saved!" %}
|
||||
</p>
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<a
|
||||
class="btn btn-primary btn-sm"
|
||||
href="{% url 'ui.associations.view' pk=association.pk %}"
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-eye"></i> {% translate 'View the link' %}
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-success btn-sm"
|
||||
href="{% url 'ui.associations.browse' %}"
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-house-heart-fill"></i> {% translate 'Home' %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Refresh a save?' %} | {% translate 'My Saves' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Refresh a save?' %}</p>
|
||||
|
||||
{% include 'ui/associations/partials/refresh_confirmation.html' %}
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{{ association.title }}{% endblock %}
|
||||
|
||||
{% block button_bar_class %}d-none{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div id="ViewAssociationView" class="container">
|
||||
<p class="display-3 mt-3 mb-0 text-center">
|
||||
{% if association.title %}
|
||||
{{ association.title }}
|
||||
{% else %}
|
||||
{{ association.target.url }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="lead mb-3 text-center ui-width-reference">
|
||||
<span class="text-muted">
|
||||
{% blocktranslate with created_at=association.created_at %}Saved on {{ created_at }}{% endblocktranslate %}
|
||||
</span>
|
||||
<br>
|
||||
<a href="{{ association.target.url }}" target="_blank" rel="noopener noreferer">
|
||||
{% translate 'View original' %} <i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
</p>
|
||||
{% if show_controls %}
|
||||
<p class="mb-3 text-center">
|
||||
{% spaceless %}
|
||||
<a
|
||||
class="btn btn-primary btn-sm"
|
||||
href="{% url 'ui.associations.edit' pk=association.pk %}"
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-pencil"></i> {% translate 'Edit' %}
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-secondary btn-sm ms-2 ui-noscript-hide ui-share-button"
|
||||
href="#"
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-box-arrow-up"></i> {% translate 'Share' %}
|
||||
</a>
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if association.description %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 col-lg-6 offset-lg-3">
|
||||
{{ association.description }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if association.target.is_youtube_video %}
|
||||
<div class="ui-embed">
|
||||
{% if not request.user.is_anonymous and request.user.settings.auto_load_embeds %}
|
||||
{% include "ui/saves/partials/embed.html" with save=association.target %}
|
||||
{% else %}
|
||||
<div class="alert alert-warning mb-0 ui-noscript-hide">
|
||||
<h4 class="alert-heading">{% translate 'Heads up!' %}</h4>
|
||||
<p class="lead">
|
||||
{% blocktranslate %}
|
||||
The link contains embeddable content. Loading it means that
|
||||
you accept cookies from the site - YouTube.
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
hx-post="{% url 'ui.saves.embed' %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target=".ui-embed"
|
||||
hx-vars='{"pk":"{{ association.target.pk }}"}'
|
||||
role="button"
|
||||
>
|
||||
<i class="bi bi-eye"></i> {% translate 'Load content' %}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% load i18n static ui %}
|
||||
<!doctype html>
|
||||
<!--
|
||||
A
|
||||
_ _ _ _ _ _
|
||||
| |__ | |_| |__ | | __ _| |__ ___ _ __ | |
|
||||
| '_ \| __| '_ \| |/ _` | '_ \/ __| | '_ \| |
|
||||
| |_) | |_| | | | | (_| | |_) \__ \_| |_) | |
|
||||
|_.__/ \__|_| |_|_|\__,_|_.__/|___(_) .__/|_|
|
||||
|_|
|
||||
production
|
||||
-->
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
|
||||
<meta name="generator" content="pl.bthlabs.HotPocket.backend@{{ IMAGE_ID }}">
|
||||
<meta name="theme-color" content="#2b3035"/>
|
||||
<title>{% block title %}{% translate 'Not Found' %}{% endblock %} | {{ SITE_TITLE }}</title>
|
||||
<link href="{% static 'ui/css/bootstrap.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'ui/css/bootstrap-icons.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'ui/css/hotpocket-backend.css' %}" rel="stylesheet">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="HotPocket">
|
||||
<link rel="apple-touch-icon" href="{% static 'ui/img/icon-180.png' %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'ui/img/icon-32.png' %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'ui/img/icon-16.png' %}">
|
||||
<link rel="manifest" href="{% url 'ui.meta.manifest_json' %}">
|
||||
{% block page_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}" hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Oops!' %}{% endblock %}
|
||||
|
||||
{% block button_bar_class %}d-none{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<div class="alert alert-danger mt-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Oops!' %}</h4>
|
||||
<p class="lead mb-0">
|
||||
{% translate "HotPocket couldn't complete this operation." %}
|
||||
</p>
|
||||
<p>{% translate 'Please try again later.' %}</p>
|
||||
<hr>
|
||||
<p class="mb-0">RequestID: <code>{{ REQUEST_ID }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Not Found' %}{% endblock %}
|
||||
|
||||
{% block button_bar_class %}d-none{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<div class="alert alert-danger mt-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Not Found' %}</h4>
|
||||
<p class="lead mb-0">
|
||||
{% translate "This isn't the page you were looking for." %}
|
||||
</p>
|
||||
<hr>
|
||||
<p class="mb-0">RequestID: <code>{{ REQUEST_ID }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Import from Pocket' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Import from Pocket' %}</p>
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,157 @@
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block body %}
|
||||
<nav id="navbar" class="navbar navbar-expand-sm bg-body-tertiary fixed-top">
|
||||
<div class="container">
|
||||
<ul class="navbar-nav flex-row flex-grow-1">
|
||||
{% if not request.user.is_anonymous %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle ui-navbar-brand" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{% spaceless %}
|
||||
<img src="{% static 'ui/img/icon-180.png' %}">
|
||||
<span class="ms-2">{% block top_nav_title %}HotPocket{% endblock %}</span>
|
||||
{% endspaceless %}
|
||||
</a>
|
||||
<ul class="dropdown-menu position-absolute">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% browse_associations_url %}">
|
||||
<i class="bi bi-house-heart-fill"></i> {% translate "Home" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% browse_associations_url mode='STARRED' %}">
|
||||
<i class="bi bi-star-fill"></i> {% translate "Starred" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% browse_associations_url mode='ARCHIVED' %}">
|
||||
<i class="bi bi-archive-fill"></i> {% translate "Archived" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link pe-none ui-navbar-brand">
|
||||
<img src="{% static 'ui/img/icon-180.png' %}">
|
||||
<span class="ms-2">{{ SITE_TITLE }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item ms-auto d-flex align-items-center">
|
||||
<a class="nav-link px-1 py-0 fs-3" data-bs-toggle="offcanvas" href="#offcanvas-controls" aria-controls="offcanvas-controls">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="ui-viewport">
|
||||
{% spaceless %}
|
||||
<div class="ui-messages container my-3">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert {{ message|alert_class}} mt-2">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close ui-noscript-hide" data-bs-dismiss="alert" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
|
||||
{% block page_body %}
|
||||
{% endblock %}
|
||||
|
||||
<template id="Messages-Alert">
|
||||
<div class="alert alert-dismissible fade show mt-2">
|
||||
<span class="ui-alert-message"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{% if not request.user.is_anonymous %}
|
||||
<nav id="button-bar" class="navbar navbar-expand-sm bg-body-tertiary fixed-bottom {% block button_bar_class %}{% endblock %}">
|
||||
<div class="container">
|
||||
<ul class="navbar-nav my-0 mx-auto">
|
||||
<li class="nav-item d-flex align-items-center ms-2">
|
||||
<a class="nav-link py-1 px-1 text-primary ui-button-bar-link" href="{% url 'ui.saves.create' %}">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvas-controls" aria-labelledby="offcanvas-controls-label">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvas-controls-label">
|
||||
{% if request.user.is_anonymous %}
|
||||
{% translate 'Guest' %}
|
||||
{% else %}
|
||||
{{ request.user.get_full_name }}
|
||||
{% endif %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body d-flex flex-column px-0 pt-0">
|
||||
<ul class="nav flex-column flex-grow-1">
|
||||
{% if not request.user.is_anonymous %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings' %}">
|
||||
<i class="bi bi-person-gear"></i>
|
||||
{% translate 'Account' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'ui.imports.pocket' %}">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i>
|
||||
{% translate 'Import from Pocket' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-auto">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.logout' %}">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
{% translate 'Log out' %}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.login' %}">
|
||||
<i class="bi bi-box-arrow-in-left"></i>
|
||||
{% translate 'Log in' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% include "ui/ui/partials/uname.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'ui/js/bootstrap.bundle.min.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/htmx.min.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.htmx.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.htmx.confirm.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.Form.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.Messages.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.Modal.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.BrowseSavesView.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.ViewAssociationView.js' %}" type="text/javascript"></script>
|
||||
{% block page_scripts %}{% endblock %}
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
window.HotPocket.run({
|
||||
debug: {% if DEBUG %}true{% else %}false{% endif %},
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Save a link' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<p class="display-6 my-3">{% translate 'Save a link' %}</p>
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% if save.is_youtube_video %}
|
||||
<div class="mb-0 d-flex justify-content-center">
|
||||
<iframe
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
class="ui-youtube-iframe"
|
||||
frameborder="0"
|
||||
height="200"
|
||||
src="{{ save|render_youtube_embed_url }}"
|
||||
title="YouTube video player"
|
||||
width="320"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1 @@
|
||||
<input {% for attribute, value in attributes.items %}{{ attribute }}="{{ value }}"{% endfor %} />
|
||||
@@ -0,0 +1,9 @@
|
||||
<div{% if formactions.attrs %} {{ formactions.flat_attrs }}{% endif %} class="{{ formactions.css_class|default_if_none:'mb-3' }} row">
|
||||
{% if label_class %}
|
||||
<div class="aab {{ label_class }}"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="{{ field_class }}">
|
||||
{{ fields_output|safe }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div{% if formactions.attrs %} {{ formactions.flat_attrs }}{% endif %} class="{{ formactions.css_class|default_if_none:'mb-3' }}">
|
||||
{{ fields_output|safe }}
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<p class="mb-0 mt-2 text-center text-muted ui-uname">
|
||||
<span>
|
||||
<a href="https://hotpocket.app/" target="_blank" rel="noopener noreferer">{{ SITE_TITLE }}</a> v{{ VERSION }}
|
||||
(<code>{{ IMAGE_ID }}</code>)
|
||||
</span>
|
||||
<br>
|
||||
<span>Copyright © 2025-present by BTHLabs. All rights reserved.</span>
|
||||
</p>
|
||||
117
services/backend/hotpocket_backend/apps/ui/templatetags/ui.py
Normal file
117
services/backend/hotpocket_backend/apps/ui/templatetags/ui.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import urllib.parse
|
||||
|
||||
from django import template
|
||||
from django.contrib.messages import Message
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from hotpocket_backend.apps.ui.constants import MessageLevelAlertClass
|
||||
from hotpocket_backend.apps.ui.dto.base import BrowseParams
|
||||
from hotpocket_common.constants import AssociationsSearchMode
|
||||
from hotpocket_soa.dto.saves import SaveOut
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter('render_url_domain')
|
||||
def render_url_domain(value: str | None) -> str:
|
||||
if value is None:
|
||||
return '-'
|
||||
|
||||
parsed_url = urllib.parse.urlsplit(value)
|
||||
return parsed_url.netloc
|
||||
|
||||
|
||||
@register.filter('next_url')
|
||||
def next_url(before: str, params: BrowseParams) -> str:
|
||||
if not before:
|
||||
return '#'
|
||||
|
||||
url = urllib.parse.urlsplit(str(reverse_lazy(params.view_name)))._replace(
|
||||
query=urllib.parse.urlencode([
|
||||
('before', before),
|
||||
('search', params.search or ''),
|
||||
('limit', params.limit),
|
||||
]),
|
||||
)
|
||||
|
||||
return url.geturl()
|
||||
|
||||
|
||||
@register.filter('previous_url')
|
||||
def previous_url(after: str, params: BrowseParams) -> str:
|
||||
if not after:
|
||||
return '#'
|
||||
|
||||
url = urllib.parse.urlsplit(str(reverse_lazy(params.view_name)))._replace(
|
||||
query=urllib.parse.urlencode([
|
||||
('after', after),
|
||||
('search', params.search or ''),
|
||||
('limit', params.limit),
|
||||
]),
|
||||
)
|
||||
|
||||
return url.geturl()
|
||||
|
||||
|
||||
@register.filter('render_youtube_embed_url')
|
||||
def render_youtube_embed_url(save: SaveOut) -> str | None:
|
||||
video_id: str | None = None
|
||||
|
||||
if save.parsed_url.netloc.endswith('youtube.com') is True:
|
||||
if save.parsed_url.path.startswith('/live'):
|
||||
video_id = str(pathlib.PurePosixPath(save.parsed_url.path).name)
|
||||
else:
|
||||
video_id = save.parsed_url_query.get('v', [None])[0]
|
||||
elif save.parsed_url.netloc.endswith('youtu.be') is True:
|
||||
video_id = save.parsed_url.path
|
||||
|
||||
if video_id is None:
|
||||
return None
|
||||
|
||||
result = urllib.parse.urlsplit('https://www.youtube.com/')._replace(
|
||||
path=str(pathlib.PurePosixPath('/embed') / video_id.strip('/')),
|
||||
)
|
||||
|
||||
return result.geturl()
|
||||
|
||||
|
||||
@register.simple_tag(name='browse_associations_url')
|
||||
def browse_associations_url(mode: str | None = None) -> str:
|
||||
mode_param: AssociationsSearchMode | None = None
|
||||
|
||||
if mode == 'STARRED':
|
||||
mode_param = AssociationsSearchMode.STARRED
|
||||
elif mode == 'ARCHIVED':
|
||||
mode_param = AssociationsSearchMode.ARCHIVED
|
||||
|
||||
query = []
|
||||
if mode_param is not None:
|
||||
query.append(('mode', mode_param.value))
|
||||
|
||||
return str(reverse_lazy(
|
||||
'ui.associations.browse',
|
||||
query=query,
|
||||
))
|
||||
|
||||
|
||||
@register.filter(name='alert_class')
|
||||
def alert_class(message: Message | None) -> str:
|
||||
if message is None:
|
||||
return 'alert-secondary'
|
||||
|
||||
try:
|
||||
return MessageLevelAlertClass[message.level_tag].value
|
||||
except KeyError:
|
||||
LOGGER.debug(
|
||||
'Unknown message level tag: message.level_tag=`%s`',
|
||||
message.level_tag,
|
||||
)
|
||||
|
||||
return 'alert-secondary'
|
||||
111
services/backend/hotpocket_backend/apps/ui/urls.py
Normal file
111
services/backend/hotpocket_backend/apps/ui/urls.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from hotpocket_backend.apps.ui.constants import StarUnstarAssociationViewMode
|
||||
|
||||
# isort: off
|
||||
from .views import (
|
||||
accounts,
|
||||
associations,
|
||||
imports,
|
||||
index,
|
||||
integrations,
|
||||
meta,
|
||||
saves,
|
||||
)
|
||||
# isort: on
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
'accounts/login/',
|
||||
accounts.LoginView.as_view(),
|
||||
name='ui.accounts.login',
|
||||
),
|
||||
path(
|
||||
'accounts/post-login/',
|
||||
accounts.PostLoginView.as_view(),
|
||||
name='ui.accounts.post_login',
|
||||
),
|
||||
path('accounts/logout/', accounts.logout, name='ui.accounts.logout'),
|
||||
path('accounts/browse/', accounts.browse, name='ui.accounts.browse'),
|
||||
path('accounts/settings/', accounts.settings, name='ui.accounts.settings'),
|
||||
path(
|
||||
'accounts/settings/profile/',
|
||||
accounts.ProfileView.as_view(),
|
||||
name='ui.accounts.settings.profile',
|
||||
),
|
||||
path(
|
||||
'accounts/settings/password/',
|
||||
accounts.PasswordView.as_view(),
|
||||
name='ui.accounts.settings.password',
|
||||
),
|
||||
path(
|
||||
'accounts/settings/settings/',
|
||||
accounts.SettingsView.as_view(),
|
||||
name='ui.accounts.settings.settings',
|
||||
),
|
||||
path('accounts/', accounts.index, name='ui.accounts.index'),
|
||||
path(
|
||||
'imports/pocket/',
|
||||
imports.PocketImportView.as_view(),
|
||||
name='ui.imports.pocket',
|
||||
),
|
||||
path(
|
||||
'integrations/ios/shortcut/',
|
||||
integrations.ios.shortcut,
|
||||
name='ui.integrations.ios.shortcut',
|
||||
),
|
||||
path(
|
||||
'integrations/android/share-sheet/',
|
||||
integrations.android.share_sheet,
|
||||
name='ui.integrations.android.share_sheet',
|
||||
),
|
||||
path(
|
||||
'saves/create/',
|
||||
saves.CreateView.as_view(),
|
||||
name='ui.saves.create',
|
||||
),
|
||||
path('saves/embed/', saves.embed, name='ui.saves.embed'),
|
||||
path('associations/browse/', associations.browse, name='ui.associations.browse'),
|
||||
path('associations/view/<str:pk>/', associations.view, name='ui.associations.view'),
|
||||
path(
|
||||
'associations/edit/<str:pk>/',
|
||||
associations.EditView.as_view(),
|
||||
name='ui.associations.edit',
|
||||
),
|
||||
path(
|
||||
'associations/star/<str:pk>/',
|
||||
associations.StarUnstarView.as_view(mode=StarUnstarAssociationViewMode.STAR),
|
||||
name='ui.associations.star',
|
||||
),
|
||||
path(
|
||||
'associations/unstar/<str:pk>/',
|
||||
associations.StarUnstarView.as_view(mode=StarUnstarAssociationViewMode.UNSTAR),
|
||||
name='ui.associations.unstar',
|
||||
),
|
||||
path(
|
||||
'associations/post-save/<str:pk>/',
|
||||
associations.post_save,
|
||||
name='ui.associations.post_save',
|
||||
),
|
||||
path(
|
||||
'associations/refresh/<str:pk>/',
|
||||
associations.RefreshView.as_view(),
|
||||
name='ui.associations.refresh',
|
||||
),
|
||||
path(
|
||||
'associations/archive/<str:pk>/',
|
||||
associations.ArchiveView.as_view(),
|
||||
name='ui.associations.archive',
|
||||
),
|
||||
path(
|
||||
'associations/delete/<str:pk>/',
|
||||
associations.DeleteView.as_view(),
|
||||
name='ui.associations.delete',
|
||||
),
|
||||
path('associations/', associations.index, name='ui.associations.index'),
|
||||
path('manifest.json', meta.manifest_json, name='ui.meta.manifest_json'),
|
||||
path('', index.index, name='ui.index.index'),
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
from . import accounts # noqa: F401
|
||||
from . import associations # noqa: F401
|
||||
from . import errors # noqa: F401
|
||||
from . import imports # noqa: F401
|
||||
from . import index # noqa: F401
|
||||
from . import integrations # noqa: F401
|
||||
from . import meta # noqa: F401
|
||||
from . import saves # noqa: F401
|
||||
247
services/backend/hotpocket_backend/apps/ui/views/accounts.py
Normal file
247
services/backend/hotpocket_backend/apps/ui/views/accounts.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.contrib.auth.views import LoginView as BaseLoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
import django.db
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView, RedirectView
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.core.conf import settings as django_settings
|
||||
from hotpocket_backend.apps.ui.forms.accounts import (
|
||||
FederatedPasswordForm,
|
||||
FederatedProfileForm,
|
||||
LoginForm,
|
||||
PasswordForm,
|
||||
ProfileForm,
|
||||
SettingsForm,
|
||||
)
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings'))
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
raise Http404()
|
||||
|
||||
|
||||
class LoginView(BaseLoginView):
|
||||
template_name = 'ui/accounts/login.html'
|
||||
form_class = LoginForm
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
request.session['post_login_next_url'] = request.GET.get('next', None)
|
||||
request.session.save()
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.post_login')
|
||||
|
||||
|
||||
class PostLoginView(RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs) -> str:
|
||||
next_url = self.request.session.pop('post_login_next_url', None)
|
||||
self.request.session.save()
|
||||
|
||||
allowed_hosts = None
|
||||
if len(django_settings.ALLOWED_HOSTS) > 0:
|
||||
allowed_hosts = set(filter(
|
||||
lambda value: value != '*',
|
||||
django_settings.ALLOWED_HOSTS,
|
||||
))
|
||||
|
||||
if next_url is not None:
|
||||
next_url_is_safe = url_has_allowed_host_and_scheme(
|
||||
url=next_url,
|
||||
allowed_hosts=allowed_hosts,
|
||||
require_https=self.request.is_secure(),
|
||||
)
|
||||
|
||||
if next_url_is_safe is False:
|
||||
next_url = None
|
||||
|
||||
return next_url or reverse('ui.index.index')
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
if request.user.is_anonymous is True:
|
||||
raise PermissionDenied('NOPE')
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
def logout(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_authenticated is True:
|
||||
auth_logout(request)
|
||||
|
||||
return render(request, 'ui/accounts/logout.html')
|
||||
|
||||
|
||||
@account_required
|
||||
def settings(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings.profile'))
|
||||
|
||||
|
||||
class BaseSettingsView(AccountRequiredMixin, FormView):
|
||||
template_name = 'ui/accounts/settings.html'
|
||||
|
||||
@property
|
||||
def is_federated(self) -> bool:
|
||||
return all((
|
||||
self.request.user.is_anonymous is False,
|
||||
self.request.user.has_usable_password() is False,
|
||||
))
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'is_federated': self.is_federated,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ProfileView(BaseSettingsView):
|
||||
def get_form_class(self) -> type[ProfileForm]:
|
||||
if self.is_federated is True:
|
||||
return FederatedProfileForm
|
||||
|
||||
return ProfileForm
|
||||
|
||||
def get_initial(self) -> dict:
|
||||
result = super().get_initial()
|
||||
|
||||
result.update({
|
||||
'username': self.request.user.username,
|
||||
'first_name': self.request.user.first_name,
|
||||
'last_name': self.request.user.last_name,
|
||||
'email': self.request.user.email,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'title': _('Profile'),
|
||||
'active_tab': 'profile',
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def form_valid(self, form: ProfileForm) -> HttpResponse:
|
||||
assert self.is_federated is False, (
|
||||
'Refuse to save profile of a federated account: '
|
||||
f'account=`{self.request.user}`'
|
||||
)
|
||||
|
||||
with django.db.transaction.atomic():
|
||||
self.request.user.first_name = form.cleaned_data['first_name']
|
||||
self.request.user.last_name = form.cleaned_data['last_name']
|
||||
self.request.user.email = form.cleaned_data['email']
|
||||
self.request.user.save()
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
message=_('Your profile has been been updated!'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.settings.profile')
|
||||
|
||||
|
||||
class PasswordView(BaseSettingsView):
|
||||
def get_form_class(self) -> type[PasswordForm]:
|
||||
if self.is_federated is True:
|
||||
return FederatedPasswordForm
|
||||
|
||||
return PasswordForm
|
||||
|
||||
def get_form(self,
|
||||
form_class: type[PasswordForm] | None = None,
|
||||
) -> PasswordForm:
|
||||
form_class = form_class or self.get_form_class()
|
||||
return form_class(self.request.user, **self.get_form_kwargs())
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'title': _('Password'),
|
||||
'active_tab': 'password',
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def form_valid(self, form: PasswordForm) -> HttpResponse:
|
||||
assert self.is_federated is False, (
|
||||
'Refuse to change password of a federated account: '
|
||||
f'account=`{self.request.user}`'
|
||||
)
|
||||
|
||||
with django.db.transaction.atomic():
|
||||
form.set_password_and_save(
|
||||
self.request.user,
|
||||
password_field_name='new_password1',
|
||||
)
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
message=_('Your password has been changed!'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.settings.password')
|
||||
|
||||
|
||||
class SettingsView(BaseSettingsView):
|
||||
form_class = SettingsForm
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'title': _('Settings'),
|
||||
'active_tab': 'settings',
|
||||
'is_federated': False,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_initial(self) -> dict:
|
||||
return self.request.user.settings
|
||||
|
||||
def form_valid(self, form: PasswordForm) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
self.request.user.raw_settings = form.cleaned_data
|
||||
self.request.user.save()
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
message=_('Your settings have been updated!'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.settings.settings')
|
||||
395
services/backend/hotpocket_backend/apps/ui/views/associations.py
Normal file
395
services/backend/hotpocket_backend/apps/ui/views/associations.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.contrib import messages
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView, View
|
||||
from django_htmx.http import trigger_client_event
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.htmx import messages as htmx_messages
|
||||
from hotpocket_backend.apps.ui.constants import StarUnstarAssociationViewMode
|
||||
from hotpocket_backend.apps.ui.dto.saves import BrowseParams
|
||||
from hotpocket_backend.apps.ui.forms.associations import (
|
||||
ArchiveForm,
|
||||
DeleteForm,
|
||||
EditForm,
|
||||
RefreshForm,
|
||||
)
|
||||
from hotpocket_backend.apps.ui.services import UIAssociationsService
|
||||
from hotpocket_common.constants import NULL_UUID, AssociationsSearchMode
|
||||
from hotpocket_soa.dto.associations import (
|
||||
AssociationOut,
|
||||
AssociationsQuery,
|
||||
AssociationUpdateIn,
|
||||
AssociationWithTargetOut,
|
||||
)
|
||||
from hotpocket_soa.services import AssociationsService, SaveProcessorService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssociationMixin:
|
||||
ALLOW_ARCHIVED_ASSOCIATION = False
|
||||
|
||||
def get_association(self,
|
||||
with_target: bool = False,
|
||||
) -> AssociationOut | AssociationWithTargetOut:
|
||||
attribute = '_instance'
|
||||
if with_target is True:
|
||||
attribute = '_instance_with_target'
|
||||
|
||||
if hasattr(self, attribute) is False:
|
||||
setattr(
|
||||
self,
|
||||
attribute,
|
||||
UIAssociationsService().get_or_404(
|
||||
account_uuid=self.request.user.pk, # type: ignore[attr-defined]
|
||||
pk=self.kwargs['pk'], # type: ignore[attr-defined]
|
||||
with_target=with_target,
|
||||
allow_archived=self.ALLOW_ARCHIVED_ASSOCIATION,
|
||||
),
|
||||
)
|
||||
|
||||
return getattr(self, attribute)
|
||||
|
||||
|
||||
class DetailView(AssociationMixin, AccountRequiredMixin, FormView):
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'association': self.get_association(),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ConfirmationView(DetailView):
|
||||
def get_initial(self) -> dict:
|
||||
result = super().get_initial()
|
||||
|
||||
association: AssociationWithTargetOut = self.get_association( # type: ignore[assignment]
|
||||
with_target=True,
|
||||
)
|
||||
|
||||
result.update({
|
||||
'canhazconfirm': 'hai',
|
||||
'title': association.title,
|
||||
'url': association.target.url,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.associations.browse')
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.associations.browse'))
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
params = BrowseParams.from_request(request=request)
|
||||
|
||||
associations = AssociationsService().search(
|
||||
query=AssociationsQuery.model_validate(dict(
|
||||
account_uuid=request.user.pk,
|
||||
before=params.before,
|
||||
search=params.search or None,
|
||||
mode=params.mode,
|
||||
)),
|
||||
limit=params.limit,
|
||||
)
|
||||
|
||||
before: uuid.UUID | None = None
|
||||
if len(associations) == params.limit:
|
||||
before = associations[-1].pk
|
||||
|
||||
next_url: str | None = None
|
||||
if before is not None:
|
||||
next_url = reverse('ui.associations.browse', query=[
|
||||
('before', before),
|
||||
('search', params.search or ''),
|
||||
('limit', params.limit),
|
||||
('mode', params.mode.value),
|
||||
])
|
||||
|
||||
context = {
|
||||
'associations': associations,
|
||||
'params': params,
|
||||
'before': before,
|
||||
'next_url': next_url,
|
||||
}
|
||||
|
||||
if request.htmx:
|
||||
response = render(
|
||||
request,
|
||||
'ui/associations/partials/associations.html',
|
||||
context,
|
||||
)
|
||||
|
||||
return trigger_client_event(
|
||||
response,
|
||||
'HotPocket:BrowseSavesView:updateLoadMoreButton',
|
||||
{'next_url': next_url},
|
||||
after='swap',
|
||||
)
|
||||
|
||||
title: str
|
||||
match params.mode:
|
||||
case AssociationsSearchMode.STARRED:
|
||||
title = _('Starred')
|
||||
|
||||
case AssociationsSearchMode.ARCHIVED:
|
||||
title = _('Archived')
|
||||
|
||||
case _:
|
||||
title = 'HotPocket'
|
||||
|
||||
context['title'] = title
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/associations/browse.html',
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
def view(request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
|
||||
account_uuid: uuid.UUID | None = None
|
||||
allow_archived = False
|
||||
|
||||
is_share = request.GET.get('share', None) is not None
|
||||
if request.user.is_anonymous is True or request.user.is_active is False:
|
||||
if is_share is True:
|
||||
account_uuid = None
|
||||
else:
|
||||
account_uuid = NULL_UUID
|
||||
else:
|
||||
if is_share is False:
|
||||
account_uuid = request.user.pk
|
||||
allow_archived = True
|
||||
|
||||
association = UIAssociationsService().get_or_404(
|
||||
account_uuid=account_uuid,
|
||||
pk=pk,
|
||||
with_target=True,
|
||||
allow_archived=allow_archived,
|
||||
)
|
||||
|
||||
show_controls = True
|
||||
if association.archived_at is not None:
|
||||
show_controls = show_controls and False
|
||||
|
||||
if request.user.is_anonymous is True:
|
||||
show_controls = show_controls and False
|
||||
else:
|
||||
if is_share is True:
|
||||
show_controls = show_controls and False
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/associations/view.html',
|
||||
{
|
||||
'association': association,
|
||||
'show_controls': show_controls,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class EditView(DetailView):
|
||||
template_name = 'ui/associations/edit.html'
|
||||
form_class = EditForm
|
||||
|
||||
def get_initial(self) -> dict:
|
||||
result = super().get_initial()
|
||||
|
||||
association = self.get_association(with_target=True)
|
||||
|
||||
result.update({
|
||||
'url': association.target.url, # type: ignore[attr-defined]
|
||||
'target_title': association.get_title(),
|
||||
'target_description': association.get_description(),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def form_valid(self, form: EditForm) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
updated_association = AssociationsService().update( # noqa: F841
|
||||
association=self.get_association(),
|
||||
update=AssociationUpdateIn.model_validate(form.cleaned_data),
|
||||
)
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('The save has been updated!'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
'ui.associations.view',
|
||||
args=(self.get_association(with_target=True).pk,),
|
||||
)
|
||||
|
||||
|
||||
class StarUnstarView(AssociationMixin, AccountRequiredMixin, View):
|
||||
mode = StarUnstarAssociationViewMode.STAR
|
||||
|
||||
def get(self, request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
association = self.get_association()
|
||||
|
||||
match self.mode:
|
||||
case StarUnstarAssociationViewMode.STAR:
|
||||
_ = AssociationsService().star(association=association)
|
||||
|
||||
case StarUnstarAssociationViewMode.UNSTAR:
|
||||
_ = AssociationsService().unstar(association=association)
|
||||
|
||||
case _:
|
||||
raise ValueError(f'Unknown mode: {self.mode.value}')
|
||||
|
||||
if request.htmx:
|
||||
return render(
|
||||
request,
|
||||
'ui/associations/partials/association_card.html',
|
||||
{
|
||||
'association': self.get_association(with_target=True),
|
||||
},
|
||||
)
|
||||
|
||||
return redirect('ui.associations.browse')
|
||||
|
||||
|
||||
@account_required
|
||||
def post_save(request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
|
||||
association = UIAssociationsService().get_or_404(
|
||||
account_uuid=request.user.pk,
|
||||
pk=pk,
|
||||
with_target=True,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/associations/post_save.html',
|
||||
{
|
||||
'association': association,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class RefreshView(ConfirmationView):
|
||||
template_name = 'ui/associations/refresh.html'
|
||||
form_class = RefreshForm
|
||||
|
||||
def form_valid(self, form: RefreshForm) -> HttpResponse:
|
||||
result = SaveProcessorService().schedule_process_save(
|
||||
save=self.get_association(with_target=True).target, # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
if self.request.htmx:
|
||||
response = JsonResponse({
|
||||
'status': 'ok',
|
||||
'result': result.id,
|
||||
})
|
||||
|
||||
htmx_messages.add_htmx_message(
|
||||
request=self.request,
|
||||
response=response,
|
||||
level=htmx_messages.SUCCESS,
|
||||
message=_('The save has been refreshed!'),
|
||||
)
|
||||
|
||||
return response
|
||||
else:
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('The save has been refreshed!'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ArchiveView(ConfirmationView):
|
||||
template_name = 'ui/associations/archive.html'
|
||||
form_class = ArchiveForm
|
||||
|
||||
def form_valid(self, form: ArchiveView) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
result = AssociationsService().archive(
|
||||
association=self.get_association(),
|
||||
)
|
||||
|
||||
if self.request.htmx:
|
||||
return JsonResponse({
|
||||
'status': 'ok',
|
||||
'result': result,
|
||||
})
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('The save has been archived.'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class DeleteView(ConfirmationView):
|
||||
template_name = 'ui/associations/delete.html'
|
||||
form_class = DeleteForm
|
||||
|
||||
ALLOW_ARCHIVED_ASSOCIATION = True
|
||||
|
||||
def form_valid(self, form: DeleteForm) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
result = AssociationsService().delete(
|
||||
association=self.get_association(),
|
||||
)
|
||||
|
||||
if self.request.htmx:
|
||||
response = JsonResponse({
|
||||
'status': 'ok',
|
||||
'result': result,
|
||||
})
|
||||
|
||||
htmx_messages.add_htmx_message(
|
||||
request=self.request,
|
||||
response=response,
|
||||
level=htmx_messages.SUCCESS,
|
||||
message=_('The save has been deleted.'),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('The save has been deleted.'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
'ui.associations.browse',
|
||||
query={
|
||||
'mode': AssociationsSearchMode.ARCHIVED.value,
|
||||
},
|
||||
)
|
||||
86
services/backend/hotpocket_backend/apps/ui/views/errors.py
Normal file
86
services/backend/hotpocket_backend/apps/ui/views/errors.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.http import (
|
||||
HttpRequest,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseForbidden,
|
||||
HttpResponseNotFound,
|
||||
HttpResponseServerError,
|
||||
)
|
||||
from django.template.loader import render_to_string
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import (
|
||||
ERROR_400_TEMPLATE_NAME,
|
||||
ERROR_403_TEMPLATE_NAME,
|
||||
ERROR_404_TEMPLATE_NAME,
|
||||
ERROR_500_TEMPLATE_NAME,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def page_not_found(request: HttpRequest,
|
||||
exception: Exception,
|
||||
template_name: str = ERROR_404_TEMPLATE_NAME,
|
||||
) -> HttpResponseNotFound:
|
||||
if exception:
|
||||
LOGGER.error('Exception: %s', exception, exc_info=exception)
|
||||
|
||||
return HttpResponseNotFound(render_to_string(
|
||||
'ui/errors/page_not_found.html',
|
||||
context={},
|
||||
request=request,
|
||||
))
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def server_error(request: HttpRequest,
|
||||
template_name: str = ERROR_500_TEMPLATE_NAME,
|
||||
) -> HttpResponseServerError:
|
||||
return HttpResponseServerError(render_to_string(
|
||||
'ui/errors/internal_server_error.html',
|
||||
context={},
|
||||
request=request,
|
||||
))
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def bad_request(request: HttpRequest,
|
||||
exception: Exception,
|
||||
template_name: str = ERROR_400_TEMPLATE_NAME,
|
||||
) -> HttpResponseBadRequest:
|
||||
if exception:
|
||||
LOGGER.error('Exception: %s', exception, exc_info=exception)
|
||||
|
||||
return HttpResponseBadRequest(render_to_string(
|
||||
'ui/errors/internal_server_error.html',
|
||||
context={},
|
||||
request=request,
|
||||
))
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def permission_denied(request: HttpRequest,
|
||||
exception: Exception,
|
||||
template_name: str = ERROR_403_TEMPLATE_NAME,
|
||||
) -> HttpResponseForbidden:
|
||||
if exception:
|
||||
LOGGER.error('Exception: %s', exception, exc_info=exception)
|
||||
|
||||
return HttpResponseForbidden(render_to_string(
|
||||
'ui/errors/internal_server_error.html',
|
||||
context={},
|
||||
request=request,
|
||||
))
|
||||
|
||||
|
||||
def csrf_failure(request: HttpRequest, reason: str = ''):
|
||||
return HttpResponseBadRequest(render_to_string(
|
||||
'ui/errors/internal_server_error.html',
|
||||
context={},
|
||||
request=request,
|
||||
))
|
||||
52
services/backend/hotpocket_backend/apps/ui/views/imports.py
Normal file
52
services/backend/hotpocket_backend/apps/ui/views/imports.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.core.conf import settings
|
||||
from hotpocket_backend.apps.ui.forms.imports import PocketImportForm
|
||||
from hotpocket_backend.apps.ui.services import UIImportsService
|
||||
|
||||
|
||||
class PocketImportView(AccountRequiredMixin, FormView):
|
||||
template_name = 'ui/imports/pocket.html'
|
||||
form_class = PocketImportForm
|
||||
|
||||
def form_valid(self, form: PocketImportForm) -> HttpResponse:
|
||||
csv_filename = '{user_pk}-{filename}'.format(
|
||||
user_pk=self.request.user.pk,
|
||||
filename=form.cleaned_data['csv'].name,
|
||||
)
|
||||
|
||||
csv_path = settings.UPLOADS_PATH / csv_filename
|
||||
with open(csv_path, 'wb') as csv_file:
|
||||
for chunk in form.cleaned_data['csv']:
|
||||
csv_file.write(chunk)
|
||||
|
||||
result = UIImportsService().schedule_import_from_pocket(
|
||||
account_uuid=self.request.user.pk,
|
||||
csv_path=str(csv_path),
|
||||
)
|
||||
|
||||
if result.ready() is True:
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('Import complete!'),
|
||||
)
|
||||
else:
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.INFO,
|
||||
_('The import has been scheduled.'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.associations.index')
|
||||
12
services/backend/hotpocket_backend/apps/ui/views/index.py
Normal file
12
services/backend/hotpocket_backend/apps/ui/views/index.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.associations.browse'))
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import android # noqa: F401
|
||||
from . import ios # noqa: F401
|
||||
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import logging
|
||||
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@account_required
|
||||
def share_sheet(request: HttpRequest) -> HttpResponse:
|
||||
LOGGER.debug('POST=`%s`', request.POST)
|
||||
|
||||
try:
|
||||
with django.db.transaction.atomic():
|
||||
assert request.user.is_anonymous is False, 'Login required'
|
||||
assert 'text' in request.POST, 'Bad request: Missing `text`'
|
||||
|
||||
url = request.POST['text'].split('\n')[0].strip()
|
||||
assert url != '', 'Bad request: Empty `text`'
|
||||
|
||||
return CreateSaveWorkflow().run(
|
||||
request=request,
|
||||
account=request.user,
|
||||
url=url,
|
||||
force_post_save=True,
|
||||
)
|
||||
except Exception as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/errors/internal_server_error.html',
|
||||
# Returning 200 here to avoid browsers showing generic error pages.
|
||||
status=http.HTTPStatus.OK,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import logging
|
||||
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@account_required
|
||||
def shortcut(request: HttpRequest) -> HttpResponse:
|
||||
LOGGER.debug('GET=`%s`', request.GET)
|
||||
|
||||
try:
|
||||
with django.db.transaction.atomic():
|
||||
assert request.user.is_anonymous is False, 'Login required'
|
||||
assert 'url' in request.GET, 'Bad request: Missing `url`'
|
||||
|
||||
url = request.GET['url'].split('\n')[0].strip()
|
||||
assert url != '', 'Bad request: Empty `url`'
|
||||
|
||||
return CreateSaveWorkflow().run(
|
||||
request=request,
|
||||
account=request.user,
|
||||
url=url,
|
||||
force_post_save=True,
|
||||
)
|
||||
except Exception as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/errors/internal_server_error.html',
|
||||
# Returning 200 here to avoid browsers showing generic error pages.
|
||||
status=http.HTTPStatus.OK,
|
||||
)
|
||||
44
services/backend/hotpocket_backend/apps/ui/views/meta.py
Normal file
44
services/backend/hotpocket_backend/apps/ui/views/meta.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
|
||||
from hotpocket_backend.apps.core.conf import settings
|
||||
|
||||
|
||||
def manifest_json(request: HttpRequest) -> JsonResponse:
|
||||
result = {
|
||||
'name': settings.SITE_TITLE,
|
||||
'short_name': settings.SITE_SHORT_TITLE,
|
||||
'start_url': reverse('ui.associations.browse'),
|
||||
'display': 'standalone',
|
||||
'background_color': '#212529',
|
||||
'theme_color': '#2b3035',
|
||||
'icons': [
|
||||
{
|
||||
'src': static('ui/img/icon-192.png'),
|
||||
'sizes': '192x192',
|
||||
'type': 'image/png',
|
||||
},
|
||||
{
|
||||
'src': static('ui/img/icon-512.png'),
|
||||
'sizes': '512x512',
|
||||
'type': 'image/png',
|
||||
},
|
||||
],
|
||||
'share_target': {
|
||||
'action': reverse('ui.integrations.android.share_sheet'),
|
||||
'method': 'POST',
|
||||
'enctype': 'multipart/form-data',
|
||||
'params': {
|
||||
'title': 'title',
|
||||
'text': 'text',
|
||||
'url': 'url',
|
||||
'files': [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return JsonResponse(result)
|
||||
78
services/backend/hotpocket_backend/apps/ui/views/saves.py
Normal file
78
services/backend/hotpocket_backend/apps/ui/views/saves.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import logging
|
||||
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import FormView
|
||||
from django_htmx.http import trigger_client_event
|
||||
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.ui.forms.saves import CreateForm
|
||||
from hotpocket_backend.apps.ui.services import UISavesService
|
||||
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateView(AccountRequiredMixin, FormView):
|
||||
template_name = 'ui/saves/create.html'
|
||||
form_class = CreateForm
|
||||
|
||||
def form_valid(self, form: CreateForm) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
return CreateSaveWorkflow().run(
|
||||
request=self.request,
|
||||
account=self.request.user,
|
||||
url=form.cleaned_data['url'],
|
||||
)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.associations.browse')
|
||||
|
||||
|
||||
def embed(request: HttpRequest) -> HttpResponse:
|
||||
try:
|
||||
assert request.method == http.HTTPMethod.POST, (
|
||||
f'405 Method Not Allowed: `{request.method}`'
|
||||
)
|
||||
assert 'pk' in request.POST, (
|
||||
'400 Bad Request: `pk` missing'
|
||||
)
|
||||
assert request.htmx, (
|
||||
'400 Bad Reqquest: Not an HTMX request'
|
||||
)
|
||||
except AssertionError as exception:
|
||||
LOGGER.error(
|
||||
'Unable to render save embed: pk=`pk`: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
return HttpResponse(
|
||||
b'NOPE',
|
||||
status=http.HTTPStatus.BAD_REQUEST,
|
||||
content_type='text/plain;charset=utf-8',
|
||||
)
|
||||
|
||||
save = UISavesService().get_or_404(pk=request.POST['pk'])
|
||||
|
||||
response = render(
|
||||
request,
|
||||
'ui/saves/partials/embed.html',
|
||||
{
|
||||
'save': save,
|
||||
},
|
||||
)
|
||||
|
||||
trigger_client_event(
|
||||
response,
|
||||
'HotPocket:ViewAssociationView:loadEmbed',
|
||||
{},
|
||||
after='swap',
|
||||
)
|
||||
|
||||
return response
|
||||
Reference in New Issue
Block a user