Release v1.0.0
Some checks failed
CI / Checks (push) Failing after 13m2s

This commit is contained in:
2025-08-20 21:00:50 +02:00
commit b4338e2769
401 changed files with 23576 additions and 0 deletions

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

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

View File

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

View 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(),
})

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

View 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

View File

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

View 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(),
),
)

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

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

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

View File

@@ -0,0 +1,3 @@
from .associations import UIAssociationsService # noqa: F401
from .imports import UIImportsService # noqa: F401
from .saves import UISavesService # noqa: F401

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
from .saves import CreateSaveWorkflow, ImportSaveWorkflow # noqa: F401

View File

@@ -0,0 +1,2 @@
from .create import CreateSaveWorkflow # noqa: F401
from .import_ import ImportSaveWorkflow # noqa: F401

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<input {% for attribute, value in attributes.items %}{{ attribute }}="{{ value }}"{% endfor %} />

View File

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

View File

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

View File

@@ -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 &copy; 2025-present by BTHLabs. All rights reserved.</span>
</p>

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

View 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'),
]

View File

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

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

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

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

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

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

View File

@@ -0,0 +1,2 @@
from . import android # noqa: F401
from . import ios # noqa: F401

View File

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

View File

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

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

View 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