diff --git a/dev b/dev index a9a47aaa8b..c7e25df319 100755 --- a/dev +++ b/dev @@ -103,7 +103,7 @@ intro() { echo " ${BOLD}run${NORMAL} runs \"docker compose run --rm misago\"." echo " ${BOLD}psql${NORMAL} runs psql connected to development database." echo " ${BOLD}pyfmt${NORMAL} runs isort + black on python code." - echo " ${BOLD}loaddevfixture${NORMAL} populates database with pre-made data for dev." + echo " ${BOLD}loaddevfixture${NORMAL} populates database with pre-made data for dev." echo " ${BOLD}fakedata${NORMAL} populates database with generated testing data." echo " ${BOLD}fakebigdata${NORMAL} populates database with LARGE amount of geerated testing data." echo " ${BOLD}pipcompile${NORMAL} run pip-compile to update requirements.txt" diff --git a/dev-docs/plugins/hooks/can-upload-private-threads-attachments-hook.md b/dev-docs/plugins/hooks/can-upload-private-threads-attachments-hook.md new file mode 100644 index 0000000000..c9050091f7 --- /dev/null +++ b/dev-docs/plugins/hooks/can-upload-private-threads-attachments-hook.md @@ -0,0 +1,86 @@ +# `can_upload_private_threads_attachments_hook` + +This hook wraps the standard Misago function that checks whether a user has permission to upload attachments in private threads. + + +## Location + +This hook can be imported from `misago.permissions.hooks`: + +```python +from misago.permissions.hooks import can_upload_private_threads_attachments_hook +``` + + +## Filter + +```python +def custom_can_upload_private_threads_attachments_filter( + action: CanUploadPrivateThreadsAttachmentsHookAction, + permissions: 'UserPermissionsProxy', +) -> bool: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CanUploadPrivateThreadsAttachmentsHookAction` + +A standard Misago function that checks whether a user has permission to upload attachments in private threads. + +See the [action](#action) section for details. + + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +### Return value + +`True` if a user can upload attachments in a category, and `False` if they cannot. + + +## Action + +```python +def can_upload_private_threads_attachments_action(permissions: 'UserPermissionsProxy') -> bool: + ... +``` + +A standard Misago function that checks whether a user has permission to upload attachments in private threads. + + +### Arguments + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +### Return value + +`True` if a user can upload attachments in a category, and `False` if they cannot. + + +## Example + +The code below implements a custom filter function that prevents a user from uploading attachments in private threads if a custom flag is set on their account. + +```python +from misago.permissions.hooks import can_upload_threads_attachments_hook +from misago.permissions.proxy import UserPermissionsProxy + +@can_upload_private_threads_attachments_hook.append_filter +def user_can_upload_attachments_in_category( + action, + permissions: UserPermissionsProxy, +) -> bool: + if permissions.user.plugin_data.get("banned_private_threads_attachments"): + return False + + result action(permissions) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/can-upload-threads-attachments-hook.md b/dev-docs/plugins/hooks/can-upload-threads-attachments-hook.md new file mode 100644 index 0000000000..78ff9a125f --- /dev/null +++ b/dev-docs/plugins/hooks/can-upload-threads-attachments-hook.md @@ -0,0 +1,101 @@ +# `can_upload_threads_attachments_hook` + +This hook wraps the standard Misago function that checks whether a user has permission to upload attachments in a category. + + +## Location + +This hook can be imported from `misago.permissions.hooks`: + +```python +from misago.permissions.hooks import can_upload_threads_attachments_hook +``` + + +## Filter + +```python +def custom_can_upload_threads_attachments_filter( + action: CanUploadThreadsAttachmentsHookAction, + permissions: 'UserPermissionsProxy', + category: Category, +) -> bool: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CanUploadThreadsAttachmentsHookAction` + +A standard Misago function that checks if a user has permission to upload attachments in a category. + +See the [action](#action) section for details. + + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category` + +A category to check permissions for. + + +### Return value + +`True` if a user can upload attachments in a category, and `False` if they cannot. + + +## Action + +```python +def can_upload_threads_attachments_action( + permissions: 'UserPermissionsProxy', category: Category +) -> bool: + ... +``` + +A standard Misago function that checks if a user has permission to upload attachments in a category. + + +### Arguments + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category` + +A category to check permissions for. + + +### Return value + +`True` if a user can upload attachments in a category, and `False` if they cannot. + + +## Example + +The code below implements a custom filter function that prevents a user from uploading attachments in a specific category if a custom flag is set on their account. + +```python +from misago.categories.models import Category +from misago.permissions.hooks import can_upload_threads_attachments_hook +from misago.permissions.proxy import UserPermissionsProxy + +@can_upload_threads_attachments_hook.append_filter +def user_can_upload_attachments_in_category( + action, + permissions: UserPermissionsProxy, + category: Category, +) -> bool: + if category.id in permissions.user.plugin_data.get("banned_attachments", []): + return False + + result action(permissions, category) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/check-download-attachment-permission-hook.md b/dev-docs/plugins/hooks/check-download-attachment-permission-hook.md new file mode 100644 index 0000000000..7686b438ad --- /dev/null +++ b/dev-docs/plugins/hooks/check-download-attachment-permission-hook.md @@ -0,0 +1,138 @@ +# `check_download_attachment_permission_hook` + +This hook wraps the standard function that Misago uses to check if the user has permission to download an attachment. It raises Django's `Http404` if the user cannot see the attachment or `PermissionDenied` if they are not allowed to download it. + + +## Location + +This hook can be imported from `misago.permissions.hooks`: + +```python +from misago.permissions.hooks import check_download_attachment_permission_hook +``` + + +## Filter + +```python +def custom_check_download_attachment_permission_filter( + action: CheckDownloadAttachmentPermissionHookAction, + permissions: 'UserPermissionsProxy', + category: Category | None, + thread: Thread | None, + post: Post | None, + attachment: Attachment, +) -> None: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CheckDownloadAttachmentPermissionHookAction` + +A standard Misago function used to check if a user has permission to download an attachment. It raises Django's `Http404` if the user cannot see the attachment or `PermissionDenied` if they are not allowed to download it. + +See the [action](#action) section for details. + + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category | None` + +A category to check permissions for, or `None` if the attachment wasn't posted. + + +#### `thread: Thread | None` + +A thread to check permissions for, or `None` if the attachment wasn't posted. + + +#### `post: Post | None` + +A post to check permissions for, or `None` if the attachment wasn't posted. + + +#### `attachment: Attachment` + +An attachment to check permissions for. + + +## Action + +```python +def check_download_attachment_permission_action( + permissions: 'UserPermissionsProxy', + category: Category | None, + thread: Thread | None, + post: Post | None, + attachment: Attachment, +) -> None: + ... +``` + +A standard Misago function used to check if a user has permission to download an attachment. It raises Django's `Http404` if the user cannot see the attachment or `PermissionDenied` if they are not allowed to download it. + + +### Arguments + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category | None` + +A category to check permissions for, or `None` if the attachment wasn't posted. + + +#### `thread: Thread | None` + +A thread to check permissions for, or `None` if the attachment wasn't posted. + + +#### `post: Post | None` + +A post to check permissions for, or `None` if the attachment wasn't posted. + + +#### `attachment: Attachment` + +An attachment to check permissions for. + + +## Example + +The code below implements a custom filter function that prevents a user from downloading an attachment if its flagged by plugin. + +```python +from django.http import Http404 +from misago.attachments.models import Attachment +from misago.categories.models import Category +from misago.permissions.hooks import check_download_attachment_permission_hook +from misago.permissions.proxy import UserPermissionsProxy +from misago.threads.models import Post, Thread + +@check_download_attachment_permission_hook.append_filter +def check_user_can_download_attachment( + action, + permissions: UserPermissionsProxy, + category: Category | None, + thread: Thread | None, + post: Post | None, + attachment: Attachment, +) -> None: + action(permissions, category, thread, post, attachment) + + if not ( + attachment.plugin_data.get("hidden") + and permissions.user.is_authenticated + and permissions.user.is_misago_admin + ): + raise Http404() +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/check-edit-private-thread-post-permission-hook.md b/dev-docs/plugins/hooks/check-edit-private-thread-post-permission-hook.md index 0bd4195cce..ee9b265cb1 100644 --- a/dev-docs/plugins/hooks/check-edit-private-thread-post-permission-hook.md +++ b/dev-docs/plugins/hooks/check-edit-private-thread-post-permission-hook.md @@ -98,7 +98,7 @@ def check_user_can_edit_thread( thread: Thread, post: Post, ) -> None: - action(permissions, category, post, thread) + action(permissions, thread, post) if ( "[PROTECT]" in post.original diff --git a/dev-docs/plugins/hooks/check-edit-post-permission-hook.md b/dev-docs/plugins/hooks/check-edit-thread-post-permission-hook.md similarity index 69% rename from dev-docs/plugins/hooks/check-edit-post-permission-hook.md rename to dev-docs/plugins/hooks/check-edit-thread-post-permission-hook.md index 17eca5d4ba..68c3033ffa 100644 --- a/dev-docs/plugins/hooks/check-edit-post-permission-hook.md +++ b/dev-docs/plugins/hooks/check-edit-thread-post-permission-hook.md @@ -1,6 +1,6 @@ -# `check_edit_post_permission_hook` +# `check_edit_thread_post_permission_hook` -This hook wraps the standard function that Misago uses to check if the user has permission to edit a post. It raises Django's `PermissionDenied` with an error message if they can't. +This hook wraps the standard function that Misago uses to check if the user has permission to edit a post in a thread. It raises Django's `PermissionDenied` with an error message if they can't. ## Location @@ -8,15 +8,15 @@ This hook wraps the standard function that Misago uses to check if the user has This hook can be imported from `misago.permissions.hooks`: ```python -from misago.permissions.hooks import check_edit_post_permission_hook +from misago.permissions.hooks import check_edit_thread_post_permission_hook ``` ## Filter ```python -def custom_check_edit_post_permission_filter( - action: CheckEditPostPermissionHookAction, +def custom_check_edit_thread_post_permission_filter( + action: CheckEditThreadPostPermissionHookAction, permissions: 'UserPermissionsProxy', category: Category, thread: Thread, @@ -30,9 +30,9 @@ A function implemented by a plugin that can be registered in this hook. ### Arguments -#### `action: CheckEditPostPermissionHookAction` +#### `action: CheckEditThreadPostPermissionHookAction` -A standard Misago function used to check if the user has permission to edit a post. It raises Django's `PermissionDenied` with an error message if they can't. +A standard Misago function used to check if the user has permission to edit a post in a thread. It raises Django's `PermissionDenied` with an error message if they can't. See the [action](#action) section for details. @@ -60,7 +60,7 @@ A post to check permissions for. ## Action ```python -def check_edit_post_permission_action( +def check_edit_thread_post_permission_action( permissions: 'UserPermissionsProxy', category: Category, thread: Thread, @@ -69,7 +69,7 @@ def check_edit_post_permission_action( ... ``` -A standard Misago function used to check if the user has permission to edit a post. It raises Django's `PermissionDenied` with an error message if they can't. +A standard Misago function used to check if the user has permission to edit a post in a thread. It raises Django's `PermissionDenied` with an error message if they can't. ### Arguments @@ -101,19 +101,19 @@ The code below implements a custom filter function that prevents a user from edi ```python from django.core.exceptions import PermissionDenied from misago.categories.models import Category -from misago.permissions.hooks import check_edit_post_permission_hook +from misago.permissions.hooks import check_edit_thread_post_permission_hook from misago.permissions.proxy import UserPermissionsProxy from misago.threads.models import Post, Thread -@check_edit_post_permission_hook.append_filter -def check_user_can_edit_thread( +@check_edit_thread_post_permission_hook.append_filter +def check_user_can_edit_thread_post( action, permissions: UserPermissionsProxy, category: Category, thread: Thread, post: Post, ) -> None: - action(permissions, category, post, thread) + action(permissions, category, thread, post) if ( "[PROTECT]" in post.original diff --git a/dev-docs/plugins/hooks/check-see-post-permission-hook.md b/dev-docs/plugins/hooks/check-see-post-permission-hook.md new file mode 100644 index 0000000000..0fe452325f --- /dev/null +++ b/dev-docs/plugins/hooks/check-see-post-permission-hook.md @@ -0,0 +1,127 @@ +# `check_see_post_permission_hook` + +This hook wraps the standard Misago function used to check if the user has a permission to see a post. Raises Django's `Http404` if they can't see it or `PermissionDenied` with an error message if they can't see it's contents. + + +## Location + +This hook can be imported from `misago.permissions.hooks`: + +```python +from misago.permissions.hooks import check_see_post_permission_hook +``` + + +## Filter + +```python +def custom_check_see_post_permission_filter( + action: CheckSeePostPermissionHookAction, + permissions: 'UserPermissionsProxy', + category: Category, + thread: Thread, + post: Post, +) -> None: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CheckSeePostPermissionHookAction` + +A standard Misago function used to check if the user has a permission to see a post. Raises Django's `Http404` if they can't see it or `PermissionDenied` with an error message if they can't see it's contents. + +See the [action](#action) section for details. + + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category` + +A category to check permissions for. + + +#### `thread: Thread` + +A thread to check permissions for. + + +#### `post: Post` + +A post to check permissions for. + + +## Action + +```python +def check_see_post_permission_action( + permissions: 'UserPermissionsProxy', + category: Category, + thread: Thread, + post: Post, +) -> None: + ... +``` + +A standard Misago function used to check if the user has a permission to see a post. Raises Django's `Http404` if they can't see it or `PermissionDenied` with an error message if they can't see it's contents. + + +### Arguments + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category` + +A category to check permissions for. + + +#### `thread: Thread` + +A thread to check permissions for. + + +#### `post: Post` + +A post to check permissions for. + + +## Example + +The code below implements a custom filter function that blocks a user from seeing a specified thread's post if there is a custom flag set on their account. + +```python +from django.core.exceptions import PermissionDenied +from django.utils.translation import pgettext +from misago.categories.models import Category +from misago.permissions.hooks import check_see_post_permission_hook +from misago.permissions.proxy import UserPermissionsProxy +from misago.threads.models import Post, Thread + +@check_see_post_permission_hook.append_filter +def check_user_can_see_thread_post( + action, + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + post: Post, +) -> None: + # Run standard permission checks + action(permissions, category, thread, post) + + if thread.id in permissions.user.plugin_data.get("hidden_post", []): + raise PermissionDenied( + pgettext( + "post permission error", + "Site admin has removed your access to this post." + ) + ) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/check-see-private-thread-permission-hook.md b/dev-docs/plugins/hooks/check-see-private-thread-permission-hook.md index c16897a44c..b34fa09788 100644 --- a/dev-docs/plugins/hooks/check-see-private-thread-permission-hook.md +++ b/dev-docs/plugins/hooks/check-see-private-thread-permission-hook.md @@ -87,7 +87,7 @@ def check_user_can_see_thread( thread: Thread, ) -> None: # Run standard permission checks - action(permissions, category) + action(permissions, category, thread) if thread.id in permissions.user.plugin_data.get("banned_thread", []): raise PermissionDenied( diff --git a/dev-docs/plugins/hooks/check-see-private-thread-post-permission-hook.md b/dev-docs/plugins/hooks/check-see-private-thread-post-permission-hook.md new file mode 100644 index 0000000000..d835b03459 --- /dev/null +++ b/dev-docs/plugins/hooks/check-see-private-thread-post-permission-hook.md @@ -0,0 +1,111 @@ +# `check_see_private_thread_post_permission_hook` + +This hook wraps the standard Misago function used to check if the user has a permission to see a private thread post. Raises Django's `Http404` if they can't. + + +## Location + +This hook can be imported from `misago.permissions.hooks`: + +```python +from misago.permissions.hooks import check_see_private_thread_post_permission_hook +``` + + +## Filter + +```python +def custom_check_see_private_thread_post_permission_filter( + action: CheckSeePrivateThreadPostPermissionHookAction, + permissions: 'UserPermissionsProxy', + thread: Thread, + post: Post, +) -> None: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CheckSeePrivateThreadPostPermissionHookAction` + +A standard Misago function used to check if the user has a permission to see a private thread. Raises Django's `Http404` if they can't. + +See the [action](#action) section for details. + + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `thread: Thread` + +A thread to check permissions for. + + +#### `post: Post` + +A post to check permissions for. + + +## Action + +```python +def check_see_private_thread_post_permission_action( + permissions: 'UserPermissionsProxy', thread: Thread, post: Post +) -> None: + ... +``` + +A standard Misago function used to check if the user has a permission to see a private thread post. Raises Django's `Http404` if they can't. + + +### Arguments + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `thread: Thread` + +A thread to check permissions for. + + +#### `post: Post` + +A post to check permissions for. + + +## Example + +The code below implements a custom filter function that blocks a user from seeing a specified post if there is a custom flag set on their account. + +```python +from django.core.exceptions import PermissionDenied +from django.utils.translation import pgettext +from misago.permissions.hooks import check_see_private_thread_post_permission_hook +from misago.permissions.proxy import UserPermissionsProxy +from misago.threads.models import Post, Thread + +@check_see_private_thread_post_permission_hook.append_filter +def check_user_can_see_thread( + action, + permissions: UserPermissionsProxy, + thread: Thread, + post: Post, +) -> None: + # Run standard permission checks + action(permissions, category, thread, post) + + if post.id in permissions.user.plugin_data.get("hidden_post", []): + raise PermissionDenied( + pgettext( + "post permission error", + "Site admin has removed your access to this post." + ) + ) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/check-see-thread-permission-hook.md b/dev-docs/plugins/hooks/check-see-thread-permission-hook.md index 29cd467890..1f7bbce78e 100644 --- a/dev-docs/plugins/hooks/check-see-thread-permission-hook.md +++ b/dev-docs/plugins/hooks/check-see-thread-permission-hook.md @@ -102,7 +102,7 @@ def check_user_can_see_thread( thread: Thread, ) -> None: # Run standard permission checks - action(permissions, category) + action(permissions, category, thread) if thread.id in permissions.user.plugin_data.get("banned_thread", []): raise PermissionDenied( diff --git a/dev-docs/plugins/hooks/check-see-thread-post-permission-hook.md b/dev-docs/plugins/hooks/check-see-thread-post-permission-hook.md new file mode 100644 index 0000000000..0431403968 --- /dev/null +++ b/dev-docs/plugins/hooks/check-see-thread-post-permission-hook.md @@ -0,0 +1,127 @@ +# `check_see_thread_post_permission_hook` + +This hook wraps the standard Misago function used to check if the user has a permission to see a post in a thread. Raises Django's `Http404` if they can't see it or `PermissionDenied` with an error message if they can't see it's contents. + + +## Location + +This hook can be imported from `misago.permissions.hooks`: + +```python +from misago.permissions.hooks import check_see_thread_post_permission_hook +``` + + +## Filter + +```python +def custom_check_see_thread_post_permission_filter( + action: CheckSeeThreadPostPermissionHookAction, + permissions: 'UserPermissionsProxy', + category: Category, + thread: Thread, + post: Post, +) -> None: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CheckSeeThreadPostPermissionHookAction` + +A standard Misago function used to check if the user has a permission to see a post in a thread. Raises Django's `Http404` if they can't see it or `PermissionDenied` with an error message if they can't see it's contents. + +See the [action](#action) section for details. + + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category` + +A category to check permissions for. + + +#### `thread: Thread` + +A thread to check permissions for. + + +#### `post: Post` + +A post to check permissions for. + + +## Action + +```python +def check_see_thread_post_permission_action( + permissions: 'UserPermissionsProxy', + category: Category, + thread: Thread, + post: Post, +) -> None: + ... +``` + +A standard Misago function used to check if the user has a permission to see a post in a thread. Raises Django's `Http404` if they can't see it or `PermissionDenied` with an error message if they can't see it's contents. + + +### Arguments + +#### `user_permissions: UserPermissionsProxy` + +A proxy object with the current user's permissions. + + +#### `category: Category` + +A category to check permissions for. + + +#### `thread: Thread` + +A thread to check permissions for. + + +#### `post: Post` + +A post to check permissions for. + + +## Example + +The code below implements a custom filter function that blocks a user from seeing a specified post if there is a custom flag set on their account. + +```python +from django.core.exceptions import PermissionDenied +from django.utils.translation import pgettext +from misago.categories.models import Category +from misago.permissions.hooks import check_see_thread_post_permission_hook +from misago.permissions.proxy import UserPermissionsProxy +from misago.threads.models import Post, Thread + +@check_see_thread_post_permission_hook.append_filter +def check_user_can_see_thread_post( + action, + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + post: Post, +) -> None: + # Run standard permission checks + action(permissions, category, thread, post) + + if post.id in permissions.user.plugin_data.get("hidden_post", []): + raise PermissionDenied( + pgettext( + "post permission error", + "Site admin has removed your access to this post." + ) + ) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/complete-markup-html-hook.md b/dev-docs/plugins/hooks/complete-markup-html-hook.md deleted file mode 100644 index 7727c8dbc3..0000000000 --- a/dev-docs/plugins/hooks/complete-markup-html-hook.md +++ /dev/null @@ -1,104 +0,0 @@ -# `complete_markup_html_hook` - -This hook wraps the standard function that Misago uses to complete an HTML representation of parsed markup. - -Completion process includes: - -- Replacing of placeholder spoiler blocks summaries with messages in active language. - Replacing quotation blocks headers with final HTML. - - -## Location - -This hook can be imported from `misago.parser.hooks`: - -```python -from misago.parser.hooks import complete_markup_html_hook -``` - - -## Filter - -```python -def custom_complete_markup_html_filter( - action: CompleteMarkupHtmlHookAction, html: str, **kwargs -) -> str: - ... -``` - -A function implemented by a plugin that can be registered in this hook. - - -### Arguments - -#### `action: CompleteMarkupHtmlHookAction` - -A standard Misago function used to complete an HTML representation of parsed markup or the next filter function from another plugin. - -See the [action](#action) section for details. - - -#### `html: str` - -An HTML representation of parsed markup to complete. - - -#### `**kwargs` - -Additional data that can be used to complete the HTML. - - -### Return value - -A `str` with completed HTML representation of parsed markup. - - -## Action - -```python -def complete_markup_html_action(html: str, **kwargs) -> str: - ... -``` - -A standard Misago function used to complete an HTML representation of parsed markup or the next filter function from another plugin. - - -### Arguments - -#### `html: str` - -An HTML representation of parsed markup to complete. - - -#### `**kwargs` - -Additional data that can be used to complete the HTML. - - -### Return value - -A `str` with completed HTML representation of parsed markup. - - -## Example - -The code below implements a custom filter function that replaces default spoiler block summary with a custom message: - -```python -from misago.parser.context import ParserContext -from misago.parser.html import SPOILER_SUMMARY - - -@complete_markup_html_hook.append_filter -def complete_markup_html_with_custom_spoiler-summary( - action: CompleteMarkupHtmlHookAction, - html: str, - **kwargs, -) -> str: - if SPOILER_SUMMARY in html: - html = html.replace( - SPOILER_SUMMARY, "SPOILER! Click at your own discretion!" - ) - - # Call the next function in chain - return action(context, html, **kwargs) -``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/create-prefetch-posts-related-objects-hook.md b/dev-docs/plugins/hooks/create-prefetch-posts-related-objects-hook.md new file mode 100644 index 0000000000..87ad41e438 --- /dev/null +++ b/dev-docs/plugins/hooks/create-prefetch-posts-related-objects-hook.md @@ -0,0 +1,211 @@ +# `create_prefetch_posts_related_objects_hook` + +This hook wraps the standard function Misago uses to create a `PrefetchPostsRelatedObjects` object, which prefetches related objects for posts displayed on a thread replies page. + +`PrefetchPostsRelatedObjects` is initialized with settings, user permissions, a list of posts for which related objects need to be prefetched, and lists of other already available objects. + +The object itself does not implement prefetch logic but instead maintains a list of prefetch operations to be executed to fetch the objects required for displaying posts on a thread's page. + +Additional prefetch operations can be added using the add_operation method. + + +## Location + +This hook can be imported from `misago.threads.hooks`: + +```python +from misago.threads.hooks import create_prefetch_posts_related_objects_hook +``` + + +## Filter + +```python +def custom_create_prefetch_posts_related_objects_filter( + action: CreatePrefetchPostsRelatedObjectsHookAction, + settings: DynamicSettings, + permissions: UserPermissionsProxy, + posts: Iterable[Post], + *, + categories: Iterable[Category] | None=None, + threads: Iterable[Thread] | None=None, + attachments: Iterable[Attachment] | None=None, + users: Iterable['User'] | None=None, +) -> 'PrefetchPostsRelatedObjects': + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: CreatePrefetchPostsRelatedObjectsHookAction` + +A standard Misago function used to create a `PrefetchPostsRelatedObjects` object, which is used to prefetch related objects for posts displayed on a thread replies page. + +See the [action](#action) section for details. + + +#### `settings: DynamicSettings` + +The `DynamicSettings` object. + + +#### `permissions: UserPermissionsProxy` + +The `UserPermissionsProxy` object for current user. + + +#### `posts: Iterable[Post]` + +Iterable of `Post` instances to prefetch related objects for. + + +#### `categories: Iterable[Category] | None = None` + +Iterable of categories that were already loaded. Defaults to `None` if not provided. + + +#### `threads: Iterable[Thread] | None = None` + +Iterable of threads that were already loaded. Defaults to `None` if not provided. + + +#### `attachments: Iterable[Attachment] | None = None` + +Iterable of attachments that were already loaded. Defaults to `None` if not provided. + + +#### `users: Iterable["User"] | None = None` + +Iterable of users that were already loaded. Defaults to `None` if not provided. + + +### Return value + +A `PrefetchPostsRelatedObjects` object to use to fetch related objects. + + +## Action + +```python +def create_prefetch_posts_related_objects_action( + settings: DynamicSettings, + permissions: UserPermissionsProxy, + posts: Iterable[Post], + *, + categories: Iterable[Category] | None=None, + threads: Iterable[Thread] | None=None, + attachments: Iterable[Attachment] | None=None, + users: Iterable['User'] | None=None, +) -> 'PrefetchPostsRelatedObjects': + ... +``` + +A standard Misago function used to create a `PrefetchPostsRelatedObjects` object, which is used to prefetch related objects for posts displayed on a thread replies page. + + +### Arguments + +#### `settings: DynamicSettings` + +The `DynamicSettings` object. + + +#### `permissions: UserPermissionsProxy` + +The `UserPermissionsProxy` object for current user. + + +#### `posts: Iterable[Post]` + +Iterable of `Post` instances to prefetch related objects for. + + +#### `categories: Iterable[Category] | None = None` + +Iterable of categories that were already loaded. Defaults to `None` if not provided. + + +#### `threads: Iterable[Thread] | None = None` + +Iterable of threads that were already loaded. Defaults to `None` if not provided. + + +#### `attachments: Iterable[Attachment] | None = None` + +Iterable of attachments that were already loaded. Defaults to `None` if not provided. + + +#### `users: Iterable["User"] | None = None` + +Iterable of users that were already loaded. Defaults to `None` if not provided. + + +### Return value + +A `PrefetchPostsRelatedObjects` object to use to fetch related objects. + + +## Example + +The code below implements a custom filter function that includes a new prefetch operation: + +```python +from typing import Iterable + +from misago.attachments.models import Attachment +from misago.categories.models import Category +from misago.conf.dynamicsettings import DynamicSettings +from misago.permissions.proxy import UserPermissionsProxy +from misago.plugins.hooks import FilterHook +from misago.threads.models import Post, Thread +from misago.threads.prefetch import PrefetchPostsRelatedObjects +from misago.users.models import User + +from .plugin.models import PluginModel + + +def fetch_posts_plugin_data( + data: dict, + settings: DynamicSettings, + permissions: UserPermissionsProxy, +): + data["plugin_models"] = {} + ids_to_fetch: set[int] = set() + + for post in data["posts"].values(): + ids_to_fetch.add(post.plugin_data["plugin_object_id"]) + + if ids_to_fetch: + queryset = PluginModel.objects.filter(id__in=ids_to_fetch) + data["plugin_models"] = {u.id: u for u in queryset} + + +@create_prefetch_posts_related_objects_hook.append_filter +def include_custom_filter( + action: CreatePrefetchPostsRelatedObjectsHookAction, + settings: DynamicSettings, + permissions: UserPermissionsProxy, + posts: Iterable[Post], + *, + categories: Iterable[Category] | None = None, + threads: Iterable[Thread] | None = None, + attachments: Iterable[Attachment] | None = None, + users: Iterable["User"] | None = None, +) -> PrefetchPostsRelatedObjects: + prefetch = action( + settings, + permissions, + posts, + categories=categories, + threads=threads, + attachments=attachments, + users=users, + ) + + prefetch.add_operation(fetch_posts_plugin_data) + + return prefetch +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/delete-attachments-hook.md b/dev-docs/plugins/hooks/delete-attachments-hook.md new file mode 100644 index 0000000000..12b4715ff5 --- /dev/null +++ b/dev-docs/plugins/hooks/delete-attachments-hook.md @@ -0,0 +1,121 @@ +# `delete_attachments_hook` + +This hook wraps the standard function that Misago uses to delete specified attachments. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import delete_attachments_hook +``` + + +## Filter + +```python +def custom_delete_attachments_filter( + action: DeleteAttachmentsHookAction, + attachments: Iterable[Union[Attachment, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: DeleteAttachmentsHookAction` + +A standard function used by Misago to delete specified attachments. + +See the [action](#action) section for details. + + +#### `attachments: Iterable[Union[Attachment, int]]` + +An iterable of attachments or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Action + +```python +def delete_attachments_action( + attachments: Iterable[Union[Attachment, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A standard function used by Misago to delete specified attachments. + + +### Arguments + +#### `attachments: Iterable[Union[Attachment, int]]` + +An iterable of attachments or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Example + +The code below implements a custom filter function that logs delete. + +```python +import logging +from typing import Iterable, Protocol, Union + +from django.http import HttpRequest +from misago.attachments.hooks import delete_attachments_hook +from misago.attachments.models import Attachment + +logger = logging.getLogger("attachments.delete") + + +@delete_attachments_hook.append_filter +def log_delete_attachments( + action, + attachments: Iterable[Union[Attachment, int]], + *, + request: HttpRequest | None = None, +) -> int: + deleted = action(attachments, request=request) + + if request and request.user.is_authenticated: + user = f"#{request.user.id}: {request.user.username}" + else: + user = None + + logger.info( + "Deleted attachments: %s", + str(deleted), + extra={"user": user}, + ) + + return deleted +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/delete-categories-attachments-hook.md b/dev-docs/plugins/hooks/delete-categories-attachments-hook.md new file mode 100644 index 0000000000..e08266670b --- /dev/null +++ b/dev-docs/plugins/hooks/delete-categories-attachments-hook.md @@ -0,0 +1,121 @@ +# `delete_categories_attachments_hook` + +This hook wraps the standard function that Misago uses to delete attachments associated with specified categories. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import delete_categories_attachments_hook +``` + + +## Filter + +```python +def custom_delete_categories_attachments_filter( + action: DeleteCategoriesAttachmentsHookAction, + categories: Iterable[Union[Category, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: DeleteCategoriesAttachmentsHookAction` + +A standard function used by Misago to delete attachments associated with specified categories. + +See the [action](#action) section for details. + + +#### `categories: Iterable[Union[Category, int]]` + +An iterable of categories or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Action + +```python +def delete_categories_attachments_action( + categories: Iterable[Union[Category, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A standard function used by Misago to delete attachments associated with specified categories. + + +### Arguments + +#### `categories: Iterable[Union[Category, int]]` + +An iterable of categories or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Example + +The code below implements a custom filter function that logs delete. + +```python +import logging +from typing import Iterable, Protocol, Union + +from django.http import HttpRequest +from misago.attachments.hooks import delete_categories_attachments_hook +from misago.categories.models import Category + +logger = logging.getLogger("attachments.delete") + + +@delete_categories_attachments_hook.append_filter +def log_delete_categories_attachments( + action, + categories: Iterable[Union[Category, int]], + *, + request: HttpRequest | None = None, +) -> int: + deleted = action(categories, request=request) + + if request and request.user.is_authenticated: + user = f"#{request.user.id}: {request.user.username}" + else: + user = None + + logger.info( + "Deleted categories attachments: %s", + str(deleted), + extra={"user": user}, + ) + + return deleted +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/delete-categories-hook.md b/dev-docs/plugins/hooks/delete-categories-hook.md new file mode 100644 index 0000000000..499f742ebe --- /dev/null +++ b/dev-docs/plugins/hooks/delete-categories-hook.md @@ -0,0 +1,138 @@ +# `delete_categories_hook` + +This hook wraps the standard function that Misago uses to delete category and its children. + + +## Location + +This hook can be imported from `misago.categories.hooks`: + +```python +from misago.categories.hooks import delete_categories_hook +``` + + +## Filter + +```python +def custom_delete_categories_filter( + action: DeleteCategoriesHookAction, + categories: list[Category], + *, + move_children_to: Category | bool | None=True, + move_contents_to: Category | None=None, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: DeleteCategoriesHookAction` + +A standard function used by Misago to delete category and its children. + + +#### `categories: list[Category]` + +A list of categories that will be deleted. If `move_children_to` is `None`, it will contain both the deleted category and all its descendants that will also be deleted. Otherwise, `categories` list will contain only single item. + + +#### `move_children_to: Category | bool | None = True` + +A category to move categories children to, `True` to make them root categories, or `None` to delete them too. + + +#### `move_contents_to: Category | None = None` + +A category to move categories content to, or `None` to delete contents too. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +## Action + +```python +def delete_categories_action( + categories: list[Category], + *, + move_children_to: Category | bool | None=True, + move_contents_to: Category | None=None, + request: HttpRequest | None=None, +): + ... +``` + +A standard function used by Misago to delete category and its children. + + +### Arguments + +#### `categories: list[Category]` + +A list of categories that will be deleted. If `move_children_to` is `None`, it will contain both the deleted category and all its descendants that will also be deleted. Otherwise, `categories` list will contain only single item. + + +#### `move_children_to: Category | bool | None = True` + +A category to move children categories to, `True` to make them root categories, or `None` to delete them too. + + +#### `move_contents_to: Category | None = None` + +A category to move categories content to, or `None` to delete contents too. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +## Example + +The code below implements a custom filter function that logs delete. + +```python +import logging +from typing import list, Protocol + +from django.http import HttpRequest +from misago.categories.hooks import delete_categories_hook +from misago.categories.models import Category + +logger = logging.getLogger("attachments.delete") + + +@delete_categories_hook.append_filter +def log_delete_categories( + action, + categories: list[Category], + *, + move_children_to: Category | bool | None = True, + move_contents_to: Category | None = None, + request: HttpRequest | None = None, +): + action( + categories, + move_children_to=move_children_to, + move_contents_to=move_contents_to, + request=request, + ) + + if request and request.user.is_authenticated: + user = f"#{request.user.id}: {request.user.username}" + else: + user = None + + logger.info( + "Deleted categories: %s", + ", ".join(f"#{c.id}: {c.name}" for c in categories), + extra={"user": user}, + ) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/delete-posts-attachments-hook.md b/dev-docs/plugins/hooks/delete-posts-attachments-hook.md new file mode 100644 index 0000000000..8608db1d9e --- /dev/null +++ b/dev-docs/plugins/hooks/delete-posts-attachments-hook.md @@ -0,0 +1,119 @@ +# `delete_posts_attachments_hook` + +This hook wraps the standard function that Misago uses to delete attachments associated with specified posts. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import delete_posts_attachments_hook +``` + + +## Filter + +```python +def custom_delete_posts_attachments_filter( + action: DeletePostsAttachmentsHookAction, + posts: Iterable[Union[Post, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: DeletePostsAttachmentsHookAction` + +A standard function used by Misago to delete attachments associated with specified posts. + +See the [action](#action) section for details. + + +#### `posts: Iterable[Union[Post, int]]` + +An iterable of posts or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Action + +```python +def delete_posts_attachments_action( + posts: Iterable[Union[Post, int]], *, request: HttpRequest | None=None +) -> int: + ... +``` + +A standard function used by Misago to delete attachments associated with specified posts. + + +### Arguments + +#### `posts: Iterable[Union[Post, int]]` + +An iterable of posts or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Example + +The code below implements a custom filter function that logs delete. + +```python +import logging +from typing import Iterable, Protocol, Union + +from django.http import HttpRequest +from misago.attachments.hooks import delete_posts_attachments_hook +from misago.threads.models import Post + +logger = logging.getLogger("attachments.delete") + + +@delete_posts_attachments_hook.append_filter +def log_delete_posts_attachments( + action, + posts: Iterable[Union[Post, int]], + *, + request: HttpRequest | None = None, +) -> int: + deleted = action(posts, request=request) + + if request and request.user.is_authenticated: + user = f"#{request.user.id}: {request.user.username}" + else: + user = None + + logger.info( + "Deleted posts attachments: %s", + str(deleted), + extra={"user": user}, + ) + + return deleted +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/delete-threads-attachments-hook.md b/dev-docs/plugins/hooks/delete-threads-attachments-hook.md new file mode 100644 index 0000000000..200ceb7b4d --- /dev/null +++ b/dev-docs/plugins/hooks/delete-threads-attachments-hook.md @@ -0,0 +1,121 @@ +# `delete_threads_attachments_hook` + +This hook wraps the standard function that Misago uses to delete attachments associated with specified threads. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import delete_threads_attachments_hook +``` + + +## Filter + +```python +def custom_delete_threads_attachments_filter( + action: DeleteThreadsAttachmentsHookAction, + threads: Iterable[Union[Thread, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: DeleteThreadsAttachmentsHookAction` + +A standard function used by Misago to delete attachments associated with specified threads. + +See the [action](#action) section for details. + + +#### `threads: Iterable[Union[Thread, int]]` + +An iterable of threads or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Action + +```python +def delete_threads_attachments_action( + threads: Iterable[Union[Thread, int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A standard function used by Misago to delete attachments associated with specified threads. + + +### Arguments + +#### `threads: Iterable[Union[Thread, int]]` + +An iterable of threads or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Example + +The code below implements a custom filter function that logs delete. + +```python +import logging +from typing import Iterable, Protocol, Union + +from django.http import HttpRequest +from misago.attachments.hooks import delete_threads_attachments_hook +from misago.threads.models import Thread + +logger = logging.getLogger("attachments.delete") + + +@delete_threads_attachments_hook.append_filter +def log_delete_threads_attachments( + action, + threads: Iterable[Union[Thread, int]], + *, + request: HttpRequest | None = None, +) -> int: + deleted = action(threads, request=request) + + if request and request.user.is_authenticated: + user = f"#{request.user.id}: {request.user.username}" + else: + user = None + + logger.info( + "Deleted threads attachments: %s", + str(deleted), + extra={"user": user}, + ) + + return deleted +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/delete-users-attachments-hook.md b/dev-docs/plugins/hooks/delete-users-attachments-hook.md new file mode 100644 index 0000000000..177c4fc9da --- /dev/null +++ b/dev-docs/plugins/hooks/delete-users-attachments-hook.md @@ -0,0 +1,121 @@ +# `delete_users_attachments_hook` + +This hook wraps the standard function that Misago uses to delete attachments associated with specified users. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import delete_users_attachments_hook +``` + + +## Filter + +```python +def custom_delete_users_attachments_filter( + action: DeleteUsersAttachmentsHookAction, + users: Iterable[Union['User', int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: DeleteUsersAttachmentsHookAction` + +A standard function used by Misago to delete attachments associated with specified users. + +See the [action](#action) section for details. + + +#### `users: Iterable[Union[User, int]]` + +An iterable of users or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Action + +```python +def delete_users_attachments_action( + users: Iterable[Union['User', int]], + *, + request: HttpRequest | None=None, +) -> int: + ... +``` + +A standard function used by Misago to delete attachments associated with specified users. + + +### Arguments + +#### `users: Iterable[Union[User, int]]` + +An iterable of users or their IDs. + + +#### `request: HttpRequest | None` + +The request object or `None`. + + +### Return value + +An `int` with the number of attachments marked for deletion. + + +## Example + +The code below implements a custom filter function that logs delete. + +```python +import logging +from typing import Iterable, Protocol, Union + +from django.http import HttpRequest +from misago.attachments.hooks import delete_users_attachments_hook +from misago.users.models import User + +logger = logging.getLogger("attachments.delete") + + +@delete_users_attachments_hook.append_filter +def log_delete_users_attachments( + action, + users: Iterable[Union[User, int]], + *, + request: HttpRequest | None = None, +) -> int: + deleted = action(users, request=request) + + if request and request.user.is_authenticated: + user = f"#{request.user.id}: {request.user.username}" + else: + user = None + + logger.info( + "Deleted users attachments: %s", + str(deleted), + extra={"user": user}, + ) + + return deleted +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-attachment-details-page-context-data-hook.md b/dev-docs/plugins/hooks/get-attachment-details-page-context-data-hook.md new file mode 100644 index 0000000000..961ec12bbc --- /dev/null +++ b/dev-docs/plugins/hooks/get-attachment-details-page-context-data-hook.md @@ -0,0 +1,96 @@ +# `get_attachment_details_page_context_data_hook` + +This hook wraps the standard function that Misago uses to get the template context data for the attachment details page. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import get_attachment_details_page_context_data_hook +``` + + +## Filter + +```python +def custom_get_attachment_details_page_context_data_filter( + action: GetAttachmentDetailsPageContextDataHookAction, + request: HttpRequest, + attachment: Attachment, +) -> dict: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: GetAttachmentDetailsPageContextDataHookAction` + +A standard Misago function used to get the template context data for the attachment details page. + +See the [action](#action) section for details. + + +#### `request: HttpRequest` + +The request object. + + +#### `attachment: Attachment` + +The `Attachment` instance. + + +### Return value + +A Python `dict` with context data to use to `render` the attachment details page. + + +## Action + +```python +def get_attachment_details_page_context_data_action(request: HttpRequest, attachment: Attachment) -> dict: + ... +``` + +A standard Misago function used to get the template context data for the attachment details page. + + +### Arguments + +#### `request: HttpRequest` + +The request object. + + +#### `attachment: Attachment` + +The `Attachment` instance. + + +### Return value + +A Python `dict` with context data to use to `render` the attachment details page. + + +## Example + +The code below implements a custom filter function that adds custom context data to the attachment details page: + +```python +from django.http import HttpRequest +from misago.attachments.hooks import get_attachment_details_page_context_data_hook + + +@get_attachment_details_page_context_data_hook.append_filter +def include_custom_context(action, request: HttpRequest, attachment: Attachment) -> dict: + context = action(request, attachment) + + context["plugin_data"] = "..." + + return context +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-attachment-plugin-data-hook.md b/dev-docs/plugins/hooks/get-attachment-plugin-data-hook.md new file mode 100644 index 0000000000..d6767622d4 --- /dev/null +++ b/dev-docs/plugins/hooks/get-attachment-plugin-data-hook.md @@ -0,0 +1,121 @@ +# `get_attachment_plugin_data_hook` + +This hook wraps the standard function that Misago uses to create a `dict` to be saved in new attachment's `plugin_data` JSON field. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import get_attachment_plugin_data_hook +``` + + +## Filter + +```python +def custom_get_attachment_plugin_data_filter( + action: GetAttachmentPluginDataHookAction, + request: HttpRequest, + upload: UploadedFile, + image: Image | None=None, +) -> dict: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: GetAttachmentPluginDataHookAction` + +A standard function that Misago uses to create a `dict` to be saved in new attachment's `plugin_data` JSON field. + +See the [action](#action) section for details. + + +#### `request: HttpRequest` + +The request object. + + +#### `upload: UploadedFile` + +The `UploadedFile` instance. + + +#### `image: Image | None` + +The `PIL.Image.Image` instance if uploaded file was an image, or `None`. + + +### Return value + +A JSON-serializable `dict`. + + +## Action + +```python +def get_attachment_plugin_data_action( + request: HttpRequest, upload: UploadedFile, image: Image | None=None +) -> dict: + ... +``` + +A standard function that Misago uses to create a `dict` to be saved in new attachment's `plugin_data` JSON field. + + +### Arguments + +#### `request: HttpRequest` + +The request object. + + +#### `upload: UploadedFile` + +The `UploadedFile` instance. + + +#### `image: Image | None` + +The `PIL.Image.Image` instance if uploaded file was an image, or `None`. + + +### Return value + +A JSON-serializable `dict`. + + +## Example + +The code below implements a custom filter function that stores an image's EXIF data in the `Attachment.plugin_data` if the uploaded file is an image: + +```python +from PIL.Image import Image +from django.core.files.uploadedfile import UploadedFile +from django.http import HttpRequest +from misago.attachments.hooks import get_attachment_plugin_data_hook + + +@get_attachment_plugin_data_hook.append_filter +def store_attachment_exif_data( + action, + request: HttpRequest, + upload: UploadedFile, + image: Image | None = None, +) -> dict: + plugin_data = action(request, upload, image) + + if image: + exif = image.getexif() + plugin_data["exif"] = { + "make": exif.get(271, None), + "model": exif.get(272, None) + } + + return plugin_data +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-categories-page-component-hook.md b/dev-docs/plugins/hooks/get-categories-page-component-hook.md new file mode 100644 index 0000000000..488c8bb334 --- /dev/null +++ b/dev-docs/plugins/hooks/get-categories-page-component-hook.md @@ -0,0 +1,92 @@ +# `get_categories_page_component_hook` + +This hook wraps the standard function that Misago uses to build a `dict` with data for the categories list component, used to display the list of categories on the categories page. + + +## Location + +This hook can be imported from `misago.categories.hooks`: + +```python +from misago.categories.hooks import get_categories_page_component_hook +``` + + +## Filter + +```python +def custom_get_categories_page_component_filter( + action: GetCategoriesPageComponentHookAction, request: HttpRequest +) -> dict: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: GetCategoriesPageComponentHookAction` + +A standard Misago function used to build a `dict` with data for the categories list component, used to display the list of categories on the categories page. + +See the [action](#action) section for details. + + +#### `request: HttpRequest` + +The request object. + + +### Return value + +A Python `dict` with data for the categories list component. + + +## Action + +```python +def get_categories_page_component_action(request: HttpRequest) -> dict: + ... +``` + +A standard Misago function used to build a `dict` with data for the categories list component, used to display the list of categories on the categories page. + + +### Arguments + +#### `request: HttpRequest` + +The request object. + + +### Return value + +A Python `dict` with data for the categories list component. + +Must have at least two keys: `categories` and `template_name`: + +```python +{ + "categories": ..., + "template_name": "misago/categories/list.html" +} +``` + + +## Example + +The code below implements a custom filter function that replaces default categories component with a custom one. + +```python +from django.http import HttpRequest +from misago.categories.hooks import get_categories_component_hook + + +@get_categories_component_hook.append_filter +def custom_categories_list(action, request: HttpRequest) -> dict: + return { + "categories": [], + "template_name": "plugin/categories_list.html", + } +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-categories-page-metatags-hook.md b/dev-docs/plugins/hooks/get-categories-page-metatags-hook.md new file mode 100644 index 0000000000..d688a68a4c --- /dev/null +++ b/dev-docs/plugins/hooks/get-categories-page-metatags-hook.md @@ -0,0 +1,103 @@ +# `get_categories_page_metatags_hook` + +This hook wraps the standard function that Misago uses to get metatags for the categories page. + + +## Location + +This hook can be imported from `misago.categories.hooks`: + +```python +from misago.categories.hooks import get_categories_page_metatags_hook +``` + + +## Filter + +```python +def custom_get_categories_page_metatags_filter( + action: GetCategoriesPageMetatagsHookAction, + request: HttpRequest, + context: dict, +) -> dict[str, MetaTag]: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: GetCategoriesPageMetatagsHookAction` + +A standard Misago function used to get metatags for the categories page. + +See the [action](#action) section for details. + + +#### `request: HttpRequest` + +The request object. + + +#### `context: dict` + +The context to render the page with. + + +### Return value + +A Python `dict` with metatags to include in the response HTML. + + +## Action + +```python +def get_categories_page_metatags_action(request: HttpRequest, context: dict) -> dict[str, MetaTag]: + ... +``` + +A standard Misago function used to get metatags for the categories page. + + +### Arguments + +#### `request: HttpRequest` + +The request object. + + +#### `context: dict` + +The context to render the page with. + + +### Return value + +A Python `dict` with metatags to include in the response HTML. + + +## Example + +The code below implements a custom filter function that sets a custom metatag on the categories page, if its not used as the forum index page: + +```python +from django.http import HttpRequest +from misago.categories.hooks import get_categories_page_metatags_hook +from misago.metatags.metatag import MetaTag + + +@get_categories_page_metatags_hook.append_filter +def include_custom_metatag(action, request: HttpRequest, context) -> dict[str, MetaTag]: + metatags = action(request) + + if not context["is_index"]: + categories = len(context["categories_list"]) + metatags["description"] = MetaTag( + name="og:description", + property="twitter:description", + content=f"There are currently {categories} categories on our forums.", + ) + + return metatags +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-categories-query-values-hook.md b/dev-docs/plugins/hooks/get-categories-query-values-hook.md new file mode 100644 index 0000000000..9af5d51a6e --- /dev/null +++ b/dev-docs/plugins/hooks/get-categories-query-values-hook.md @@ -0,0 +1,58 @@ +# `get_categories_query_values_hook` + +This hook wraps the standard Misago function used to retrieve a set of arguments for the `values` call on the categories queryset. + + +## Location + +This hook can be imported from `misago.categories.hooks`: + +```python +from misago.categories.hooks import get_categories_query_values_hook +``` + + +## Filter + +```python +def custom_get_categories_query_values_filter(action: GetCategoriesQueryValuesHookAction) -> set[str]: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Return value + +A Python `set` with names of the `Category` model fields to include in the queryset. + + +## Action + +```python +def get_categories_query_values_action(self) -> set[str]: + ... +``` + +A standard Misago function used to retrieve a set of arguments for the `values` call on the categories queryset. + + +### Return value + +A Python `set` with names of the `Category` model fields to include in the queryset. + + +## Example + +The code below implements a custom filter function that includes the `plugin_data` field in the queryset. + +```python +from misago.categories.hooks import get_categories_query_values_hook + + +@get_categories_query_values_hook.append_filter +def include_plugin_data_in_query(action) -> set[str]: + fields = action(groups) + fields.add("plugin_data") + return fields +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-category-data-hook.md b/dev-docs/plugins/hooks/get-category-data-hook.md new file mode 100644 index 0000000000..bb06f7ee9d --- /dev/null +++ b/dev-docs/plugins/hooks/get-category-data-hook.md @@ -0,0 +1,75 @@ +# `get_category_data_hook` + +This hook wraps the standard function that Misago uses to build a `dict` with category data from queryset's result. + + +## Location + +This hook can be imported from `misago.categories.hooks`: + +```python +from misago.categories.hooks import get_category_data_hook +``` + + +## Filter + +```python +def custom_get_category_data_filter( + action: GetCategoryDataHookAction, result: dict[str, Any] +) -> dict[str, Any]: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +#### `result: dict[str, Any]` + +A `dict` with category data returned by the queryset. + + +### Return value + +A Python `dict` with category data to cache and use by Misago. + + +## Action + +```python +def get_category_data_action(result: dict[str, Any]) -> dict[str, Any]: + ... +``` + +A standard Misago function used to build a `dict` with category result from queryset's result. + + +### Arguments + +#### `result: dict[str, Any]` + +A `dict` with category data returned by the queryset. + + +### Return value + +A Python `dict` with category data to cache and use by Misago. + + +## Example + +The code below implements a custom filter function that includes a custom dict entry using `plugin_data`: + +```python +from typing import Any +from misago.categories.hooks import get_category_data_hook + + +@get_category_data_hook.append_filter +def include_plugin_permission_in_data(action, result: result[str, Any]) -> dict: + data = action(groups) + if result.get("plugin_data"): + data["plugin_flag"] = result["plugin_data"].get("plugin_flag") + + return data +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-posts-feed-item-user-ids-hook.md b/dev-docs/plugins/hooks/get-posts-feed-item-user-ids-hook.md deleted file mode 100644 index 92a6a6b664..0000000000 --- a/dev-docs/plugins/hooks/get-posts-feed-item-user-ids-hook.md +++ /dev/null @@ -1,57 +0,0 @@ -# `get_posts_feed_item_user_ids_hook` - -This hook enables plugins to include extra user IDs stored on posts in the query that Misago uses to retrieve `User`s to display on thread and private thread replies pages. - - -## Location - -This hook can be imported from `misago.threads.hooks`: - -```python -from misago.threads.hooks import get_posts_feed_item_user_ids_hook -``` - - -## Action - -```python -def custom_get_posts_feed_item_user_ids_filter( - request: HttpRequest, item: dict, user_ids: set[int] -): - ... -``` - -A function that finds user ids in the `item` and updates `user_ids` set with them. - - -### Arguments - -#### `item: dict` - -A `dict` with feed's item data. - - -#### `user_ids: set[int]` - -A `set` of `int`s being user ids to retrieve from the database that action should mutate calling its `add` or `update` methods. - - -## Example - -The code below implements a custom function that adds - -```python -from misago.threads.hooks import get_posts_feed_item_user_ids_hook - - -@get_posts_feed_item_user_ids_hook.append_action -def include_plugin_users( - item: dict, - user_ids: set[int], -): - if item["type"] != "post": - return - - if linked_user_ids := item["plugin_data"].get("linked_posts_users"): - user_ids.update(linked_user_ids) -``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/get-posts-feed-users-hook.md b/dev-docs/plugins/hooks/get-posts-feed-users-hook.md deleted file mode 100644 index 8a01238b9b..0000000000 --- a/dev-docs/plugins/hooks/get-posts-feed-users-hook.md +++ /dev/null @@ -1,105 +0,0 @@ -# `get_posts_feed_users_hook` - -This hook wraps the standard function that Misago uses to get a `dict` of `User` instances used to display thread posts feed. Users have their `group` field already populated. - - -## Location - -This hook can be imported from `misago.threads.hooks`: - -```python -from misago.threads.hooks import get_posts_feed_users_hook -``` - - -## Filter - -```python -def custom_get_posts_feed_users_filter( - action: GetThreadPostsFeedUsersHookAction, - request: HttpRequest, - user_ids: set[int], -) -> dict[int, 'User']: - ... -``` - -A function implemented by a plugin that can be registered in this hook. - - -### Arguments - -#### `action: GetThreadPostsFeedUsersHookAction` - -A standard Misago function used to get a `dict` of `User` instances used to display thread posts feed. Users have their `group` field populated. - -See the [action](#action) section for details. - - -#### `request: HttpRequest` - -The request object. - - -#### `user_ids: set[int]` - -A set of IDs of `User` objects to retrieve from the database - - -#### Return value - -A `dict` of `User` instances, indexed by their IDs. - - -## Action - -```python -def get_posts_feed_users_action(request: HttpRequest, user_ids: set[int]) -> dict[int, 'User']: - ... -``` - -A standard Misago function used to get a `dict` of `User` instances used to display thread posts feed. Users have their `group` field already populated. - - -### Arguments - -#### `request: HttpRequest` - -The request object. - - -#### `user_ids: set[int]` - -A set of IDs of `User` objects to retrieve from the database - - -#### Return value - -A `dict` of `User` instances, indexed by their IDs. - - -## Example - -The code below implements a custom filter function that removes some users from the dictionary, making them display on a posts feed as deleted users. - -```python -from typing import TYPE_CHECKING - -from django.http import HttpRequest -from misago.threads.hooks import get_posts_feed_users_hook - -if TYPE_CHECKING: - from misago.users.models import User - - -@get_posts_feed_users_hook.append_filter -def replace_post_poster( - action, request: HttpRequest, user_ids: set[int] -) -> dict[int, "User"]: - users = action(request, user_ids) - - for user_id, user in list(users.items()) - if user.plugin_data.get("is_hidden"): - del users[user_id] - - return users -``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/reference.md b/dev-docs/plugins/hooks/reference.md index 2fd8c872c3..e671639bd8 100644 --- a/dev-docs/plugins/hooks/reference.md +++ b/dev-docs/plugins/hooks/reference.md @@ -4,6 +4,7 @@ This document contains a list of all standard plugin hooks existing in Misago. Hooks instances are importable from the following Python modules: +- [`misago.attachments.hooks`](#misago-attachments-hooks) - [`misago.categories.hooks`](#misago-categories-hooks) - [`misago.oauth2.hooks`](#misago-oauth2-hooks) - [`misago.parser.hooks`](#misago-parser-hooks) @@ -13,10 +14,29 @@ Hooks instances are importable from the following Python modules: - [`misago.users.hooks`](#misago-users-hooks) +## `misago.attachments.hooks` + +`misago.attachments.hooks` defines the following hooks: + +- [`delete_attachments_hook`](./delete-attachments-hook.md) +- [`delete_categories_attachments_hook`](./delete-categories-attachments-hook.md) +- [`delete_posts_attachments_hook`](./delete-posts-attachments-hook.md) +- [`delete_threads_attachments_hook`](./delete-threads-attachments-hook.md) +- [`delete_users_attachments_hook`](./delete-users-attachments-hook.md) +- [`get_attachment_details_page_context_data_hook`](./get-attachment-details-page-context-data-hook.md) +- [`get_attachment_plugin_data_hook`](./get-attachment-plugin-data-hook.md) +- [`serialize_attachment_hook`](./serialize-attachment-hook.md) + + ## `misago.categories.hooks` `misago.categories.hooks` defines the following hooks: +- [`delete_categories_hook`](./delete-categories-hook.md) +- [`get_categories_page_component_hook`](./get-categories-page-component-hook.md) +- [`get_categories_page_metatags_hook`](./get-categories-page-metatags-hook.md) +- [`get_categories_query_values_hook`](./get-categories-query-values-hook.md) +- [`get_category_data_hook`](./get-category-data-hook.md) ## `misago.oauth2.hooks` @@ -31,11 +51,11 @@ Hooks instances are importable from the following Python modules: `misago.parser.hooks` defines the following hooks: -- [`complete_markup_html_hook`](./complete-markup-html-hook.md) - [`create_parser_hook`](./create-parser-hook.md) - [`get_ast_metadata_users_queryset_hook`](./get-ast-metadata-users-queryset-hook.md) - [`render_ast_node_to_html_hook`](./render-ast-node-to-html-hook.md) - [`render_ast_node_to_plaintext_hook`](./render-ast-node-to-plaintext-hook.md) +- [`replace_rich_text_tokens_hook`](./replace-rich-text-tokens-hook.md) - [`setup_parser_context_hook`](./setup-parser-context-hook.md) - [`update_ast_metadata_from_node_hook`](./update-ast-metadata-from-node-hook.md) - [`update_ast_metadata_hook`](./update-ast-metadata-hook.md) @@ -48,19 +68,25 @@ Hooks instances are importable from the following Python modules: - [`build_user_category_permissions_hook`](./build-user-category-permissions-hook.md) - [`build_user_permissions_hook`](./build-user-permissions-hook.md) +- [`can_upload_private_threads_attachments_hook`](./can-upload-private-threads-attachments-hook.md) +- [`can_upload_threads_attachments_hook`](./can-upload-threads-attachments-hook.md) - [`check_browse_category_permission_hook`](./check-browse-category-permission-hook.md) -- [`check_edit_post_permission_hook`](./check-edit-post-permission-hook.md) +- [`check_download_attachment_permission_hook`](./check-download-attachment-permission-hook.md) - [`check_edit_private_thread_permission_hook`](./check-edit-private-thread-permission-hook.md) - [`check_edit_private_thread_post_permission_hook`](./check-edit-private-thread-post-permission-hook.md) - [`check_edit_thread_permission_hook`](./check-edit-thread-permission-hook.md) +- [`check_edit_thread_post_permission_hook`](./check-edit-thread-post-permission-hook.md) - [`check_post_in_closed_category_permission_hook`](./check-post-in-closed-category-permission-hook.md) - [`check_post_in_closed_thread_permission_hook`](./check-post-in-closed-thread-permission-hook.md) - [`check_private_threads_permission_hook`](./check-private-threads-permission-hook.md) - [`check_reply_private_thread_permission_hook`](./check-reply-private-thread-permission-hook.md) - [`check_reply_thread_permission_hook`](./check-reply-thread-permission-hook.md) - [`check_see_category_permission_hook`](./check-see-category-permission-hook.md) +- [`check_see_post_permission_hook`](./check-see-post-permission-hook.md) - [`check_see_private_thread_permission_hook`](./check-see-private-thread-permission-hook.md) +- [`check_see_private_thread_post_permission_hook`](./check-see-private-thread-post-permission-hook.md) - [`check_see_thread_permission_hook`](./check-see-thread-permission-hook.md) +- [`check_see_thread_post_permission_hook`](./check-see-thread-post-permission-hook.md) - [`check_start_private_threads_permission_hook`](./check-start-private-threads-permission-hook.md) - [`check_start_thread_permission_hook`](./check-start-thread-permission-hook.md) - [`copy_category_permissions_hook`](./copy-category-permissions-hook.md) @@ -111,6 +137,7 @@ Hooks instances are importable from the following Python modules: `misago.threads.hooks` defines the following hooks: +- [`create_prefetch_posts_related_objects_hook`](./create-prefetch-posts-related-objects-hook.md) - [`get_category_threads_page_context_data_hook`](./get-category-threads-page-context-data-hook.md) - [`get_category_threads_page_filters_hook`](./get-category-threads-page-filters-hook.md) - [`get_category_threads_page_moderation_actions_hook`](./get-category-threads-page-moderation-actions-hook.md) @@ -121,8 +148,6 @@ Hooks instances are importable from the following Python modules: - [`get_edit_private_thread_post_page_context_data_hook`](./get-edit-private-thread-post-page-context-data-hook.md) - [`get_edit_thread_page_context_data_hook`](./get-edit-thread-page-context-data-hook.md) - [`get_edit_thread_post_page_context_data_hook`](./get-edit-thread-post-page-context-data-hook.md) -- [`get_posts_feed_item_user_ids_hook`](./get-posts-feed-item-user-ids-hook.md) -- [`get_posts_feed_users_hook`](./get-posts-feed-users-hook.md) - [`get_private_thread_replies_page_context_data_hook`](./get-private-thread-replies-page-context-data-hook.md) - [`get_private_thread_replies_page_posts_queryset_hook`](./get-private-thread-replies-page-posts-queryset-hook.md) - [`get_private_thread_replies_page_thread_queryset_hook`](./get-private-thread-replies-page-thread-queryset-hook.md) @@ -145,7 +170,7 @@ Hooks instances are importable from the following Python modules: - [`get_threads_page_queryset_hook`](./get-threads-page-queryset-hook.md) - [`get_threads_page_subcategories_hook`](./get-threads-page-subcategories-hook.md) - [`get_threads_page_threads_hook`](./get-threads-page-threads-hook.md) -- [`set_posts_feed_item_users_hook`](./set-posts-feed-item-users-hook.md) +- [`set_posts_feed_related_objects_hook`](./set-posts-feed-related-objects-hook.md) ## `misago.users.hooks` diff --git a/dev-docs/plugins/hooks/replace-rich-text-tokens-hook.md b/dev-docs/plugins/hooks/replace-rich-text-tokens-hook.md new file mode 100644 index 0000000000..54dbeae14b --- /dev/null +++ b/dev-docs/plugins/hooks/replace-rich-text-tokens-hook.md @@ -0,0 +1,102 @@ +# `replace_rich_text_tokens_hook` + +This hook wraps the standard function that Misago uses to replace rich-text tokens in pre-rendered HTML or the next filter from another plugin. + +Tokens are pseudo-HTML elements like `` that are replaced with real HTML markup instead. + + +## Location + +This hook can be imported from `misago.parser.hooks`: + +```python +from misago.parser.hooks import replace_rich_text_tokens_hook +``` + + +## Filter + +```python +def custom_replace_rich_text_tokens_filter( + action: ReplaceRichTextTokensHookAction, html: str, data: dict +) -> str: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: ReplaceRichTextTokensHookAction` + +A standard Misago function used to replace rich-text tokens in pre-rendered HTML or the next filter from another plugin. + +See the [action](#action) section for details. + + +#### `html: str` + +An HTML string in which tokens will be replaced. + + +#### `data: dict` + +Data that can be embedded in HTML. + + +### Return value + +A `str` with HTML that has its tokens replaced. + + +## Action + +```python +def replace_rich_text_tokens_action(html: str, data: dict) -> str: + ... +``` + +A standard Misago function used to replace rich-text tokens in pre-rendered HTML or the next filter from another plugin. + + +### Arguments + +#### `html: str` + +An HTML string in which tokens will be replaced. + + +#### `data: dict` + +Data that can be embedded in HTML. + + +### Return value + +A `str` with HTML that has its tokens replaced. + + +## Example + +The code below implements a custom filter function that replaces default spoiler block summary with a custom message: + +```python +from misago.parser.context import ParserContext +from misago.parser.html import SPOILER_SUMMARY + + +@replace_rich_text_tokens_hook.append_filter +def replace_rich_text_spoiler_hoom( + action: ReplaceRichTextTokensHookAction, + html: str, + data: dict, +) -> str: + if SPOILER_SUMMARY in html: + html = html.replace( + SPOILER_SUMMARY, "SPOILER! Click at your own discretion!" + ) + + # Call the next function in chain + return action(context, html, **kwargs) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/serialize-attachment-hook.md b/dev-docs/plugins/hooks/serialize-attachment-hook.md new file mode 100644 index 0000000000..f665c594ec --- /dev/null +++ b/dev-docs/plugins/hooks/serialize-attachment-hook.md @@ -0,0 +1,84 @@ +# `serialize_attachment_hook` + +This hook wraps the standard function that Misago uses to create a JSON-serializable `dict` for an attachment. + + +## Location + +This hook can be imported from `misago.attachments.hooks`: + +```python +from misago.attachments.hooks import serialize_attachment_hook +``` + + +## Filter + +```python +def custom_serialize_attachment_filter( + action: SerializeAttachmentHookAction, attachment: Attachment +) -> dict: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: SerializeAttachmentHookAction` + +A standard function that Misago uses to create a JSON-serializable `dict` for an attachment. + +See the [action](#action) section for details. + + +#### `attachment: Attachment` + +The `Attachment` instance to serialize. + + +### Return value + +A JSON-serializable `dict`. + + +## Action + +```python +def serialize_attachment_action(attachment: Attachment) -> dict: + ... +``` + +A standard function that Misago uses to create a JSON-serializable `dict` for an attachment. + + +### Arguments + +#### `attachment: Attachment` + +The `Attachment` instance to serialize. + + +### Return value + +A JSON-serializable `dict`. + + +## Example + +The code below implements a custom filter function that includes image's EXIF data in serialized payload + +```python +from misago.attachments.hooks import serialize_attachment_hook +from misago.attachments.models import Attachment + + +@serialize_attachment_hook.append_filter +def serialize_attachment_exif_data( + action, attachment: Attachment +) -> dict: + data = action(attachment) + data["exif"] = attachment.plugin_data.get("exif) + return data +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/set-posts-feed-item-users-hook.md b/dev-docs/plugins/hooks/set-posts-feed-item-users-hook.md deleted file mode 100644 index 08581f3b45..0000000000 --- a/dev-docs/plugins/hooks/set-posts-feed-item-users-hook.md +++ /dev/null @@ -1,92 +0,0 @@ -# `set_posts_feed_item_users_hook` - -This hook wraps the standard function that Misago uses to set `User` instances on a `dict` with thread posts feed item's data. - - -## Location - -This hook can be imported from `misago.threads.hooks`: - -```python -from misago.threads.hooks import set_posts_feed_item_users_hook -``` - - -## Filter - -```python -def custom_set_posts_feed_item_users_filter( - action: SetPostFeedItemUsersHookAction, - users: dict[int, 'User'], - item: dict, -): - ... -``` - -A function implemented by a plugin that can be registered in this hook. - - -### Arguments - -#### `action: SetPostFeedItemUsersHookAction` - -A standard Misago function used to set `User` instances on a `dict` with thread posts feed item's data. - -See the [action](#action) section for details. - - -#### `users: dict[int, "User"]` - -A `dict` of `User` instances, indexed by their IDs. - - -#### `item: dict` - -A `dict` with posts feed item's data. Hook should update it using the `User` instances from the `users`. - - -## Action - -```python -def set_posts_feed_item_users_action(users: dict[int, 'User'], item: dict): - ... -``` - -A standard Misago function used to set `User` instances on a `dict` with thread posts feed item's data. - - -### Arguments - -#### `users: dict[int, "User"]` - -A `dict` of `User` instances, indexed by their IDs. - - -#### `item: dict` - -A `dict` with posts feed item's data. Hook should update it using the `User` instances from the `users`. - - -## Example - -The code below implements a custom filter function that replaces post's real author with other one: - -```python -from typing import TYPE_CHECKING - -from misago.threads.hooks import set_posts_feed_item_users_hook - -if TYPE_CHECKING: - from misago.users.models import User - - -@set_posts_feed_item_users_hook.append_filter -def replace_post_poster( - action, users: dict[int, "User"], item: dict -): - action(users, item) - - if item["type"] == "post": - if override_poster := item["post"].plugin_data.get("poster_id"): - item["poster"] = users[override_poster] -``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/set-posts-feed-related-objects-hook.md b/dev-docs/plugins/hooks/set-posts-feed-related-objects-hook.md new file mode 100644 index 0000000000..617f9b9868 --- /dev/null +++ b/dev-docs/plugins/hooks/set-posts-feed-related-objects-hook.md @@ -0,0 +1,90 @@ +# `set_posts_feed_related_objects_hook` + +This hook wraps the standard function that Misago uses to set related objects on dicts containing posts feed data. + + +## Location + +This hook can be imported from `misago.threads.hooks`: + +```python +from misago.threads.hooks import set_posts_feed_related_objects_hook +``` + + +## Filter + +```python +def custom_set_posts_feed_related_objects_filter( + action: SetPostsFeedRelatedObjectsHookAction, + feed: list[dict], + related_objects: dict, +): + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: SetPostsFeedRelatedObjectsHookAction` + +A standard Misago function used to set related objects on dicts containing posts feed data. + +See the [action](#action) section for details. + + +#### `feed: list[dict]` + +A list of `dict` objects with post feed items. This function updates these dicts with objects from the `related_objects` dictionary. + + +#### `related_objects: dict` + +A `dict` with objects related to the feed's items. + + +## Action + +```python +def set_posts_feed_related_objects_action(feed: list[dict], related_objects: dict): + ... +``` + +A standard Misago function used to set related objects on dicts containing posts feed data. + + +### Arguments + +#### `feed: list[dict]` + +A list of `dict` objects with post feed items. This function updates these dicts with objects from the `related_objects` dictionary. + + +#### `related_objects: dict` + +A `dict` with objects related to the feed's items. + + +## Example + +The code below implements a custom filter function that populates feed's items with plugin objects + +```python +from misago.threads.hooks import set_posts_feed_related_objects_hook + + +@set_posts_feed_related_objects_hook.append_filter +def replace_post_poster( + action, feed: list[dict], related_objects: dict +): + action(feed, related_objects) + + for item in feed: + if item["type"] == "post": + plugin_obj = related_objects["plugin_objects"].get( + item["post"].plugin_data["plugin_relation_id"] + ) + item["plugin_attr"] = plugin_obj +``` \ No newline at end of file diff --git a/dev-docs/plugins/template-outlets-reference.md b/dev-docs/plugins/template-outlets-reference.md index ab3f004bba..0b225ee3da 100644 --- a/dev-docs/plugins/template-outlets-reference.md +++ b/dev-docs/plugins/template-outlets-reference.md @@ -3,9 +3,9 @@ This document contains a list of all built-in template outlets in Misago. -## `ADMIN_DASHBOARD_AFTER_ANALYTICS` +## `ADMIN_DASHBOARD_START` -On the Admin dashboard page, below the Analytics card. +On the Admin dashboard page, above all other content. ## `ADMIN_DASHBOARD_AFTER_CHECKS` @@ -13,39 +13,44 @@ On the Admin dashboard page, below the Analytics card. On the Admin dashboard page, below the Checks card. +## `ADMIN_DASHBOARD_AFTER_ANALYTICS` + +On the Admin dashboard page, below the Analytics card. + + ## `ADMIN_DASHBOARD_END` On the Admin dashboard page, below all other content. -## `ADMIN_DASHBOARD_START` +## `ATTACHMENT_PAGE_START` -On the Admin dashboard page, above all other content. +On the attachment details page, above the preview. -## `CATEGORIES_LIST_END` +## `ATTACHMENT_PAGE_AFTER_PREVIEW` -On the Categories page, below the list. +On the attachment details page, under the preview. -## `CATEGORIES_LIST_START` +## `ATTACHMENT_PAGE_END` -On the Categories page, above the list. +On the attachment details page, under the details block. -## `CATEGORY_THREADS_LIST_END` +## `ATTACHMENT_DELETE_PAGE_START` -On the Category threads page, below the list. +On the attachment delete page, above the confirmation block. -## `CATEGORY_THREADS_LIST_MIDDLE` +## `ATTACHMENT_DELETE_PAGE_END` -On the Category threads page, between the subcategories and the list. +On the attachment delete page, below the confirmation block. -## `CATEGORY_THREADS_LIST_START` +## `LOGIN_PAGE_START` -On the Category threads page, above the list. +On the Sign in page, above the form. ## `LOGIN_PAGE_END` @@ -53,44 +58,44 @@ On the Category threads page, above the list. On the Sign in page, below the form. -## `LOGIN_PAGE_START` +## `CATEGORIES_LIST_START` -On the Sign in page, above the form. +On the Categories page, above the list. -## `MARKUP_EDITOR_TOOLBAR_BEFORE_LINK` +## `CATEGORIES_LIST_END` -On the the markup editor's toolbar, between insert horizontal ruler and insert link. +On the Categories page, below the list. -## `MARKUP_EDITOR_TOOLBAR_BEFORE_QUOTE` +## `THREADS_LIST_START` -On the the markup editor's toolbar, between insert photo and insert quote. +On the Threads page, above the list. -## `MARKUP_EDITOR_TOOLBAR_BEFORE_RULER` +## `THREADS_LIST_MIDDLE` -On the the markup editor's toolbar, between strikethrough and insert horizontal. +On the Threads page, between the subcategories and the list. -## `MARKUP_EDITOR_TOOLBAR_BEFORE_UPLOAD` +## `THREADS_LIST_END` -On the the markup editor's toolbar, between insert code block and upload. +On the Threads page, below the list. -## `MARKUP_EDITOR_TOOLBAR_END` +## `CATEGORY_THREADS_LIST_START` -At the end of the markup editor's toolbar. +On the Category threads page, above the list. -## `MARKUP_EDITOR_TOOLBAR_START` +## `CATEGORY_THREADS_LIST_MIDDLE` -At the start of the markup editor's toolbar. +On the Category threads page, between the subcategories and the list. -## `PRIVATE_THREADS_LIST_END` +## `CATEGORY_THREADS_LIST_END` -On the Private threads page, below the list. +On the Category threads page, below the list. ## `PRIVATE_THREADS_LIST_START` @@ -98,101 +103,116 @@ On the Private threads page, below the list. On the Private threads page, above the list. -## `PRIVATE_THREAD_REPLIES_PAGE_END` +## `PRIVATE_THREADS_LIST_END` -On the Private thread replies page, above the bottom breadcrumbs. +On the Private threads page, below the list. -## `PRIVATE_THREAD_REPLIES_PAGE_START` +## `THREADS_LIST_TOOLBAR_START` -On the Private thread replies page, below the page's header. +On threads lists pages, at the start of the toolbar. -## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_AFTER_SPACER` +## `THREADS_LIST_TOOLBAR_BEFORE_SPACER` -On the Private thread replies page, after the toolbar's spacer. +On threads lists pages, before the toolbar's spacer. -## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_BEFORE_SPACER` +## `THREADS_LIST_TOOLBAR_AFTER_SPACER` -On the Private thread replies page, before the toolbar's spacer. +On threads lists pages, after the toolbar's spacer. -## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_END` +## `THREADS_LIST_TOOLBAR_END` -On the Private thread replies page, at the end of the toolbar. +On threads lists pages, at the end of the toolbar. -## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_START` +## `THREAD_REPLIES_PAGE_START` -On the Private thread replies page, at the start of the toolbar. +On the Thread replies page, below the page's header. -## `TEST` +## `THREAD_REPLIES_PAGE_END` -Used in some tests. +On the Thread replies page, above the bottom breadcrumbs. -## `THREADS_LIST_END` +## `THREAD_REPLIES_PAGE_TOOLBAR_START` -On the Threads page, below the list. +On the Thread replies page, at the start of the toolbar. -## `THREADS_LIST_MIDDLE` +## `THREAD_REPLIES_PAGE_TOOLBAR_BEFORE_SPACER` -On the Threads page, between the subcategories and the list. +On the Thread replies page, before the toolbar's spacer. -## `THREADS_LIST_START` +## `THREAD_REPLIES_PAGE_TOOLBAR_AFTER_SPACER` -On the Threads page, above the list. +On the Thread replies page, after the toolbar's spacer. -## `THREADS_LIST_TOOLBAR_AFTER_SPACER` +## `THREAD_REPLIES_PAGE_TOOLBAR_END` -On threads lists pages, after the toolbar's spacer. +On the Thread replies page, at the end of the toolbar. -## `THREADS_LIST_TOOLBAR_BEFORE_SPACER` +## `PRIVATE_THREAD_REPLIES_PAGE_START` -On threads lists pages, before the toolbar's spacer. +On the Private thread replies page, below the page's header. -## `THREADS_LIST_TOOLBAR_END` +## `PRIVATE_THREAD_REPLIES_PAGE_END` -On threads lists pages, at the end of the toolbar. +On the Private thread replies page, above the bottom breadcrumbs. -## `THREADS_LIST_TOOLBAR_START` +## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_START` -On threads lists pages, at the start of the toolbar. +On the Private thread replies page, at the start of the toolbar. -## `THREAD_REPLIES_PAGE_END` +## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_BEFORE_SPACER` -On the Thread replies page, above the bottom breadcrumbs. +On the Private thread replies page, before the toolbar's spacer. -## `THREAD_REPLIES_PAGE_START` +## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_AFTER_SPACER` -On the Thread replies page, below the page's header. +On the Private thread replies page, after the toolbar's spacer. -## `THREAD_REPLIES_PAGE_TOOLBAR_AFTER_SPACER` +## `PRIVATE_THREAD_REPLIES_PAGE_TOOLBAR_END` -On the Thread replies page, after the toolbar's spacer. +On the Private thread replies page, at the end of the toolbar. -## `THREAD_REPLIES_PAGE_TOOLBAR_BEFORE_SPACER` +## `MARKUP_EDITOR_TOOLBAR_START` -On the Thread replies page, before the toolbar's spacer. +At the start of the markup editor's toolbar. -## `THREAD_REPLIES_PAGE_TOOLBAR_END` +## `MARKUP_EDITOR_TOOLBAR_BEFORE_RULER` -On the Thread replies page, at the end of the toolbar. +On the the markup editor's toolbar, between strikethrough and insert horizontal. -## `THREAD_REPLIES_PAGE_TOOLBAR_START` +## `MARKUP_EDITOR_TOOLBAR_BEFORE_LINK` + +On the the markup editor's toolbar, between insert horizontal ruler and insert link. + + +## `MARKUP_EDITOR_TOOLBAR_BEFORE_QUOTE` + +On the the markup editor's toolbar, between insert photo and insert quote. + + +## `MARKUP_EDITOR_TOOLBAR_END` + +At the end of the markup editor's toolbar. + + +## `TEST` -On the Thread replies page, at the start of the toolbar. \ No newline at end of file +Used in some tests. \ No newline at end of file diff --git a/devproject/settings.py b/devproject/settings.py index a049b76f54..b0c04fe2e8 100644 --- a/devproject/settings.py +++ b/devproject/settings.py @@ -279,6 +279,11 @@ # Misago specific settings # https://misago.readthedocs.io/en/latest/developers/settings.html +# Use Django's FileResponse to serve attachments in dev server + +MISAGO_ATTACHMENTS_SERVER = "misago.attachments.servers.django_file_response" + + # On dev instance, generate only three sizes of avatars instead of default 6 sizes. MISAGO_AVATARS_SIZES = [400, 200, 100] diff --git a/devproject/test_settings.py b/devproject/test_settings.py index a605a4ba4e..d4117e2d14 100644 --- a/devproject/test_settings.py +++ b/devproject/test_settings.py @@ -32,6 +32,9 @@ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] +# Use Django's redirects for serving attachments +MISAGO_ATTACHMENTS_SERVER = "misago.attachments.servers.django_redirect_response" + # Use english search config MISAGO_SEARCH_CONFIG = "english" diff --git a/frontend/src/animations.js b/frontend/src/animations.js new file mode 100644 index 0000000000..12155a080b --- /dev/null +++ b/frontend/src/animations.js @@ -0,0 +1,21 @@ +export function deleteElement(element, callback) { + element.classList.remove("animation-fade-in") + + element + .querySelectorAll("button, input, select, textarea") + .forEach((child) => { + child.setAttribute("disabled", "") + }) + + element.addEventListener("animationend", ({ animationName }) => { + if (animationName === "deleteElementAnimation") { + element.remove() + + if (callback) { + callback() + } + } + }) + + element.classList.add("animation-delete") +} diff --git a/frontend/src/csrf.js b/frontend/src/csrf.js new file mode 100644 index 0000000000..dce07d9a8a --- /dev/null +++ b/frontend/src/csrf.js @@ -0,0 +1,17 @@ +export const CSRF_FIELD_NAME = "csrfmiddlewaretoken" + +export function getCSRFToken() { + const element = document.querySelector( + 'input[name="' + CSRF_FIELD_NAME + '"]' + ) + + if (element) { + return element.value + } + + return null +} + +export function appendCSRFTokenToForm(form) { + form.append(CSRF_FIELD_NAME, getCSRFToken()) +} diff --git a/frontend/src/editor/editor.js b/frontend/src/editor/editor.js index d325b7b88d..5d27a80b58 100644 --- a/frontend/src/editor/editor.js +++ b/frontend/src/editor/editor.js @@ -1,4 +1,6 @@ import htmx from "htmx.org" +import * as animations from "../animations" +import MarkupEditorUploader from "./uploader" import { MarkupEditorCodeModal, MarkupEditorImageModal, @@ -118,7 +120,7 @@ class MarkupEditor { const toolbar = element.querySelector(".markup-editor-toolbar-left") let btnWidth = 0 - element.querySelectorAll("button").forEach((child) => { + toolbar.querySelectorAll("button").forEach((child) => { let childWidth = child.clientWidth const childStyle = window.getComputedStyle(child) childWidth += parseInt(parseFloat(childStyle.borderLeftWidth)) @@ -148,6 +150,8 @@ class MarkupEditor { this._setEditorActive(element) this._setEditorFocus(element) this._setEditorActions(element) + this._setEditorPasteUpload(element) + this._setEditorDropUpload(element) this._resizeEditor(element) } } @@ -157,35 +161,51 @@ class MarkupEditor { } _setEditorFocus(element) { - element.addEventListener("focusin", () => { - element.classList.add("markup-editor-focused") + const focusEvents = ["focusin", "click"] + const className = "markup-editor-focused" + + focusEvents.forEach((event) => { + element.addEventListener(event, () => { + element.classList.add(className) + }) }) - element.addEventListener("focusout", () => { - element.classList.remove("markup-editor-focused") + focusEvents.forEach((event) => { + document.addEventListener(event, (event) => { + if (!element.contains(event.target)) { + element.classList.remove(className) + } + }) }) } _setEditorActions(element) { - const input = element.querySelector("textarea") + const textarea = this.getTextarea(element) - element.querySelectorAll("[misago-editor-action]").forEach((control) => { - const actionName = control.getAttribute("misago-editor-action") - control.addEventListener("click", (event) => { - event.preventDefault() + element.addEventListener("click", (event) => { + const target = event.target.closest("[misago-editor-action]") + if (!target) { + return null + } - const action = this.actions[actionName] - if (action) { - action({ - input, - target: event.target, - editor: this, - selection: new MarkupEditorSelection(input), - }) - } else { - console.warn("Undefined editor action: " + actionName) - } - }) + const actionName = target.getAttribute("misago-editor-action") + if (!actionName) { + return null + } + + event.preventDefault() + + const action = this.actions[actionName] + if (action) { + action({ + textarea, + target, + editor: this, + selection: new MarkupEditorSelection(textarea), + }) + } else { + console.warn("Undefined editor action: " + actionName) + } }) element @@ -202,6 +222,80 @@ class MarkupEditor { }) } + _setEditorPasteUpload = (element) => { + element.addEventListener("paste", (event) => { + const uploader = new MarkupEditorUploader(this, element) + if (!uploader.canUpload) { + uploader.showPermissionDeniedError() + } else if (event.clipboardData.files) { + event.preventDefault() + const textarea = event.target.closest("textarea") + uploader.uploadFiles(event.clipboardData.files, textarea) + } + }) + } + + _setEditorDropUpload = (element) => { + const className = "markup-editor-drag-drop" + const elements = [element.querySelector("textarea")] + + const attachments = element.querySelector("[misago-editor-attachments]") + if (attachments) { + elements.push(attachments) + } + + elements.forEach((child) => { + child.addEventListener("drop", (event) => { + const uploader = new MarkupEditorUploader(this, element) + if (event.dataTransfer.files) { + event.preventDefault() + if (!uploader.canUpload) { + uploader.showPermissionDeniedError() + } else if (event.dataTransfer.files) { + const textarea = event.target.closest("textarea") + uploader.uploadFiles(event.dataTransfer.files, textarea) + } + } + + child.classList.remove(className) + }) + + child.addEventListener("dragenter", (event) => { + event.preventDefault() + }) + + child.addEventListener("dragleave", () => { + child.classList.remove(className) + }) + + child.addEventListener("dragover", (event) => { + child.classList.add(className) + event.preventDefault() + }) + }) + } + + getTextarea(element) { + return element.querySelector("textarea") + } + + getSelection(textarea) { + return new MarkupEditorSelection(textarea) + } + + showFilePrompt(element, options) { + const uploader = new MarkupEditorUploader( + this, + element.closest("[misago-editor-active]") + ) + + if (!uploader.canUpload) { + uploader.showPermissionDeniedError() + } else { + uploader.prompt(options) + } + } + showCodeModal(selection) { this.codeModal.show(selection) } @@ -217,11 +311,26 @@ class MarkupEditor { showQuoteModal(selection) { this.quoteModal.show(selection) } + + getAttachmentByKey(key) { + return document.querySelector('[misago-editor-upload-key="' + key + '"]') + } + + removeAttachmentElement(element) { + const list = element.closest("ul") + + animations.deleteElement(element, function () { + if (!list.querySelector("li")) { + const container = list.closest(".markup-editor-attachments-list") + container.classList.add("d-none") + } + }) + } } class MarkupEditorSelection { - constructor(input) { - this.input = input + constructor(textarea) { + this.textarea = textarea this._range = this._getRange() } @@ -254,7 +363,7 @@ class MarkupEditorSelection { value += prefix + this._range.text + suffix value += this._range.suffix - this.input.value = value + this.textarea.value = value this._range.start += prefix.length this._range.end += prefix.length @@ -265,7 +374,7 @@ class MarkupEditorSelection { replace(text, options) { const value = this._range.prefix + text + this._range.suffix - this.input.value = value + this.textarea.value = value if (options && options.start) { this._range.start += options.start @@ -287,35 +396,92 @@ class MarkupEditorSelection { this.refocus() } + insert(text, options) { + const whitespace = (options && options.whitespace) || "" + + const prefix = whitespace + ? this._range.prefix.trimEnd() + : this._range.prefix + const suffix = whitespace ? this._range.suffix.trim() : this._range.suffix + + let whitespaces = 1 + let value = prefix + + if (prefix.length && whitespace) { + value += whitespace + whitespaces += 1 + } + + value += text + whitespace + suffix + this.textarea.value = value + + const caret = prefix.length + text.length + whitespace.length * whitespaces + this._range.end = this._range.start = caret + this._range.length = 0 + + this.refocus() + } + + replaceAttachments(callback) { + this.textarea.value = this.textarea.value.replace( + //gi, + function (match, p1) { + if (p1.match(/:/g).length !== 1) { + return match + } + + let value = p1.trim() + while (value.substring(0, 1) === '"') { + value = value.substring(1) + } + while (value.substring(value.length - 1) === '"') { + value = value.substring(0, value.length - 1) + } + + const name = value.substring(0, value.indexOf(":")).trim() + const id = value.substring(value.indexOf(":") + 1).trim() + + if ((name, id)) { + const result = callback({ match, name, id }) + if (typeof result === "string" || result instanceof String) { + return result + } + } + + return match + } + ) + } + refocus() { window.setTimeout(() => { - const scroll = this.input.scrollTop - this.input.focus() - this.input.scrollTop = scroll + const scroll = this.textarea.scrollTop + this.textarea.focus() + this.textarea.scrollTop = scroll const caret = this._range.start - this.input.setSelectionRange(caret, caret + this._range.length) + this.textarea.setSelectionRange(caret, caret + this._range.length) }, 250) } _getRange() { if (document.selection) { - this.input.focus() + this.textarea.focus() const range = document.selection.createRange() const length = range.text.length - range.moveStart("character", -this.input.value.length) + range.moveStart("character", -this.textarea.value.length) return this._createRange( - this.input, + this.textarea, range.text.length - length, range.text.length ) } - if (this.input.selectionStart || this.input.selectionStart == "0") { + if (this.textarea.selectionStart || this.textarea.selectionStart == "0") { return this._createRange( - this.input, - this.input.selectionStart, - this.input.selectionEnd + this.textarea, + this.textarea.selectionStart, + this.textarea.selectionEnd ) } } @@ -370,7 +536,7 @@ editor.setAction("strikethrough", function ({ selection }) { }) editor.setAction("horizontal-ruler", function ({ selection }) { - selection.replace("\n\n- - -\n\n", { start: 9 }) + selection.insert("- - -", { whitespace: "\n\n" }) }) editor.setAction("link", function ({ editor, selection }) { @@ -405,6 +571,74 @@ editor.setAction("code", function ({ editor, selection }) { editor.showCodeModal(selection) }) +editor.setAction("attachment", function ({ target, selection }) { + const attachment = target.getAttribute("misago-editor-attachment") + if (attachment) { + selection.insert("", { whitespace: "\n\n" }) + } +}) + +editor.setAction("image-upload", function ({ editor, target }) { + editor.showFilePrompt(target, { accept: "image", insert: true }) +}) + +editor.setAction("attachment-upload", function ({ editor, target }) { + editor.showFilePrompt(target) +}) + +editor.setAction("attachment-delete", function ({ editor, target, selection }) { + const attachment = target.getAttribute("misago-editor-attachment") + const name = target + .closest("[misago-editor-deleted-attachments-name]") + .getAttribute("misago-editor-deleted-attachments-name") + + selection.replaceAttachments(function ({ id }) { + if (id === attachment) { + return "" + } + + return false + }) + + const element = target.closest("li") + editor.removeAttachmentElement(element) + + const input = document.createElement("input") + input.setAttribute("type", "hidden") + input.setAttribute("name", name) + input.setAttribute("value", attachment) + + const attachments = target.closest("[misago-editor-attachments]") + attachments.appendChild(input) +}) + +editor.setAction( + "attachment-error-dismiss", + function ({ editor, target, selection }) { + const key = target.getAttribute("misago-editor-attachment-key") + + selection.replaceAttachments(function (attachment) { + if (attachment.id === key) { + return "" + } + + return false + }) + + const message = document.querySelector( + '[misago-editor-attachment-error="' + key + '"]' + ) + if (message) { + animations.deleteElement(message) + } + + const attachment = editor.getAttachmentByKey(key) + if (attachment) { + editor.removeAttachmentElement(attachment) + } + } +) + editor.setAction("formatting-help", function ({ target }) { const modal = document.getElementById("markup-editor-formatting-help") const element = target.closest("a") diff --git a/frontend/src/editor/modals.js b/frontend/src/editor/modals.js index 1934dbf207..39ca6ff259 100644 --- a/frontend/src/editor/modals.js +++ b/frontend/src/editor/modals.js @@ -6,8 +6,10 @@ class MarkupEditorModal { document.addEventListener("DOMContentLoaded", () => { this.element = this.getElement() - this.form = this.element.querySelector("form") - this.initForm(this.form) + if (this.element) { + this.form = this.element.querySelector("form") + this.initForm(this.form) + } }) } diff --git a/frontend/src/editor/uploader.js b/frontend/src/editor/uploader.js new file mode 100644 index 0000000000..547eb4e796 --- /dev/null +++ b/frontend/src/editor/uploader.js @@ -0,0 +1,427 @@ +import { appendCSRFTokenToForm } from "../csrf" +import getRandomString from "../getRandomString" +import renderTemplate from "../renderTemplate" +import * as snackbar from "../snackbars" + +export default class MarkupEditorUploader { + constructor(editor, element) { + this.editor = editor + + this.element = element + this.textarea = editor.getTextarea(element) + + this.lists = { + media: element.querySelector('[misago-editor-attachments="media"]'), + other: element.querySelector('[misago-editor-attachments="other"]'), + } + + this.templates = { + error: document.getElementById("attachment-upload-error-template"), + media: document.getElementById("attachment-media-template"), + mediaFooter: document.getElementById("attachment-media-footer-template"), + mediaFailedFooter: document.getElementById( + "attachment-media-failed-footer-template" + ), + other: document.getElementById("attachment-other-template"), + otherFailed: document.getElementById("attachment-other-failed-template"), + otherUploaded: document.getElementById( + "attachment-other-uploaded-template" + ), + } + + const attachmentsElement = element.querySelector( + "[misago-editor-attachments]" + ) + this.field = { + name: element.getAttribute("misago-editor-attachments-name"), + element: attachmentsElement, + } + + this.accept = { + all: this._getAcceptedExtensions( + attachmentsElement.getAttribute("misago-editor-accept-attachments") + ), + image: this._getAcceptedExtensions( + attachmentsElement.getAttribute("misago-editor-accept-image") + ), + video: this._getAcceptedExtensions( + attachmentsElement.getAttribute("misago-editor-accept-video") + ), + } + + this.uploadUrl = this._getUploadUrl(element) + this.canUpload = !!this.uploadUrl && !!this.accept.all + } + + _getUploadUrl(element) { + return element.getAttribute("misago-editor-attachments-url") || null + } + + _getAcceptedExtensions(extensions) { + return extensions.split(",").map((item) => item.trim()) + } + + showPermissionDeniedError() { + snackbar.error( + pgettext("markup editor upload", "You can't upload attachments") + ) + } + + prompt(options) { + const accept = options ? options.accept : "all" + const insert = options ? options.insert : false + + const input = document.createElement("input") + input.setAttribute("type", "file") + input.setAttribute( + "accept", + (this.accept[accept] || this.accept.all).join(",") + ) + input.setAttribute("multiple", true) + input.classList.add("d-none") + + input.addEventListener("change", (event) => { + const files = event.target.files + if (files.length) { + this.uploadFiles(files, insert) + } + input.remove() + }) + + this.element.appendChild(input) + input.click() + } + + _getAcceptAttributeStr(accept) { + return (this.accept[accept] || this.accept.all).join(",") + } + + uploadFiles(files, insert) { + const allowedFiles = [] + + for (let i = 0; i < files.length; i++) { + const file = files[i] + if (this._isFileTypeAccepted(file)) { + allowedFiles.push(file) + } else { + snackbar.error( + pgettext( + "markup editor upload", + "%(name)s: uploaded file type is not allowed." + ).replace("%(name)s", file.name) + ) + } + } + + if (!allowedFiles) { + return { keys: [], files: [] } + } + + const keys = [] + const elements = {} + + const data = new FormData() + appendCSRFTokenToForm(data) + + allowedFiles.forEach((file) => { + const key = getRandomString(16) + keys.push(key) + data.append("keys", key) + data.append("upload", file) + + elements[key] = this._createUploadUI(file, key) + }) + + if (insert) { + this._insertAttachmentsInTextarea(keys, allowedFiles) + } + + const request = new XMLHttpRequest() + + this._addOnLoadedEventListener(request, keys, elements) + this._addOnProgressEventListener(request, keys, elements) + + request.open("POST", this.uploadUrl) + request.send(data) + + return { keys, files: allowedFiles } + } + + _isFileTypeAccepted(file) { + return this._isFileTypeInList(file, this.accept.all) + } + + _isFileTypeImage(file) { + return this._isFileTypeInList(file, this.accept.image) + } + + _isFileTypeVideo(file) { + return this._isFileTypeInList(file, this.accept.video) + } + + _isFileTypeInList(file, list) { + const name = file.name.toLowerCase() + for (const extension of list) { + if (name.substring(name.length - extension.length) === extension) { + return true + } + } + return false + } + + _insertAttachmentsInTextarea(keys, files) { + const markup = [] + + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const file = files[i] + markup.push("") + } + + if (markup) { + const selection = this.editor.getSelection(this.textarea) + selection.insert(markup.join("\n"), { whitespace: "\n\n" }) + } + } + + _addOnLoadedEventListener(request, keys, elements) { + request.addEventListener("loadend", () => { + if (request.readyState === XMLHttpRequest.DONE) { + if (request.status === 200) { + this._handleUploadSuccess(request, keys, elements) + } else { + this._handleUploadError(request, keys) + } + + keys.forEach((key) => (elements[key] = null)) + } + }) + } + + _handleUploadSuccess(request, keys, elements) { + try { + const { attachments, errors } = JSON.parse(request.response) + + this._replaceTextareaPlaceholders(this.textarea, keys, attachments) + + if (errors) { + const helpText = this.element.querySelector( + "[misago-editor-attachments-help]" + ) + keys.forEach((key) => { + const error = errors[key] + if (error) { + this._updateUploadUIWithError(key, helpText, error) + } + }) + } + + if (attachments) { + attachments.forEach((attachment) => { + try { + this._updateUploadUI(attachment, elements[attachment.key]) + this._createAttachmentIDField(attachment) + } catch (error) { + console.error(error) + } + }) + } + + } catch (error) { + snackbar.error( + pgettext("markup editor upload", "Unexpected upload API response") + ) + console.error(error) + } + } + + _handleUploadError(request, keys) { + if (request.status === 0) { + snackbar.error( + pgettext("markup editor upload", "Site could not be reached") + ) + } else if (request.status >= 400 && request.status < 500) { + try { + snackbar.error(JSON.parse(request.response).error) + } catch (error) { + snackbar.error( + pgettext( + "markup editor upload", + "Unexpected upload API error response" + ) + ) + console.error(error) + } + } else { + snackbar.error( + pgettext("markup editor upload", "Unexpected error during upload") + ) + } + + keys.forEach(key => { + this._updateUploadUIWithError(key) + }) + } + + _addOnProgressEventListener(request, keys, elements) { + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + const progress = Math.ceil((event.loaded * 100) / event.total) + for (const key of keys) { + const progressBar = elements[key].querySelector(".progress-bar") + progressBar.setAttribute("aria-valuenow", progress) + progressBar.style.width = progress + "%" + } + } + }) + } + + _replaceTextareaPlaceholders(textarea, keys, attachments) { + const results = {} + keys.forEach((key) => (results[key] = null)) + if (attachments) { + attachments.forEach( + (attachment) => (results[attachment.key] = attachment) + ) + } + + const selection = this.editor.getSelection(textarea) + selection.replaceAttachments(function ({ id: key }) { + const attachment = results[key] + if (attachment) { + return "" + } else if (attachment === null) { + return "" + } + }) + } + + _createUploadUI(file, key) { + const data = { + key, + file, + name: file.name, + isImage: false, + isVideo: false, + } + + if (this._isFileTypeImage(file)) { + data.isImage = true + this._createMediaUploadUI(data) + } else if (this._isFileTypeVideo(file)) { + data.isVideo = true + this._createMediaUploadUI(data) + } else { + this._createOtherUploadUI(data) + } + + return this.editor.getAttachmentByKey(key) + } + + _createMediaUploadUI(data) { + const element = renderTemplate(this.templates.media, data) + + if (data.isImage) { + const image = element.querySelector("[misago-tpl-image]") + const buffer = new FileReader() + buffer.onload = () => { + image.style.backgroundImage = "url('" + buffer.result + "')" + } + buffer.readAsDataURL(data.file) + } else if (data.isVideo) { + const video = element.querySelector("video") + const buffer = new FileReader() + buffer.onload = () => { + const source = document.createElement("source") + source.setAttribute("src", buffer.result) + source.setAttribute("type", data.file.type) + + video.append(source) + } + buffer.readAsDataURL(data.file) + } + + this._addUIToAttachmentsList(this.lists.media, element) + } + + _createOtherUploadUI(data) { + const element = renderTemplate(this.templates.other, data) + this._addUIToAttachmentsList(this.lists.other, element) + } + + _addUIToAttachmentsList(list, element) { + list.querySelector("ul").prepend(element) + list.classList.remove("d-none") + } + + _createAttachmentIDField(attachment) { + const input = document.createElement("input") + input.setAttribute("type", "hidden") + input.setAttribute("name", this.field.name) + input.setAttribute("value", attachment.id) + this.field.element.appendChild(input) + } + + _updateUploadUI(attachment, element) { + if (attachment.filetype["is_media"]) { + this._updateMediaUploadUI(attachment, element) + } else { + this._updateOtherUploadUI(attachment, element) + } + } + + _updateMediaUploadUI(attachment, element) { + const footer = renderTemplate(this.templates.mediaFooter, attachment) + + footer + .querySelector("[misago-editor-attachment]") + .setAttribute( + "misago-editor-attachment", + attachment.name + ":" + attachment.id + ) + + element.querySelector("[misago-tpl-footer]").replaceWith(footer) + } + + _updateOtherUploadUI(attachment, element) { + const item = renderTemplate(this.templates.otherUploaded, attachment) + + item + .querySelector("[misago-editor-attachment]") + .setAttribute( + "misago-editor-attachment", + attachment.name + ":" + attachment.id + ) + + element.replaceWith(item) + } + + _updateUploadUIWithError(key, helpText, error) { + if (error) { + this._createErrorMessage(key, error, helpText) + } + + const attachment = this.editor.getAttachmentByKey(key) + const footer = attachment.querySelector("[misago-tpl-footer]") + if (footer) { + // Media attachment + const template = renderTemplate(this.templates.mediaFailedFooter, { key }) + footer.replaceWith(template) + } else { + // Other attachment + const upload = attachment.querySelector("[misago-tpl-upload]") + const button = upload.closest("li").querySelector("button") + + button.setAttribute("misago-editor-action", "attachment-error-dismiss") + button.setAttribute("misago-editor-attachment-key", key) + button.removeAttribute("disabled") + + upload.replaceWith(renderTemplate(this.templates.otherFailed)) + } + } + + _createErrorMessage(key, error, insertAfter) { + const errorTemplate = renderTemplate(this.templates.error, { key, error }) + insertAfter.after(errorTemplate) + } +} diff --git a/frontend/src/getRandomString.js b/frontend/src/getRandomString.js new file mode 100644 index 0000000000..2661a8eeb2 --- /dev/null +++ b/frontend/src/getRandomString.js @@ -0,0 +1,11 @@ +const ALPHABET = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +const ALPHABET_LENGTH = ALPHABET.length + +export default function getRandomString(length) { + let string = "" + for (let i = 0; i < length; i++) { + string += ALPHABET[Math.floor(Math.random() * ALPHABET_LENGTH)] + } + return string +} diff --git a/frontend/src/htmxErrors.js b/frontend/src/htmxErrors.js index 237360221e..71cf75c96c 100644 --- a/frontend/src/htmxErrors.js +++ b/frontend/src/htmxErrors.js @@ -28,10 +28,7 @@ function getResponseErrorMessage(xhr) { function handleSendError(event) { if (isEventVisible(event)) { - const message = pgettext( - "htmx response error", - "Site could not be reached." - ) + const message = pgettext("htmx response error", "Site could not be reached") error(message) } } diff --git a/frontend/src/index.js b/frontend/src/index.js index 462b84a83b..cff58ac131 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -131,3 +131,11 @@ document.addEventListener("htmx:load", activateEditors) document.addEventListener("misago:afterModeration", () => { $("#threads-moderation-modal").modal("hide") }) + +// Custom misago-confirm attribute +document.addEventListener("submit", function (event) { + const element = event.target.closest("form[misago-confirm]") + if (!!element && !window.confirm(element.getAttribute("misago-confirm"))) { + event.preventDefault() + } +}) diff --git a/frontend/src/renderTemplate.js b/frontend/src/renderTemplate.js new file mode 100644 index 0000000000..c0e49c74e5 --- /dev/null +++ b/frontend/src/renderTemplate.js @@ -0,0 +1,59 @@ +export default function renderTemplate(template, data) { + const node = template.content.cloneNode(true) + + node.querySelectorAll("[misago-tpl-if]").forEach((element) => { + const variable = element.getAttribute("misago-tpl-if") + if (getVariableValue(data, variable)) { + element.removeAttribute("misago-tpl-if") + } else { + element.remove() + } + }) + + node.querySelectorAll("[misago-tpl-ifnot]").forEach((element) => { + const variable = element.getAttribute("misago-tpl-ifnot") + if (getVariableValue(data, variable)) { + element.remove() + } else { + element.removeAttribute("misago-tpl-ifnot") + } + }) + + node.querySelectorAll("[misago-tpl-var]").forEach((element) => { + const variable = element.getAttribute("misago-tpl-var") + element.innerText = getVariableValue(data, variable) || "" + element.removeAttribute("misago-tpl-var") + }) + + node.querySelectorAll("[misago-tpl-attr]").forEach((element) => { + const attr = element.getAttribute("misago-tpl-attr") + if (attr.indexOf(":") !== ":") { + const name = attr.substring(0, attr.indexOf(":")).trim() + const variable = attr.substring(attr.indexOf(":") + 1).trim() + const value = variable ? getVariableValue(data, variable) : undefined + + if (name && value) { + element.setAttribute(name, value) + } + } + element.removeAttribute("misago-tpl-attr") + }) + + return node +} + +function getVariableValue(data, variable) { + if (variable.indexOf(".") === -1) { + return data[variable] + } else { + let value = data + for (const part of variable.split(".")) { + if (part && typeof value[part] !== "undefined") { + value = value[part] + } else { + return undefined + } + } + return value + } +} diff --git a/frontend/src/style/flavor/typo.less b/frontend/src/style/flavor/typo.less index 44ed97bf21..ca4e0d5409 100644 --- a/frontend/src/style/flavor/typo.less +++ b/frontend/src/style/flavor/typo.less @@ -6,3 +6,18 @@ abbr { outline: none; text-decoration: none; } + +.text-danger { + color: @state-danger-text !important; +} + +.text-help-block { + color: @gray-light !important; + + a, + a:link, + a:visited { + color: darken(@gray-light, 5); + text-decoration: underline; + } +} diff --git a/frontend/src/style/img-bg.png b/frontend/src/style/img-bg.png new file mode 100644 index 0000000000..9462e513b3 Binary files /dev/null and b/frontend/src/style/img-bg.png differ diff --git a/frontend/src/style/index.less b/frontend/src/style/index.less index 3ece480e50..acb1cf092f 100644 --- a/frontend/src/style/index.less +++ b/frontend/src/style/index.less @@ -62,6 +62,7 @@ // -------------------------------------------------- // Core CSS +@import "misago/animations.less"; @import "misago/scaffolding.less"; @import "misago/flex-row.less"; @import "misago/display.less"; @@ -96,6 +97,7 @@ @import "misago/page-header.less"; @import "misago/page-container.less"; @import "misago/panels.less"; +@import "misago/rich-text.less"; @import "misago/footer.less"; @import "misago/moderation.less"; @import "misago/ui-preview.less"; @@ -106,7 +108,6 @@ @import "misago/user-card.less"; @import "misago/toolbar.less"; @import "misago/type.less"; -@import "misago/markup.less"; @import "misago/formatting-help.less"; @import "misago/scroll-target.less"; @@ -133,6 +134,8 @@ @import "misago/auth-pages.less"; @import "misago/message-pages.less"; @import "misago/threads-lists.less"; +@import "misago/attachment-list.less"; +@import "misago/attachment-details.less"; @import "misago/notifications-list.less"; @import "misago/notifications-dropdown.less"; @import "misago/notifications-overlay.less"; diff --git a/frontend/src/style/misago/account-settings.less b/frontend/src/style/misago/account-settings.less index 38114a01cf..27faf6d538 100644 --- a/frontend/src/style/misago/account-settings.less +++ b/frontend/src/style/misago/account-settings.less @@ -47,3 +47,74 @@ .table-watching-option .material-icon { font-size: 18px; } + +.panel-attachments-usage { + h4 { + margin: 0; + } + + .progress { + margin: @line-height-computed 0; + } + + .progress-bar-locked { + background-color: #434343; + } +} + +.attachment-usage-legend { + margin: -5px -5px floor(@line-height-computed / 2) -5px; + padding: 0; + + list-style: none; + + li { + display: inline-block; + margin: 5px; + } +} + +.attachment-usage-legend-item { + display: inline-block; + + width: 10px; + height: 10px; + + background-color: @gray-lighter; + border-radius: 100%; + vertical-align: baseline; +} + +.attachment-usage-exceeded { + background-color: @brand-danger; +} + +.attachment-usage-posted { + background-color: @brand-primary; +} + +.attachment-usage-unused { + background-color: @brand-warning; +} + +.attachment-usage-help-text { + position: relative; + + padding-left: 15px; + + color: @gray; + + &:last-child { + margin: 0; + } + + .attachment-usage-legend-item { + position: absolute; + left: 0; + top: 5px; + } + + strong { + color: @text-color; + } +} diff --git a/frontend/src/style/misago/animations.less b/frontend/src/style/misago/animations.less new file mode 100644 index 0000000000..ab6e835edf --- /dev/null +++ b/frontend/src/style/misago/animations.less @@ -0,0 +1,30 @@ +@keyframes deleteElementAnimation { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.animation-delete { + animation-name: deleteElementAnimation; + animation-duration: 0.3s; + animation-iteration-count: 1; + opacity: 0; +} + +@keyframes fadeInElementAnimation { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animation-fade-in { + animation-name: fadeInElementAnimation; + animation-duration: 0.3s; + animation-iteration-count: 1; +} diff --git a/frontend/src/style/misago/attachment-details.less b/frontend/src/style/misago/attachment-details.less new file mode 100644 index 0000000000..57b421f924 --- /dev/null +++ b/frontend/src/style/misago/attachment-details.less @@ -0,0 +1,140 @@ +.attachment-details-video, +.attachment-details-image { + background-color: #1f1f1f; + + img, + video { + display: block; + max-width: 100%; + height: auto; + + margin: 0 auto; + padding: 0; + } +} + +@media screen and (max-width: @screen-xs-max) { + .attachment-video-container { + padding: 0; + } +} + +.attachment-details-image, +.attachment-details-image-sm { + img { + background-image: url("../img-bg.png"); + background-repeat: repeat; + } +} + +.attachment-details-image-sm { + display: flex; + justify-content: center; + + a { + display: block; + + margin: @line-height-computed auto; + padding: 4px; + + background-color: #fff; + border: 1px solid #ccc; + } +} + +.attachment-details-file { + margin: @line-height-computed * 2 0; + + .btn { + display: flex; + flex-direction: column; + width: 180px; + height: 180px; + justify-content: center; + + margin: 0 auto; + padding: @padding-xs-horizontal; + + background-color: @body-bg; + border: 1px solid @gray-lighter; + border-radius: @border-radius-base; + color: @text-color; + text-wrap-mode: wrap; + + span, + strong, + small { + display: block; + } + + .material-icon { + width: 64px; + height: 64px; + + margin: 0 auto; + margin-bottom: floor(@line-height-computed / 2); + + color: @gray-light; + font-size: 64px; + line-height: 64px; + } + + strong { + word-break: break-all; + } + + small { + margin-top: floor(@line-height-computed / 4); + } + } +} + +.panel-attachment-details { + .panel-title { + color: @text-color; + } + + .panel-body { + padding-top: 0; + padding-bottom: 0; + } + + .col-xs-12 { + padding: @panel-body-padding; + + border-bottom: 1px solid @gray-lighter; + } + + .item-link { + &, + &:link, + &:visited { + color: @gray-dark; + text-decoration: none; + } + + &:hover, + &:focus, + &:active { + color: @text-color; + text-decoration: underline; + } + } +} + +@media screen and (min-width: @screen-md-min) { + .panel-attachment-details { + .panel-body { + padding: 0; + } + + .row { + margin: 0; + } + + .col-xs-12 { + padding-left: 0; + padding-right: 0; + } + } +} diff --git a/frontend/src/style/misago/attachment-list.less b/frontend/src/style/misago/attachment-list.less new file mode 100644 index 0000000000..930ba17cf4 --- /dev/null +++ b/frontend/src/style/misago/attachment-list.less @@ -0,0 +1,162 @@ +.attachment-list-item { + display: flex; + align-items: start; +} + +.attachment-list-item-download-btn { + display: block; + flex: 1; + + padding: 2px; + + border: 1px solid @gray-lighter; + border-radius: 4px; + + &, + &:link, + &:hover, + &:focus, + &:active, + &:visited { + color: darken(@gray-lighter, 25); + font-size: 32px; + line-height: 32px; + text-decoration: none; + } + + &:hover, + &:focus, + &:active { + border-color: darken(@gray-lighter, 15); + } +} + +.attachment-list-item-icon, +.attachment-list-item-icon-broken { + display: flex; + align-items: center; + justify-content: center; + + width: 40px; + height: 40px; + + background-color: @gray-lighter; + border-radius: 2px; +} + +.attachment-list-item-icon-broken { + color: #ee6e6c; + background-color: #fae3e2; +} + +.attachment-list-item-image { + width: 40px; + height: 40px; + + background-size: cover; + background-repeat: no-repeat; + background-position: center; + border-radius: 2px; +} + +.attachment-list-item-body { + width: 100%; + + margin-left: 15px; +} + +.attachment-list-item-name { + word-break: break-word; + + s { + color: @gray; + font-weight: bold; + } + + a, + a:link, + a:hover, + a:focus, + a:active, + a:visited { + color: @text-color; + font-weight: bold; + } +} + +.attachment-list-item-details { + display: block; + + margin: 2px -4px -2px -4px; + padding: 0; + + color: @gray-light; + list-style-image: none; + + li { + display: inline-block; + + margin: 2px 4px; + } +} + +.attachment-list-item-thread { + word-break: break-word; + + &, + &:link, + &:hover, + &:focus, + &:active, + &:visited { + color: @gray-darker; + } +} + +.attachment-list-item-delete-btn { + .btn { + display: flex; + align-items: center; + justify-content: center; + + position: relative; + left: 7px; + + width: 28px; + height: 28px; + padding: 0; + + color: darken(@gray-lighter, 35); + } +} + +@media screen and (min-width: @screen-sm-min) { + .attachment-list-item { + align-items: center; + } + + .attachment-list-item-icon, + .attachment-list-item-icon-broken, + .attachment-list-item-image { + width: 64px; + height: 64px; + } + + .attachment-list-item-delete-btn { + margin-left: 15px; + + .btn { + position: static; + + width: 32px; + height: 32px; + } + } +} + +.attachment-list-blankslate { + padding: 30px 15px; + + font-size: @font-size-large; + text-align: center; +} diff --git a/frontend/src/style/misago/inputs.less b/frontend/src/style/misago/inputs.less index c5f40bb40e..138363ab11 100644 --- a/frontend/src/style/misago/inputs.less +++ b/frontend/src/style/misago/inputs.less @@ -76,27 +76,16 @@ input.hidden-file-upload { .form-control-post { resize: none; min-height: 200px; - - &.form-control-post-lg { - min-height: 300px; - } } -@media screen and (min-height: 600px) { - .form-control-post { - field-sizing: content; - resize: vertical; - - &.form-control-post-lg { - min-height: 340px; - } +@media screen and (min-height: 800px) { + .form-control-post.form-control-post-lg { + min-height: 300px; } } -@media screen and (min-height: 760px) { - .form-control-post { - &.form-control-post-lg { - min-height: 500px; - } +@media screen and (min-height: 1000px) { + .form-control-post.form-control-post-lg { + min-height: 400px; } } diff --git a/frontend/src/style/misago/markup-editor.less b/frontend/src/style/misago/markup-editor.less index 02ab9b9d2d..8d898fa2a1 100644 --- a/frontend/src/style/misago/markup-editor.less +++ b/frontend/src/style/misago/markup-editor.less @@ -87,23 +87,13 @@ margin-right: @line-height-computed / 2; } -// Footer -.markup-editor-footer { - display: flex; - padding: @markup-editor-padding; - - background: @markup-editor-footer-bg; - border-top: 1px solid @markup-editor-footer-border-color; -} - // Spacer that pushes items away from each other .markup-editor-spacer { flex: 1; } -// Toolbar and footer items layout -.markup-editor-toolbar, -.markup-editor-footer { +// Toolbar items layout +.markup-editor-toolbar { // Add spacing between buttons .btn + .btn, .btn + .dropdown, @@ -114,9 +104,10 @@ // Textarea .markup-editor-textarea.form-control { - height: @markup-editor-height; + min-height: @markup-editor-height; padding: @markup-editor-padding; resize: none; + field-sizing: content; border: 0; border-radius: 0; @@ -129,163 +120,355 @@ } } -// Preview area -// Relative/absolute positioning is a fix for preview area changing height -// when it's contents are too long. -.markup-editor-preview { - position: relative; - height: @markup-editor-height; - overflow-y: auto; +// Attachments +.markup-editor-attachments { + background: @markup-editor-attachments-bg; + border-top: 1px solid @markup-editor-attachments-border-color; + + .form-group { + margin: 0; + padding: @markup-editor-padding; + + .help-block:last-child { + margin-bottom: 0; + } + } } -.markup-editor-preview-contents { - position: absolute; - padding: @markup-editor-padding; +.markup-editor-attachments-noscript-row input { + margin-bottom: floor(@line-height-computed / 2); +} + +@media screen and (min-width: 400px) { + .markup-editor-attachments-noscript-row { + display: flex; + + input { + margin-right: floor(@grid-gutter-width / 2); + margin-bottom: 0; + } + + button { + width: auto; + } + } } -.markup-editor-preview-loading { +.markup-editor-attachments-script-row { + font-weight: bold; + + button { + padding: 0; + + background: transparent; + border: 0; + text-decoration: underline; + } +} + +.markup-editor-attachments-header { + margin: 0; + padding: 0 @markup-editor-padding-horizontal; + + font-size: @font-size-base; + font-weight: bold; +} + +.markup-editor-attachments-error-dismiss-button { + margin-left: 4px; + padding: 0; + + background-color: transparent; + border: 0; + + &:hover { + text-decoration: underline; + } +} + +.markup-editor-attachments-media-list, +.markup-editor-attachments-other-list { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: floor(@grid-gutter-width / 2); + + margin: 0; padding: @markup-editor-padding; + + list-style: none; + + li { + margin: 0; + + border: 1px solid @gray-lighter; + border-radius: @border-radius-base; + } } -// Attachments -.markup-editor-attachments { - max-height: @markup-editor-attachments-max-height; - overflow-y: auto; +.markup-editor-attachments-media-list-item { + display: block; + padding: 2px; } -.markup-editor-attachments-container { - padding: floor(@markup-editor-padding / 2); - overflow: auto; +.markup-editor-attachments-media-list-item-preview-video { + position: relative; + + background-color: #1e1e1e; + + .markup-editor-attachments-media-list-item-preview-video-container { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + + video { + max-width: 100%; + max-height: 100%; + } } -.markup-editor-attachments-item { - padding: floor(@markup-editor-padding / 2); +.markup-editor-attachments-media-list-item-preview-image { + background-position: center; + background-repeat: no-repeat; + background-size: cover; } -.markup-editor-attachment { - display: flex; - align-items: center; - padding: floor(@markup-editor-padding / 2); +.markup-editor-attachments-media-list-item-preview-video, +.markup-editor-attachments-media-list-item-preview-image { + margin-bottom: floor(@grid-gutter-width / 4); + + padding-bottom: 100%; - border: 1px solid @markup-editor-attachment-border-color; border-radius: @border-radius-small; + overflow: hidden; } -.markup-editor-attachment-details { - flex: 1; - white-space: nowrap; +.markup-editor-attachments-media-list-item-preview { + margin-bottom: floor(@grid-gutter-width / 4); + padding-bottom: 100%; + + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: @border-radius-small; +} + +.markup-editor-attachments-media-list-item-body { + padding: @padding-small-vertical @padding-small-horizontal; + + font-size: @font-size-small; +} + +.markup-editor-attachments-media-list-item-name { overflow: hidden; + padding: 6px 4px; + + font-size: @font-size-small; + font-weight: bold; + white-space: nowrap; text-overflow: ellipsis; +} - strong { - display: block; - } +.markup-editor-attachments-media-list-item-footer { + display: flex; + padding: 4px; +} - .list-unstyled { - margin-bottom: 0; - } +.markup-editor-attachments-media-list-item-footer-spacer { + width: 100%; } -.btn-markup-editor-attachment { - margin-left: floor(@markup-editor-padding / 2); +.markup-editor-attachments-media-list-item-button { + padding: 0 12px; + + background-color: @gray-lighter; + border: none; + border-radius: @border-radius-small; + + &:disabled { + opacity: 0.3; + } } -// Make attachments responsive -@media screen and (min-width: @screen-sm-min) { - .markup-editor-attachments-item { - width: 50%; - float: left; +.markup-editor-attachments-media-list-item-delete-button { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 28px; + height: 28px; + padding: 0; + + .material-icon { + font-size: 16px; + line-height: 16px; } } -@media screen and (min-width: @screen-md-min) { - .markup-editor-attachments-item { - width: 25%; +.markup-editor-attachments-media-list-item-upload-progress { + display: flex; + align-items: center; + + height: 36px; + padding: 0 4px; + + .progress { + width: 100%; + margin: 0; } } -// Center attachment's image in modal -.markup-editor-attachment-modal-preview { - margin: @line-height-computed 0 @line-height-computed * 2 0; +.markup-editor-attachments-media-list-item-failed-message { + display: flex; + align-items: center; + width: 100%; - text-align: center; + color: darken(@brand-danger, 30); + font-size: @font-size-small; - a { - display: inline-block; - padding: @padding-base-horizontal; + .material-icon { + margin-right: 4px; - background: #fff; - border: 1px solid @gray-lighter; + font-size: 18px; } +} - img { - max-width: 100%; - max-height: 50vh; +.markup-editor-attachments-other-list-item { + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + min-width: 0; + height: 100%; + + padding-right: 6px; +} + +.markup-editor-attachments-other-list-item-body { + width: calc(100% - 36px); + padding: 2px 6px; + + background: transparent; + border: 0; + text-align: left; + + cursor: default; + + &:disabled { + cursor: default !important; } } -.markup-editor-attachment-modal-filename { - font-size: @font-size-base * 2; +.markup-editor-attachments-other-list-item-name { + overflow: hidden; + font-weight: bold; - margin-bottom: @line-height-computed; + font-size: @font-size-small; + text-overflow: ellipsis; + white-space: nowrap; +} + +.markup-editor-attachments-other-list-item-description { + color: @gray-light; + font-size: @font-size-small; + text-overflow: ellipsis; + white-space: nowrap; } -.markup-editor-attachment-modal-details { - margin-bottom: @line-height-computed; +.markup-editor-attachments-other-list-item-cta { + margin-left: 8px; } -// Make editor take full height on full-screen mode -.posting-fullscreen .markup-editor { +.markup-editor-attachments-other-list-item-delete-button { display: flex; - flex-direction: column; - height: 100%; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 28px; + height: 28px; + padding: 0; - .markup-editor-textarea, - .markup-editor-preview { - flex: 1; - height: auto; + background-color: @gray-lighter; + border: none; + border-radius: @border-radius-small; + + &:disabled { + opacity: 0.3; + } + + .material-icon { + font-size: 16px; + line-height: 16px; } } -// Make editor take full height on mobile devices -@media screen and (max-width: @screen-sm-max) { - body.posting-default .markup-editor { - display: flex; - flex-direction: column; - height: 100%; +.markup-editor-attachments-other-list-item-upload-progress { + display: flex; + align-items: center; - .markup-editor-textarea, - .markup-editor-preview { - flex: 1; - height: 100%; - } + height: 17px; + + .progress { + width: 100%; + margin: 0; } } -// Some overrides for x-small mobile devices -@media screen and (max-width: @screen-xs-max) { - // Limit editor attachments list height on small mobile devices - .markup-editor-attachments { - max-height: @markup-editor-attachments-max-height-xs; - } +.markup-editor-attachments-other-list-item-upload-failed { + display: flex; + align-items: center; - // Make buttons in footer take whole width - .markup-editor-footer .btn-auto { - flex: 1; + height: 17px; + + color: darken(@brand-danger, 30); + font-size: @font-size-small; +} + +.misago-javascript .markup-editor-attachments-other-list-item-body { + cursor: pointer; + + &:hover, + &:active { + .markup-editor-attachments-other-list-item-cta { + color: @gray-dark; + text-decoration: underline; + } } +} - .markup-editor-footer .btn-icon { - margin-right: @line-height-computed / 2; +@media screen and (min-width: @screen-sm-min) { + .markup-editor-attachments-media-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); } - .markup-editor-spacer { - display: none; + .markup-editor-attachments-other-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } -// Hide controls dropdown on larger displays @media screen and (min-width: @screen-md-min) { - .markup-editor-controls-dropdown { - display: none; + .markup-editor-attachments-media-list { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .markup-editor-attachments-other-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); } } + +// Drag and drop focus +.markup-editor-textarea.form-control, +.markup-editor-attachments { + transition: background-color 0.5s; +} + +.markup-editor-textarea.form-control.markup-editor-drag-drop, +.markup-editor-attachments.markup-editor-drag-drop { + background-color: @markup-editor-drag-drop-bg; +} diff --git a/frontend/src/style/misago/markup.less b/frontend/src/style/misago/markup.less deleted file mode 100644 index 9e620087ac..0000000000 --- a/frontend/src/style/misago/markup.less +++ /dev/null @@ -1,171 +0,0 @@ -// -// Markup styles -// -------------------------------------------------- - -// Set font-size -.misago-markup, -.misago-markup blockquote { - font-size: @misago-markup-font-size; -} - -// Force word wrap for user text -.misago-markup { - h1, - h2, - h3, - h4, - h5, - h6, - p { - overflow-wrap: anywhere; - } -} - -// Add upper margins for headers -.misago-markup { - h1, - h2, - h3, - h4, - h5, - h6 { - margin-top: @line-height-computed * 2; - } -} - -// Even out blocks -.misago-markup > *, -.misago-markup blockquote > * { - margin: @line-height-computed 0px; - - &:first-child { - margin-top: 0px; - } - - &:last-child { - margin-bottom: 0px; - } -} - -// Downscale images so they don't expand area -.misago-markup img { - max-width: 100%; - max-height: 500px; -} - -// Make quotes stand out a little -.misago-markup .quote-block, -.misago-markup blockquote { - background-color: #f0f9ff; - border-left: 3px solid #0ea5e9; - border-radius: @border-radius-small; - overflow: hidden; - - color: #0c4a6e; -} - -.misago-markup .quote-heading { - padding: @padding-large-vertical @padding-large-horizontal; - - font-size: @font-size-base; - font-weight: bold; -} - -.misago-markup .quote-body { - border: none; - border-radius: 0; -} - -.misago-markup blockquote { - padding: @padding-large-vertical @padding-large-horizontal; - - &:last-child { - margin-bottom: 0; - } -} - -// Add extra styles to nested quotes -.misago-markup .quote-body > blockquote, -.misago-markup .quote-body > .quote-block, -.misago-markup blockquote > blockquote, -.misago-markup blockquote > .quote-block { - border: 1px solid #0ea5e9; - border-left: 3px solid #0ea5e9; -} - -// Style spoilers -.misago-markup .spoiler-block { - position: relative; - - background: repeating-linear-gradient( - 45deg, - fadeout(@gray-lighter, 50%), - fadeout(@gray-lighter, 50%) 10px, - @post-bg 10px, - @post-bg 20px - ); - border: 3px solid @gray-lighter; - border-radius: @border-radius-base; - font-size: @font-size-base; -} - -.misago-markup .spoiler-body { - background: @body-bg; - border-width: 0; - margin: 0px; - padding: @line-height-computed; -} - -.misago-markup .spoiler-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: @body-bg; -} - -.misago-markup .spoiler-block.revealed .spoiler-overlay { - display: none; -} - -// Add bullets to lists -.misago-markup ul { - list-style-type: square; - - li { - list-style-type: square; - } -} - -.misago-markup ol { - list-style-type: decimal; - - li { - list-style-type: decimal; - } -} - -// Expand code blocks a little -.misago-markup pre { - background: #eee; - border: none; - padding: @padding-large-vertical; - overflow: hidden; - - color: #000; - - code.hljs { - margin: @padding-large-vertical * -1; - padding: ((@line-height-computed - 1) / 2); - } -} - -// Align markup horizontally -.misago-markup-example { - display: flex; - align-items: center; -} diff --git a/frontend/src/style/misago/panels.less b/frontend/src/style/misago/panels.less index 680d749476..994646bdf3 100644 --- a/frontend/src/style/misago/panels.less +++ b/frontend/src/style/misago/panels.less @@ -62,3 +62,26 @@ max-width: 500px; margin: @line-height-computed auto; } + +// Responsive panel +.panel-responsive { + margin-left: floor(@grid-gutter-width / -2); + margin-right: floor(@grid-gutter-width / -2); + margin-bottom: @line-height-computed; + + .panel-heading { + border-bottom: 1px solid @panel-default-border; + } +} + +@media screen and (min-width: @screen-md-min) { + .panel-responsive { + margin-left: 0; + margin-right: 0; + + .panel-heading { + padding-left: 0; + padding-right: 0; + } + } +} diff --git a/frontend/src/style/misago/posts-feed.less b/frontend/src/style/misago/posts-feed.less index 343c41cec1..d65222c30e 100644 --- a/frontend/src/style/misago/posts-feed.less +++ b/frontend/src/style/misago/posts-feed.less @@ -169,6 +169,119 @@ padding: floor(@line-height-computed / 2) 0; } +.posts-feed-item-attachments { + margin-top: @line-height-computed; +} + +.posts-feed-item-attachments-header { + margin: 0 0 floor(@line-height-computed / 2) 0; + + color: @gray-dark; + font-size: @font-size-base; + font-weight: bold; +} + +.posts-feed-item-attachments-list { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + + margin: 0; + padding: 0; + + list-style: none; +} + +@media screen and (min-width: 600px) { + .posts-feed-item-attachments-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media screen and (min-width: 900px) { + .posts-feed-item-attachments-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.posts-feed-item-attachments-list-item { + display: flex; + align-items: center; + + min-width: 0; + + padding: 2px; + + border: 1px solid @gray-lighter; + border-radius: @border-radius-small; +} + +.posts-feed-item-attachments-list-item-image, +.posts-feed-item-attachments-list-item-icon { + flex-shrink: 0; + + width: 38px; + height: 38px; + margin-right: floor(@grid-gutter-width / 4); + + border-radius: @border-radius-small; +} + +.posts-feed-item-attachments-list-item-image { + background-size: cover; +} + +.posts-feed-item-attachments-list-item-icon { + display: flex; + align-items: center; + justify-content: center; + + font-size: 24px; + line-height: 24px; + + &, + &:link, + &:visited { + color: darken(@gray-lighter, 10); + } + + &:hover, + &:focus { + color: @gray; + background-color: @gray-lighter; + text-decoration: none; + } + + &:active { + color: darken(@gray, 10); + background-color: darken(@gray-lighter, 10); + text-decoration: none; + } +} + +.posts-feed-item-attachments-list-item-body { + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.posts-feed-item-attachments-list-item-name { + font-size: @font-size-small; + font-weight: bold; + + &, + &:link, + &:visited { + color: @text-color; + } +} + +.posts-feed-item-attachments-list-item-details { + color: @gray; + font-size: @font-size-small; +} + .posts-feed-item-post-body-footer { display: flex; align-items: center; diff --git a/frontend/src/style/misago/rich-text.less b/frontend/src/style/misago/rich-text.less new file mode 100644 index 0000000000..06fd4f818a --- /dev/null +++ b/frontend/src/style/misago/rich-text.less @@ -0,0 +1,281 @@ +.rich-text { + font-size: 16px; + + h1 { + font-size: 36px; + } + h2 { + font-size: 32px; + } + h3 { + font-size: 28px; + } + h4 { + font-size: 24px; + } + h5 { + font-size: 20px; + } + h6 { + font-size: 16px; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: @line-height-computed * 2; + + &:first-child { + margin-top: 0; + } + } + + h1, + h2, + h3, + h4, + h5, + h6, + hr, + p, + ul, + ol { + margin-bottom: @line-height-computed; + + &:last-child { + margin-bottom: 0; + } + } + + img { + max-width: 100%; + } +} + +.rich-text-image { + display: inline-block; + position: relative; + overflow: hidden; + max-width: 100%; + + border-radius: @border-radius-base; + + .rich-text-image-link { + display: inline-block; + } +} + +.rich-text-image-responsive img { + width: 100%; + height: auto; +} + +.rich-text-video { + position: relative; + + display: block; + overflow: hidden; + + height: 0; + padding: 0 0 56.25% 0; + + background-color: #1e1e1e; + border-radius: @border-radius-base; + + video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + + width: 100%; + height: 100%; + + border: 0; + } +} + +.rich-text-info-hover-btn { + display: flex; + align-items: center; + justify-content: center; + + position: absolute; + top: 10px; + right: 10px; + + width: 32px; + height: 32px; + + border-radius: @border-radius-base; + + &, + &:link, + &:visited { + background-color: fadeout(#000, 50); + color: #eee; + } + + &:hover, + &:focus, + &:active { + background-color: fadeout(#333, 10); + color: #fff; + text-decoration: none; + } + + .material-icon { + font-size: 24px; + line-height: 24px; + } +} + +.rich-text-file { + display: inline-block; +} + +.rich-text-file-body { + display: inline-block; + position: relative; + + .rich-text-info-hover-btn { + top: 3px; + right: 4px; + + width: 32px; + height: 32px; + + &, + &:link, + &:visited { + background-color: transparent; + color: @gray; + } + + &:hover, + &:focus, + &:active { + background-color: darken(@gray-lighter, 10); + color: @gray-dark; + text-decoration: none; + } + } +} + +.rich-text-file-link { + display: inline-block; + position: relative; + + padding: 8px 48px 8px 42px; + + border-radius: @border-radius-base; + + &:link, + &:visited { + background: @gray-lighter; + color: @text-color; + text-decoration: none; + } + + &:hover, + &:focus, + &:active { + background: darken(@gray-lighter, 5); + color: @text-color; + text-decoration: none; + } + + .material-icon { + position: absolute; + top: 8px; + left: 10px; + + font-size: 24px; + line-height: 24px; + } + + .rich-text-file-name { + margin-right: 8px; + + word-break: break-all; + + &:last-child { + margin: 0; + } + } + + .rich-text-file-size { + color: @gray; + } +} + +.rich-text-attachment-error { + display: inline-block; + + &:last-child { + margin-bottom: 0; + } + + .rich-text-attachment-error-body { + display: inline-block; + position: relative; + + display: inline-block; + padding: 8px 12px 8px 42px; + + background: lighten(@gray-lighter, 5); + border-radius: @border-radius-base; + color: @gray-light; + } + + .material-icon { + position: absolute; + top: 7px; + left: 10px; + + color: @gray-light; + font-size: 24px; + line-height: 24px; + } + + .rich-text-attachment-error-name { + word-break: break-all; + } +} + +.rich-text-image + .rich-text-file, +.rich-text-image + .rich-text-attachment-error, +.rich-text-file + .rich-text-image, +.rich-text-attachment-error + .rich-text-image { + clear: left; +} + +.rich-text-attachment-group { + overflow: auto; + + margin: 0 -6px; + + .rich-text-image, + .rich-text-file, + .rich-text-attachment-error { + float: left; + } + + .rich-text-video { + clear: both; + } + + .rich-text-image, + .rich-text-video, + .rich-text-file, + .rich-text-attachment-error { + margin: 0 6px @line-height-computed 6px; + } + + &:last-child { + margin-bottom: @line-height-computed * -1; + } +} diff --git a/frontend/src/style/misago/toolbar.less b/frontend/src/style/misago/toolbar.less index 2bf57bc10f..71de7222e7 100644 --- a/frontend/src/style/misago/toolbar.less +++ b/frontend/src/style/misago/toolbar.less @@ -16,13 +16,14 @@ } .toolbar-section { - justify-items: center; display: flex; + justify-items: center; flex: 1; margin-bottom: @line-height-computed; } .toolbar-item { + display: block; flex: 1; padding: 0 floor(@grid-gutter-width * 0.25); diff --git a/frontend/src/style/misago/variables.less b/frontend/src/style/misago/variables.less index 9820db582b..9cf7cd2e2d 100644 --- a/frontend/src/style/misago/variables.less +++ b/frontend/src/style/misago/variables.less @@ -402,6 +402,7 @@ @markup-editor-height: 200px; @markup-editor-padding: floor(@grid-gutter-width / 2); +@markup-editor-padding-horizontal: floor(@grid-gutter-width / 2); @markup-editor-bg: @input-bg; @markup-editor-border-color: @input-border; @@ -427,12 +428,12 @@ @markup-editor-btn-active-border: @gray-lighter; @markup-editor-btn-active-color: @text-color; -@markup-editor-attachments-max-height: 170px; -@markup-editor-attachments-max-height-xs: 120px; +@markup-editor-drag-drop-bg: #e0ecff; + @markup-editor-attachment-border-color: @gray-lighter; -@markup-editor-footer-bg: #fff; -@markup-editor-footer-border-color: @gray-lighter; +@markup-editor-attachments-bg: #fff; +@markup-editor-attachments-border-color: @gray-lighter; // Dropdown height offset used in calc() @dropdown-height-offset: @navbar-height + @line-height-computed; diff --git a/generate_dev_docs.py b/generate_dev_docs.py index def161ff85..1fdcf339ca 100644 --- a/generate_dev_docs.py +++ b/generate_dev_docs.py @@ -8,6 +8,7 @@ from textwrap import dedent, indent HOOKS_MODULES = ( + "misago.attachments.hooks", "misago.categories.hooks", "misago.oauth2.hooks", "misago.parser.hooks", @@ -328,11 +329,11 @@ def generate_outlets_reference(): fp.write( "This document contains a list of all built-in template outlets in Misago." ) - for outlet_name in sorted(outlets_dict): + for outlet_name, outlet_contents in outlets_dict.items(): fp.write("\n\n\n") fp.write(f"## `{outlet_name}`") fp.write("\n\n") - fp.write(outlets_dict[outlet_name]) + fp.write(outlet_contents) def get_callable_class_signature(class_def: ast.ClassDef) -> tuple[str, str | None]: diff --git a/misago-admin/src/style/admin-dashboard.scss b/misago-admin/src/style/admin-dashboard.scss index 85b37a9b9a..b8280d701f 100644 --- a/misago-admin/src/style/admin-dashboard.scss +++ b/misago-admin/src/style/admin-dashboard.scss @@ -17,13 +17,6 @@ padding: $table-cell-padding; } -// Make stat larger -.card-admin-stat { - @extend .text-center; - - font-size: $font-size-lg; -} - // Make text sizes in admin checks smaller .media-admin-check { font-size: $font-size-sm; diff --git a/misago-admin/src/style/admin-table.scss b/misago-admin/src/style/admin-table.scss index a41058d5b7..f8336106f2 100644 --- a/misago-admin/src/style/admin-table.scss +++ b/misago-admin/src/style/admin-table.scss @@ -88,6 +88,21 @@ background-size: cover; } +.card-admin-table .btn-thumbnail-broken { + display: flex; + align-items: center; + justify-content: center; + + width: $font-size-base * 2; + height: $font-size-base * 2; + + padding: 0; + + background-color: lighten($red, 30%); + color: $red; + cursor: default !important; +} + // Util for making item link stand out .card-admin-table .item-name { @extend .font-weight-bold; @@ -107,6 +122,13 @@ } } +.card-admin-table .item-deleted-name { + @extend .font-weight-bold; + + color: $gray-600; + text-decoration: line-through; +} + // Depth indicator .card-admin-table .item-level { margin-right: $btn-padding-x-sm; diff --git a/misago-admin/src/style/controls.scss b/misago-admin/src/style/controls.scss index a5019a8173..50c9d78c45 100644 --- a/misago-admin/src/style/controls.scss +++ b/misago-admin/src/style/controls.scss @@ -62,6 +62,24 @@ } } +// Image dimensions +.control-dimensions { + display: flex; + align-items: center; + + max-width: 280px; + + .form-control { + width: 50%; + } +} + +.control-dimensions-separator { + margin: 0 .5rem; + + color: $gray-700; +} + // Image upload/preview .control-image-preview { @extend .d-inline-block; diff --git a/misago-admin/src/tooltips.js b/misago-admin/src/tooltips.js index bcdd16837b..f084d1d3ad 100644 --- a/misago-admin/src/tooltips.js +++ b/misago-admin/src/tooltips.js @@ -3,6 +3,7 @@ import $ from "jquery" const initTooltips = () => { $('[data-tooltip="top"]').tooltip({ placement: "top" }) $('[data-tooltip="bottom"]').tooltip({ placement: "bottom" }) + $('[data-tooltip="left"]').tooltip({ placement: "left" }) } export default initTooltips diff --git a/misago/account/menus.py b/misago/account/menus.py index d6ef2bc7b7..2ca5b99a86 100644 --- a/misago/account/menus.py +++ b/misago/account/menus.py @@ -59,6 +59,14 @@ def auth_is_not_delegated(request: HttpRequest) -> bool: ) +account_settings_menu.add_item( + key="password", + url_name="misago:account-attachments", + label=pgettext_lazy("account settings page", "Attachments"), + icon="file_download", +) + + def show_download_data(request: HttpRequest) -> bool: return request.settings.allow_data_downloads diff --git a/misago/account/tests/test_account_attachments.py b/misago/account/tests/test_account_attachments.py new file mode 100644 index 0000000000..5f8dd58a77 --- /dev/null +++ b/misago/account/tests/test_account_attachments.py @@ -0,0 +1,452 @@ +from unittest.mock import patch + +from django.urls import reverse + +from ...conf.test import override_dynamic_settings +from ...pagination.cursor import EmptyPageError +from ...test import assert_contains, assert_not_contains + + +def test_account_attachments_displays_login_page_for_guests(db, client): + response = client.get(reverse("misago:account-attachments")) + assert_contains(response, "Sign in to change your settings") + + +def test_account_attachments_shows_storage_for_user_without_attachments(user_client): + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_used_attachment( + user, user_client, attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_unused_attachment( + user, user_client, attachment +): + attachment.uploader = user + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_used_and_unused_attachments( + user, user_client, attachment, user_attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_without_attachments_and_no_limits( + members_group, user_client +): + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_with_used_attachment_and_no_limits( + user, members_group, user_client, attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_with_unused_attachment_and_no_limits( + user, members_group, user_client, attachment +): + attachment.uploader = user + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_with_used_and_unused_attachments_and_no_limits( + user, members_group, user_client, attachment, user_attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_without_attachments_and_no_group_limits( + members_group, user_client +): + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_used_attachment_and_no_group_limits( + user, members_group, user_client, attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_unused_attachment_and_no_group_limits( + user, members_group, user_client, attachment +): + attachment.uploader = user + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_used_and_unused_attachments_and_no_group_limits( + user, members_group, user_client, attachment, user_attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_without_attachments_and_no_unused_limits( + members_group, user_client +): + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_with_used_attachment_and_no_unused_limits( + user, members_group, user_client, attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_with_unused_attachment_and_no_unused_limits( + user, members_group, user_client, attachment +): + attachment.uploader = user + attachment.save() + + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_storage_limit=0) +def test_account_attachments_shows_storage_for_user_with_used_and_unused_attachments_and_no_unused_limits( + user, members_group, user_client, attachment, user_attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.unused_attachments_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_without_attachments_and_no_storage_limit( + members_group, user_client +): + members_group.attachment_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_used_attachment_and_no_storage_limit( + user, members_group, user_client, attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_unused_attachment_and_no_storage_limit( + user, members_group, user_client, attachment +): + attachment.uploader = user + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +def test_account_attachments_shows_storage_for_user_with_used_and_unused_attachments_and_no_storage_limit( + user, members_group, user_client, attachment, user_attachment, post +): + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + members_group.attachment_storage_limit = 0 + members_group.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert response.status_code == 200 + + +@override_dynamic_settings(unused_attachments_lifetime=12) +def test_account_attachments_shows_unused_attachments_expire_in_12_hours( + user, user_client, attachment +): + attachment.uploader = user + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, "12 hours") + + +@override_dynamic_settings(unused_attachments_lifetime=48) +def test_account_attachments_shows_unused_attachments_expire_in_two_days( + user, user_client, attachment +): + attachment.uploader = user + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + print(response.content.decode()) + assert_contains(response, "2 days") + + +def test_account_attachments_list_shows_blankslate_for_user_without_attachments( + user_client, +): + response = user_client.get(reverse("misago:account-attachments")) + assert_contains( + response, "You haven’t uploaded any attachments, or they have been deleted." + ) + + +def test_account_attachments_list_shows_broken_attachment( + user, attachment, user_client +): + attachment.uploader = user + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_contains(response, attachment.get_absolute_url()) + + +def test_account_attachments_list_shows_image_attachment(user, attachment, user_client): + attachment.uploader = user + attachment.upload = "test.png" + attachment.filetype_id = "png" + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_contains(response, attachment.get_absolute_url()) + + +def test_account_attachments_list_shows_image_attachment_with_thumbnail( + user, attachment, user_client +): + attachment.uploader = user + attachment.upload = "test.png" + attachment.thumbnail = "thumbnail.png" + attachment.filetype_id = "png" + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_contains(response, attachment.get_thumbnail_url()) + assert_contains(response, attachment.get_absolute_url()) + + +def test_account_attachments_list_shows_video_attachment(user, attachment, user_client): + attachment.uploader = user + attachment.upload = "video.mp4" + attachment.filetype_id = "mp4" + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_contains(response, attachment.get_absolute_url()) + + +def test_account_attachments_list_shows_file_attachment(user, attachment, user_client): + attachment.uploader = user + attachment.upload = "document.pdf" + attachment.filetype_id = "pdf" + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_contains(response, attachment.get_absolute_url()) + + +def test_account_attachments_list_shows_attachment_delete_option_if_user_has_permission( + user, attachment, user_client +): + attachment.uploader = user + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_contains(response, attachment.get_delete_url()) + + +def test_account_attachments_list_hides_attachment_delete_option_if_user_has_no_permission( + user, members_group, attachment, user_client, post +): + members_group.can_always_delete_own_attachments = False + members_group.save() + + attachment.uploader = user + attachment.category = post.category + attachment.thread = post.thread + attachment.post = post + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_contains(response, attachment.name) + assert_not_contains(response, attachment.get_delete_url()) + + +def test_account_attachments_list_excludes_anonymous_attachments( + attachment, user_client +): + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_not_contains(response, attachment.name) + + +def test_account_attachments_list_excludes_other_users_attachments( + other_user, attachment, user_client +): + attachment.uploader = other_user + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_not_contains(response, attachment.name) + + +def test_account_attachments_list_excludes_user_deleted_attachments( + user, attachment, user_client +): + attachment.uploader = user + attachment.is_deleted = True + attachment.save() + + response = user_client.get(reverse("misago:account-attachments")) + assert_not_contains(response, attachment.name) + + +@patch( + "misago.account.views.settings.paginate_queryset", side_effect=EmptyPageError(10) +) +def test_account_attachments_list_redirects_to_last_page_for_invalid_cursor( + mock_pagination, user_client +): + response = user_client.get(reverse("misago:account-attachments")) + + assert response.status_code == 302 + assert response["location"] == reverse("misago:account-attachments") + "?cursor=10" + + mock_pagination.assert_called_once() diff --git a/misago/account/tests/test_account_username.py b/misago/account/tests/test_account_username.py index 4b9cae2209..118330a6c0 100644 --- a/misago/account/tests/test_account_username.py +++ b/misago/account/tests/test_account_username.py @@ -165,7 +165,7 @@ def test_account_username_renders_history_item(user_client, user): "misago.account.views.settings.paginate_queryset", side_effect=EmptyPageError(10) ) def test_account_username_redirects_to_last_page_for_invalid_cursor( - mock_pagination, user_client, user + mock_pagination, user_client ): response = user_client.get(reverse("misago:account-username")) diff --git a/misago/account/urls.py b/misago/account/urls.py index 78cd9fc2d3..890c3b117b 100644 --- a/misago/account/urls.py +++ b/misago/account/urls.py @@ -40,6 +40,11 @@ settings.account_email_confirm_change, name="account-email-confirm-change", ), + path( + "attachments/", + settings.AccountAttachmentsView.as_view(), + name="account-attachments", + ), path( "download-data/", settings.AccountDownloadDataView.as_view(), diff --git a/misago/account/views/settings.py b/misago/account/views/settings.py index 029c2b240b..fc29e920c5 100644 --- a/misago/account/views/settings.py +++ b/misago/account/views/settings.py @@ -1,3 +1,4 @@ +from math import ceil from typing import Any from django.conf import settings @@ -12,10 +13,19 @@ from django.views import View from django.views.decorators.debug import sensitive_post_parameters +from ...attachments.models import Attachment +from ...attachments.storage import ( + get_user_attachment_storage_usage, + get_user_unused_attachments_size, +) from ...auth.decorators import login_required from ...core.mail import build_mail from ...pagination.cursor import EmptyPageError, paginate_queryset from ...pagination.redirect import redirect_to_last_page +from ...permissions.attachments import check_delete_attachment_permission +from ...permissions.checkutils import check_permissions +from ...permissions.posts import check_see_post_permission +from ...threads.privatethreads import prefetch_private_thread_member_ids from ...users.datadownloads import ( request_user_data_download, user_has_data_download_request, @@ -417,6 +427,170 @@ def account_email_confirm_change(request, user_id, token): ) +class AccountAttachmentsView(AccountSettingsFormView): + template_name = "misago/account/settings/attachments.html" + template_name_htmx = "misago/account/settings/attachments_list.html" + + def get(self, request: HttpRequest) -> HttpResponse: + if request.is_htmx: + template_name = self.template_name_htmx + else: + template_name = self.template_name + + return self.render(request, template_name) + + def get_context_data( + self, + request: HttpRequest, + context: dict[str, Any], + ) -> dict[str, Any]: + context["storage_usage"] = self.get_storage_usage(request) + context["attachments"] = self.get_attachments(request) + + return context + + def get_storage_usage(self, request: HttpRequest) -> dict: + unused_attachments_lifetime_days = 0 + unused_attachments_lifetime = request.settings.unused_attachments_lifetime + + if unused_attachments_lifetime >= 24 and unused_attachments_lifetime % 24 == 0: + unused_attachments_lifetime_days = int(unused_attachments_lifetime / 24) + + total_storage = request.user_permissions.attachment_storage_limit + total_unused_storage = ( + request.settings.unused_attachments_storage_limit * 1024 * 1024 + ) + unused_storage = request.user_permissions.unused_attachments_storage_limit + + unused_storage_limit = 0 + if total_unused_storage and unused_storage: + unused_storage_limit = min(total_unused_storage, unused_storage) + else: + unused_storage_limit = total_unused_storage or unused_storage + + all_attachments = get_user_attachment_storage_usage(request.user) + unused_attachments = get_user_unused_attachments_size(request.user) + posted_attachments = max(all_attachments - unused_attachments, 0) + + free_storage = 0 + exceeded_storage = 0 + posted_pc = 0 + unused_pc = 0 + exceeded_pc = 0 + + if total_storage: + free_pc = 100 + free_storage = max(total_storage - all_attachments, 0) + + if all_attachments > total_storage: + exceeded_storage = all_attachments - total_storage + max_storage = all_attachments + exceeded_storage + else: + max_storage = total_storage + + if exceeded_storage: + exceeded_pc = ceil(float(exceeded_storage) * 100 / float(max_storage)) + free_pc -= exceeded_pc + + if unused_attachments: + unused_pc = ceil(float(unused_attachments) * 100 / float(max_storage)) + free_pc -= unused_pc + + if posted_attachments: + posted_pc = ceil(float(posted_attachments) * 100 / float(max_storage)) + posted_pc = min(posted_pc, free_pc) + + elif unused_storage_limit and unused_attachments < unused_storage_limit: + free_storage = unused_storage_limit - unused_attachments + + storage_max = posted_attachments + unused_storage_limit + if unused_attachments: + unused_pc = ceil(float(unused_attachments) * 100 / float(storage_max)) + if posted_attachments: + posted_pc = ceil(float(posted_attachments) * 100 / float(storage_max)) + + elif posted_attachments or unused_attachments: + unused_pc = ceil(float(unused_attachments) * 100 / float(all_attachments)) + posted_pc = 100 - unused_pc + + return { + "total": all_attachments, + "posted": posted_attachments, + "unused": unused_attachments, + "exceeded": exceeded_storage, + "free": free_storage, + "total_limit": total_storage, + "unused_limit": unused_storage_limit, + "posted_pc": posted_pc, + "unused_pc": unused_pc, + "exceeded_pc": exceeded_pc, + "unused_lifetime_hours": unused_attachments_lifetime, + "unused_lifetime_days": unused_attachments_lifetime_days, + } + + def get_attachments(self, request: HttpRequest) -> dict: + queryset = Attachment.objects.filter( + uploader=request.user, is_deleted=False + ).prefetch_related("category", "thread", "post") + + result = paginate_queryset(request, queryset, 20, "-id") + prefetch_private_thread_member_ids( + [attachment.thread for attachment in result.items if attachment.thread] + ) + + show_post_column = False + show_delete_column = False + + items: list[dict] = [] + for attachment in result.items: + attachment.uploader = request.user + + if attachment.post: + with check_permissions() as can_see_post: + check_see_post_permission( + request.user_permissions, + attachment.category, + attachment.thread, + attachment.post, + ) + else: + can_see_post = False + + with check_permissions() as can_delete: + check_delete_attachment_permission( + request.user_permissions, + attachment.category, + attachment.thread, + attachment.post, + attachment, + ) + + if can_see_post: + show_post_column = True + if can_delete: + show_delete_column = True + + items.append( + { + "attachment": attachment, + "show_post": can_see_post, + "show_delete": can_delete, + } + ) + + referer = "?referer=settings" + if request.GET.get("cursor"): + referer += "&cursor=" + request.GET["cursor"] + + return { + "referer": referer, + "paginator": result, + "items": items, + "show_post_column": show_post_column, + "show_delete_column": show_delete_column, + } + + class AccountDownloadDataView(AccountSettingsView): template_name = "misago/account/settings/download_data.html" template_name_htmx = "misago/account/settings/download_data_form.html" diff --git a/misago/admin/admin.py b/misago/admin/admin.py index 90b1c38514..14acb0b5f5 100644 --- a/misago/admin/admin.py +++ b/misago/admin/admin.py @@ -1,6 +1,7 @@ from django.urls import path from django.utils.translation import pgettext_lazy +from .attachments import views as attachments from .categories import views as categories from .groups import views as groups from .moderators import views as moderators @@ -27,6 +28,17 @@ def register_navigation_nodes(self, site): after="categories:index", namespace="moderators", ) + site.add_node( + name=pgettext_lazy("admin node", "Attachments"), + icon="fas fa-paperclip", + after="permissions:index", + namespace="attachments", + ) + site.add_node( + name=pgettext_lazy("admin node", "File types"), + parent="attachments", + namespace="filetypes", + ) def register_urlpatterns(self, urlpatterns): urlpatterns.namespace("groups/", "groups") @@ -84,3 +96,21 @@ def register_urlpatterns(self, urlpatterns): path("edit//", moderators.EditView.as_view(), name="edit"), path("delete//", moderators.DeleteView.as_view(), name="delete"), ) + + urlpatterns.namespace("attachments/", "attachments") + urlpatterns.patterns( + "attachments", + path("", attachments.AttachmentsList.as_view(), name="index"), + path("/", attachments.AttachmentsList.as_view(), name="index"), + path( + "delete//", + attachments.DeleteAttachment.as_view(), + name="delete", + ), + ) + urlpatterns.single_pattern( + "filetypes/", + "filetypes", + "attachments", + attachments.AttachmentsFiletypesList.as_view(), + ) diff --git a/misago/threads/admin/tests/__init__.py b/misago/admin/attachments/__init__.py similarity index 100% rename from misago/threads/admin/tests/__init__.py rename to misago/admin/attachments/__init__.py diff --git a/misago/admin/attachments/forms.py b/misago/admin/attachments/forms.py new file mode 100644 index 0000000000..ee268acf25 --- /dev/null +++ b/misago/admin/attachments/forms.py @@ -0,0 +1,86 @@ +from django import forms +from django.utils.translation import pgettext_lazy + +from ...attachments.filetypes import filetypes + + +def get_searchable_filetypes(): + choices = [("", pgettext_lazy("admin attachments type filter choice", "All types"))] + choices.extend(filetypes.as_django_choices()) + return choices + + +class FilterAttachmentsForm(forms.Form): + uploader = forms.CharField( + label=pgettext_lazy("admin attachments filter form", "Uploader name contains"), + required=False, + ) + name = forms.CharField( + label=pgettext_lazy("admin attachments filter form", "Name contains"), + required=False, + ) + filetype = forms.ChoiceField( + label=pgettext_lazy("admin attachments filter form", "File type"), + choices=get_searchable_filetypes, + required=False, + ) + status = forms.ChoiceField( + label=pgettext_lazy("admin attachments filter form", "Status"), + required=False, + choices=[ + ( + "", + pgettext_lazy( + "admin attachments status filter choice", + "All", + ), + ), + ( + "posted", + pgettext_lazy( + "admin attachments status filter choice", + "Posted", + ), + ), + ( + "unused", + pgettext_lazy( + "admin attachments status filter choice", + "Unused", + ), + ), + ( + "deleted", + pgettext_lazy( + "admin attachments status filter choice", + "Deleted", + ), + ), + ( + "broken", + pgettext_lazy( + "admin attachments status filter choice", + "Broken", + ), + ), + ], + ) + + def filter_queryset(self, criteria, queryset): + if criteria.get("uploader"): + queryset = queryset.filter( + uploader_slug__contains=criteria["uploader"].lower() + ) + if criteria.get("name"): + queryset = queryset.filter(name__icontains=criteria["name"]) + if criteria.get("filetype"): + queryset = queryset.filter(filetype_id=criteria["filetype"]) + if criteria.get("status") == "posted": + queryset = queryset.filter(post__isnull=False) + elif criteria.get("status") == "unused": + queryset = queryset.filter(post__isnull=True, is_deleted=False) + elif criteria.get("status") == "deleted": + queryset = queryset.filter(is_deleted=True) + elif criteria.get("status") == "broken": + queryset = queryset.filter(upload="") + return queryset diff --git a/misago/threads/admin/views/attachments.py b/misago/admin/attachments/views.py similarity index 82% rename from misago/threads/admin/views/attachments.py rename to misago/admin/attachments/views.py index 6f2e7b310e..f7ca866c99 100644 --- a/misago/threads/admin/views/attachments.py +++ b/misago/admin/attachments/views.py @@ -2,9 +2,11 @@ from django.db import transaction from django.utils.translation import pgettext, pgettext_lazy -from ....admin.views import generic -from ...models import Attachment, Post -from ..forms import FilterAttachmentsForm +from ...attachments.filetypes import filetypes +from ...attachments.models import Attachment +from ...threads.models import Post +from ..views import generic +from .forms import FilterAttachmentsForm class AttachmentAdmin(generic.AdminBaseMixin): @@ -17,9 +19,7 @@ class AttachmentAdmin(generic.AdminBaseMixin): def get_queryset(self): qs = super().get_queryset() - return qs.select_related( - "filetype", "uploader", "post", "post__thread", "post__category" - ) + return qs.select_related("uploader", "post", "post__thread", "post__category") class AttachmentsList(AttachmentAdmin, generic.ListView): @@ -88,9 +88,9 @@ def button_action(self, request, target): self.delete_from_cache(target) target.delete() message = pgettext( - "admin attachments", 'Attachment "%(filename)s" has been deleted.' + "admin attachments", 'Attachment "%(name)s" has been deleted.' ) - messages.success(request, message % {"filename": target.filename}) + messages.success(request, message % {"name": target.name}) def delete_from_cache(self, attachment): if not attachment.post.attachments_cache: @@ -103,3 +103,12 @@ def delete_from_cache(self, attachment): attachment.post.attachments_cache = clean_cache or None attachment.post.save(update_fields=["attachments_cache"]) + + +class AttachmentsFiletypesList(generic.AdminView): + root_link = "misago:admin:attachments:filetypes:index" + templates_dir = "misago/admin/attachments_filetypes" + template_name = "list.html" + + def get(self, request): + return self.render(request, {"items": filetypes.get_all_filetypes()}) diff --git a/misago/admin/categories/forms.py b/misago/admin/categories/forms.py index 8e5ba60b84..32b7980abd 100644 --- a/misago/admin/categories/forms.py +++ b/misago/admin/categories/forms.py @@ -326,15 +326,6 @@ def __init__(self, *args, **kwargs): self.setup_fields() def setup_fields(self): - content_queryset = Category.objects.all_categories().order_by("lft") - self.fields["move_threads_to"] = AdminCategoryChoiceField( - label=pgettext_lazy("admin category form", "Move category threads to"), - queryset=content_queryset, - initial=self.instance.parent, - empty_label=pgettext_lazy("admin category form", "Delete with category"), - required=False, - ) - not_siblings = models.Q(lft__lt=self.instance.lft) not_siblings = not_siblings | models.Q(rght__gt=self.instance.rght) children_queryset = Category.objects.all_categories(True) @@ -350,22 +341,31 @@ def setup_fields(self): required=False, ) + content_queryset = Category.objects.all_categories().order_by("lft") + self.fields["move_contents_to"] = AdminCategoryChoiceField( + label=pgettext_lazy("admin category form", "Move contents to"), + queryset=content_queryset, + initial=self.instance.parent, + empty_label=pgettext_lazy("admin category form", "Delete with category"), + required=False, + ) + def clean(self): data = super().clean() - if data.get("move_threads_to"): - if data["move_threads_to"].pk == self.instance.pk: + if data.get("move_contents_to"): + if data["move_contents_to"].pk == self.instance.pk: message = pgettext_lazy( "admin category form", "You are trying to move this category threads to itself.", ) raise forms.ValidationError(message) - moving_to_child = self.instance.has_child(data["move_threads_to"]) + moving_to_child = self.instance.has_child(data["move_contents_to"]) if moving_to_child and not data.get("move_children_to"): message = pgettext_lazy( "admin category form", - "You are trying to move this category's threads to a child category that will also be deleted.", + "You are trying to move this category's contents to a child category that will also be deleted.", ) raise forms.ValidationError(message) diff --git a/misago/admin/categories/views.py b/misago/admin/categories/views.py index 37bbe59cad..61ef15e9b2 100644 --- a/misago/admin/categories/views.py +++ b/misago/admin/categories/views.py @@ -6,6 +6,7 @@ from ...admin.views import generic from ...cache.enums import CacheName from ...cache.versions import invalidate_cache +from ...categories.delete import delete_category from ...categories.enums import CategoryTree from ...categories.models import Category, RoleCategoryACL from ...permissions.admin import get_admin_category_permissions @@ -167,32 +168,17 @@ class DeleteCategory(CategoryAdmin, generic.ModelFormView): def handle_form(self, form, request, target): move_children_to = form.cleaned_data.get("move_children_to") - move_threads_to = form.cleaned_data.get("move_threads_to") + move_contents_to = form.cleaned_data.get("move_contents_to") - if move_children_to: - for child in target.get_children(): - # refresh child and new parent - move_children_to = Category.objects.get(pk=move_children_to.pk) - child = Category.objects.get(pk=child.pk) + if move_children_to and not move_children_to.level: + move_children_to = True - child.move_to(move_children_to, "last-child") - if move_threads_to and child.pk == move_threads_to.pk: - move_threads_to = child - else: - for child in target.get_descendants().order_by("-lft"): - child.delete_content() - child.delete() - - if move_threads_to: - target.move_content(move_threads_to) - move_threads_to.synchronize() - move_threads_to.save() - else: - target.delete_content() - - # refresh instance - instance = Category.objects.get(pk=form.instance.pk) - instance.delete() + delete_category( + target, + move_children_to=move_children_to, + move_contents_to=move_contents_to, + request=request, + ) invalidate_cache( CacheName.CATEGORIES, diff --git a/misago/admin/groups/forms.py b/misago/admin/groups/forms.py index 993e69ddc2..1a1ddb8c6d 100644 --- a/misago/admin/groups/forms.py +++ b/misago/admin/groups/forms.py @@ -1,6 +1,6 @@ from django import forms from django.core.validators import validate_slug -from django.utils.translation import pgettext_lazy +from django.utils.translation import pgettext, pgettext_lazy from ...core.validators import validate_color_hex, validate_css_name, validate_sluggable from ...parser.context import create_parser_context @@ -9,6 +9,7 @@ from ...parser.html import render_ast_to_html from ...parser.metadata import create_ast_metadata from ...parser.plaintext import PlainTextFormat, render_ast_to_plaintext +from ...permissions.enums import CanUploadAttachments from ...users.models import Group, GroupDescription from ..forms import YesNoSwitch @@ -169,6 +170,52 @@ class EditGroupForm(forms.ModelForm): min_value=1, ) + can_upload_attachments = forms.TypedChoiceField( + label=pgettext_lazy("admin group permissions form", "Can upload attachments"), + choices=CanUploadAttachments.get_choices(), + widget=forms.RadioSelect(), + coerce=int, + ) + attachment_storage_limit = forms.IntegerField( + label=pgettext_lazy( + "admin group permissions form", "Total attachment storage limit" + ), + help_text=pgettext_lazy( + "admin group permissions form", + "Maximum total storage space, in megabytes, that each member of this group can to use for their attachments. Enter zero to remove this limit.", + ), + min_value=0, + ) + unused_attachments_storage_limit = forms.IntegerField( + label=pgettext_lazy( + "admin group permissions form", "Unused attachments storage limit" + ), + help_text=pgettext_lazy( + "admin group permissions form", + "Maximum total storage space, in megabytes, for member's attachments that have been uploaded but are not associated with any posts. Enter zero to remove this limit.", + ), + min_value=0, + ) + attachment_size_limit = forms.IntegerField( + label=pgettext_lazy( + "admin group permissions form", "Attachment file size limit" + ), + help_text=pgettext_lazy( + "admin group permissions form", + "Maximum file size of an attachment in kilobytes. Enter zero to remove this limit. Note: Server and Django request body size limits will still apply.", + ), + min_value=0, + ) + can_always_delete_own_attachments = YesNoSwitch( + label=pgettext_lazy( + "admin group permissions form", "Can always delete own attachments" + ), + help_text=pgettext_lazy( + "admin group permissions form", + "This permission allows users to delete their own attachments, even if they no longer have permission to edit or view the post they are associated with.", + ), + ) + can_change_username = YesNoSwitch( label=pgettext_lazy("admin group permissions form", "Can change username"), ) @@ -226,6 +273,11 @@ class Meta: "can_use_private_threads", "can_start_private_threads", "private_thread_users_limit", + "can_upload_attachments", + "attachment_storage_limit", + "unused_attachments_storage_limit", + "attachment_size_limit", + "can_always_delete_own_attachments", "can_change_username", "username_changes_limit", "username_changes_expire", @@ -240,6 +292,28 @@ def __init__(self, *args, **kwargs): id=kwargs["instance"].id ) + def clean(self): + data = super().clean() + + attachment_storage_limit = data.get("attachment_storage_limit") + unused_attachments_storage_limit = data.get("unused_attachments_storage_limit") + if ( + attachment_storage_limit + and unused_attachments_storage_limit + and unused_attachments_storage_limit > attachment_storage_limit + ): + self.add_error( + "unused_attachments_storage_limit", + forms.ValidationError( + message=pgettext( + "admin group form", + "Unused attachments limit cannot exceed total attachments limit.", + ), + ), + ) + + return data + class EditGroupDescriptionForm(forms.ModelForm): markdown = forms.CharField( diff --git a/misago/admin/templates/misago/admin/attachments/list.html b/misago/admin/templates/misago/admin/attachments/list.html new file mode 100644 index 0000000000..bfdedd190c --- /dev/null +++ b/misago/admin/templates/misago/admin/attachments/list.html @@ -0,0 +1,143 @@ +{% extends "misago/admin/generic/list.html" %} +{% load i18n misago_admin_form misago_capture %} + + +{% block table-header %} +  +{% translate "Attachment" context "admin attachments list" %} +{% translate "Size" context "admin attachments list" %} +{% translate "Type" context "admin attachments list" %} +{% translate "Uploader" context "admin attachments list" %} +{% translate "Uploaded at" context "admin attachments list" %} +{% translate "Thread" context "admin attachments list" %} +  +{% endblock table-header %} + + +{% block table-row %} +{% if item.upload %} + {% if item.filetype.is_image %} + + {% if item.thumbnail %} + + {% else %} + + {% endif %} + + {% else %} + + + + + + {% endif %} +{% else %} + +
+ +
+ +{% endif %} + + {% if item.upload %} + + {{ item.name }} + + {% else %} + + {{ item.name }} + + {% endif %} + + + {{ item.size|filesizeformat }} + + + {{ item.filetype_name }} + + + {% if item.uploader %} + {{ item.uploader }} + {% else %} + {{ item.uploader_name }} + {% endif %} + + + + {{ item.uploaded_at }} + + + + {% if item.post %} + + {{ item.post.thread }} + + {% elif item.is_deleted %} + + {% translate "Marked for deletion" context "admin attachment status" %} + + {% else %} + + {% translate "Unused" context "admin attachment status" %} + + {% endif %} + + +
+ {% csrf_token %} + +
+ +{% endblock table-row %} + + +{% block blankslate %} + + {% if active_filters %} + {% translate "No attachments matching criteria exist." context "admin attachments" %} + {% else %} + {% translate "No attachments exist." context "admin attachments" %} + {% endif %} + +{% endblock blankslate %} + + +{% block filters-modal-body %} +
+
+ {% form_row filter_form.uploader %} +
+
+
+
+ {% form_row filter_form.name %} +
+
+
+
+ {% form_row filter_form.filetype %} +
+
+
+
+ {% form_row filter_form.status %} +
+
+{% endblock filters-modal-body %} + + +{% block javascripts %} +{{ block.super }} + +{% endblock %} diff --git a/misago/admin/templates/misago/admin/attachments_filetypes/list.html b/misago/admin/templates/misago/admin/attachments_filetypes/list.html new file mode 100644 index 0000000000..06416409c2 --- /dev/null +++ b/misago/admin/templates/misago/admin/attachments_filetypes/list.html @@ -0,0 +1,61 @@ +{% extends "misago/admin/generic/base.html" %} +{% load i18n %} + + +{% block title %} +{{ active_link.name }} | {{ block.super }} +{% endblock title %} + + +{% block view %} +
+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "ID" context "admin attachment filetypes list" %}{% translate "Name" context "admin attachment filetypes list" %}{% translate "Extensions" context "admin attachment filetypes list" %}{% translate "Content types" context "admin attachment filetypes list" %} 
{{ item.id }}{{ item.name }} + {% for extension in item.extensions %} + .{{ extension }}{% if not forloop.last %},{% endif %} + {% endfor %} + + {% for content_type in item.content_types %} + {{ content_type }}{% if not forloop.last %},{% endif %} + {% endfor %} + + + + +
+ {% translate "No attachments file types exist." context "admin attachments filetypes" %} +
+
+{% endblock view %} \ No newline at end of file diff --git a/misago/templates/misago/admin/attachmenttypes/form.html b/misago/admin/templates/misago/admin/attachmenttypes/form.html similarity index 100% rename from misago/templates/misago/admin/attachmenttypes/form.html rename to misago/admin/templates/misago/admin/attachmenttypes/form.html diff --git a/misago/templates/misago/admin/attachmenttypes/list.html b/misago/admin/templates/misago/admin/attachmenttypes/list.html similarity index 100% rename from misago/templates/misago/admin/attachmenttypes/list.html rename to misago/admin/templates/misago/admin/attachmenttypes/list.html diff --git a/misago/templates/misago/admin/base.html b/misago/admin/templates/misago/admin/base.html similarity index 100% rename from misago/templates/misago/admin/base.html rename to misago/admin/templates/misago/admin/base.html diff --git a/misago/admin/templates/misago/admin/categories/delete.html b/misago/admin/templates/misago/admin/categories/delete.html index 087f66edbc..a99859b60c 100644 --- a/misago/admin/templates/misago/admin/categories/delete.html +++ b/misago/admin/templates/misago/admin/categories/delete.html @@ -3,9 +3,9 @@ {% block title %} -{% blocktrans trimmed with category=target.name context "admin category form" %} +{% blocktranslate trimmed with category=target.name context "admin category form" %} Delete category: {{ category }} -{% endblocktrans %} | {{ active_link.name }} | {{ block.super }} +{% endblocktranslate %} | {{ active_link.name }} | {{ block.super }} {% endblock title %} @@ -18,23 +18,23 @@ {% block form-header %} -{% trans "Delete category" context "admin category form" %} +{% translate "Delete category" context "admin category form" %} {% endblock %} {% block form-body %}
- {% trans "Category contents" context "admin category form" %} + {% translate "Category contents" context "admin category form" %} {% if not form.instance.is_leaf_node %} {% form_row form.move_children_to %} {% endif %} - {% form_row form.move_threads_to %} + {% form_row form.move_contents_to %}
{% endblock form-body %} {% block form-footer %} - + {% endblock %} diff --git a/misago/admin/templates/misago/admin/categories/form.html b/misago/admin/templates/misago/admin/categories/form.html index 7ecaba0979..d7f23f60bd 100644 --- a/misago/admin/templates/misago/admin/categories/form.html +++ b/misago/admin/templates/misago/admin/categories/form.html @@ -6,7 +6,7 @@ {% if target.pk %} {{ target }} {% else %} - {% trans "New category" context "admin category form" %} + {% translate "New category" context "admin category form" %} {% endif %} | {{ active_link.name }} | {{ block.super }} {% endblock title %} @@ -26,13 +26,13 @@ {% endif %} @@ -41,9 +41,9 @@ {% block form-header %} {% if target.pk %} - {% trans "Edit category" context "admin category form" %} + {% translate "Edit category" context "admin category form" %} {% else %} - {% trans "New category" context "admin category form" %} + {% translate "New category" context "admin category form" %} {% endif %} {% endblock %} @@ -51,7 +51,7 @@ {% block form-body %}
- {% trans "Display and position" context "admin category form" %} + {% translate "Display and position" context "admin category form" %} {% form_row form.new_parent %} {% form_row form.name %} @@ -64,7 +64,7 @@
- {% trans "Behavior" context "admin category form" %} + {% translate "Behavior" context "admin category form" %} {% form_row form.copy_permissions %} {% form_row form.allow_polls %} @@ -79,7 +79,7 @@
- {% trans "Content approval" context "admin category form" %} + {% translate "Content approval" context "admin category form" %} {% form_row form.require_threads_approval %} {% form_row form.require_replies_approval %} @@ -89,7 +89,7 @@
- {% trans "Prune threads" context "admin category form" %} + {% translate "Prune threads" context "admin category form" %} {% form_row form.prune_started_after %} {% form_row form.prune_replied_after %} diff --git a/misago/admin/templates/misago/admin/categories/list.html b/misago/admin/templates/misago/admin/categories/list.html index 8682190d47..cca07f96d7 100644 --- a/misago/admin/templates/misago/admin/categories/list.html +++ b/misago/admin/templates/misago/admin/categories/list.html @@ -6,18 +6,18 @@ {% endblock %} {% block table-header %} -{% trans "Category" context "admin categories list" %} -{% trans "Label" context "admin categories list" %} -{% trans "CSS class" context "admin categories list" %} -{% trans "Threads" context "admin categories list" %} -{% trans "Posts" context "admin categories list" %} +{% translate "Category" context "admin categories list" %} +{% translate "Label" context "admin categories list" %} +{% translate "CSS class" context "admin categories list" %} +{% translate "Threads" context "admin categories list" %} +{% translate "Posts" context "admin categories list" %}       @@ -46,7 +46,7 @@ {% if item.css_class %}
{{ item.css_class }}
{% else %} - {% trans "Not set" context "admin category css class" %} + {% translate "Not set" context "admin category css class" %} {% endif %} @@ -56,7 +56,7 @@ {{ item.posts }} - + @@ -64,7 +64,7 @@ {% if not item.first %}
{% csrf_token %} -
@@ -78,7 +78,7 @@ {% if not item.last %}
{% csrf_token %} -
@@ -89,7 +89,7 @@ {% endif %} - + @@ -100,7 +100,7 @@
@@ -121,6 +121,6 @@ {% block blankslate %} - {% trans "No categories are set." context "admin categories" %} + {% translate "No categories are set." context "admin categories" %} {% endblock blankslate %} diff --git a/misago/admin/templates/misago/admin/categories/permissions.html b/misago/admin/templates/misago/admin/categories/permissions.html index 14cf3a0f59..a7b2f8bf40 100644 --- a/misago/admin/templates/misago/admin/categories/permissions.html +++ b/misago/admin/templates/misago/admin/categories/permissions.html @@ -3,7 +3,7 @@ {% block title %} -{% trans "Category permissions" context "admin category form" %} | {{ target }} | {{ active_link.name }} | {{ block.super }} +{% translate "Category permissions" context "admin category form" %} | {{ target }} | {{ active_link.name }} | {{ block.super }} {% endblock title %} @@ -19,23 +19,23 @@ {% endblock %} {% block form-header %} -{% trans "Category permissions" context "admin category form" %} +{% translate "Category permissions" context "admin category form" %} {% endblock %} {% block item-header %} -{% trans "Category" context "admin category form" %} +{% translate "Category" context "admin category form" %} {% endblock item-header %} \ No newline at end of file diff --git a/misago/templates/misago/admin/dashboard/analytics.html b/misago/admin/templates/misago/admin/dashboard/analytics.html similarity index 100% rename from misago/templates/misago/admin/dashboard/analytics.html rename to misago/admin/templates/misago/admin/dashboard/analytics.html diff --git a/misago/templates/misago/admin/dashboard/checks.html b/misago/admin/templates/misago/admin/dashboard/checks.html similarity index 78% rename from misago/templates/misago/admin/dashboard/checks.html rename to misago/admin/templates/misago/admin/dashboard/checks.html index 2d25bd0ffe..c22d688129 100644 --- a/misago/templates/misago/admin/dashboard/checks.html +++ b/misago/admin/templates/misago/admin/dashboard/checks.html @@ -29,8 +29,8 @@
{% translate "Checking Misago version used by the site..." context "admin ve
{% translate "The site is running in DEBUG mode." context "admin debug check" %}
{% blocktranslate trimmed context "admin debug check" %} - Error pages displayed in DEBUG mode will expose site configuration details like secrets and tokens to all visitors. - This is MAJOR security risk. + Error pages displayed in the DEBUG mode will expose site configuration details like secrets and tokens to all visitors. + This is a MAJOR security risk. {% endblocktranslate %}
@@ -85,7 +85,7 @@
{% translate "Configured forum address appears to be incorrect." context "ad @@ -143,6 +143,43 @@
{% endif %} + {% if not checks.attachments_storage.is_ok %} +
+
+
+
+ {% if checks.attachments_storage.usage > 85 %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+
+ {% blocktranslate trimmed with usage=checks.attachments_storage.usage context "admin attachments storage check" %} + Unused attachments storage is {{ usage }}% full. + {% endblocktranslate %} +
+ {% blocktranslate trimmed context "admin attachments storage check" %} + Users without unlimited unused attachments storage will not be able to upload files if it is 100% full. + {% endblocktranslate %} + {% blocktranslate trimmed with space_left=checks.attachments_storage.space_left|filesizeformat limit=checks.attachments_storage.limit|filesizeformat context "admin attachments storage check" %} + {{ space_left }} of {{ limit }} is available for new attachments. + {% endblocktranslate %} +
+
+
+
+ + + +
+
+
+ {% endif %} {% if not checks.inactive_users.is_ok %}
diff --git a/misago/templates/misago/admin/dashboard/index.html b/misago/admin/templates/misago/admin/dashboard/index.html similarity index 100% rename from misago/templates/misago/admin/dashboard/index.html rename to misago/admin/templates/misago/admin/dashboard/index.html diff --git a/misago/admin/templates/misago/admin/dashboard/totals.html b/misago/admin/templates/misago/admin/dashboard/totals.html new file mode 100644 index 0000000000..00b9accb1a --- /dev/null +++ b/misago/admin/templates/misago/admin/dashboard/totals.html @@ -0,0 +1,36 @@ +{% load i18n %} +
+
+

+ {% translate "Totals" context "admin dashboard totals" %} +

+
+ + + + + + + + + +
+ {% translate "Users:" context "admin dashboard totals" %} + {{ totals.users }} + + {% translate "Threads:" context "admin dashboard totals" %} + {{ totals.threads }} + + {% translate "Posts:" context "admin dashboard totals" %} + {{ totals.posts }} + + {% translate "Attachments:" context "admin dashboard totals" %} + {{ totals.attachments }} + + {% translate "Attachments size:" context "admin dashboard totals" %} + {{ totals.attachments_total_size|filesizeformat }} + + {% translate "Unused attachments:" context "admin dashboard totals" %} + {{ totals.attachments_unused_size|filesizeformat }} +
+
\ No newline at end of file diff --git a/misago/templates/misago/admin/errorpages/403.html b/misago/admin/templates/misago/admin/errorpages/403.html similarity index 100% rename from misago/templates/misago/admin/errorpages/403.html rename to misago/admin/templates/misago/admin/errorpages/403.html diff --git a/misago/templates/misago/admin/errorpages/404.html b/misago/admin/templates/misago/admin/errorpages/404.html similarity index 100% rename from misago/templates/misago/admin/errorpages/404.html rename to misago/admin/templates/misago/admin/errorpages/404.html diff --git a/misago/templates/misago/admin/errorpages/csrf_failure.html b/misago/admin/templates/misago/admin/errorpages/csrf_failure.html similarity index 100% rename from misago/templates/misago/admin/errorpages/csrf_failure.html rename to misago/admin/templates/misago/admin/errorpages/csrf_failure.html diff --git a/misago/templates/misago/admin/errorpages/csrf_failure_authenticated.html b/misago/admin/templates/misago/admin/errorpages/csrf_failure_authenticated.html similarity index 100% rename from misago/templates/misago/admin/errorpages/csrf_failure_authenticated.html rename to misago/admin/templates/misago/admin/errorpages/csrf_failure_authenticated.html diff --git a/misago/templates/misago/admin/errorpages/csrf_failure_message.html b/misago/admin/templates/misago/admin/errorpages/csrf_failure_message.html similarity index 100% rename from misago/templates/misago/admin/errorpages/csrf_failure_message.html rename to misago/admin/templates/misago/admin/errorpages/csrf_failure_message.html diff --git a/misago/templates/misago/admin/form/checkbox_row.html b/misago/admin/templates/misago/admin/form/checkbox_row.html similarity index 100% rename from misago/templates/misago/admin/form/checkbox_row.html rename to misago/admin/templates/misago/admin/form/checkbox_row.html diff --git a/misago/admin/templates/misago/admin/form/dimensions_row.html b/misago/admin/templates/misago/admin/form/dimensions_row.html new file mode 100644 index 0000000000..26866cb41d --- /dev/null +++ b/misago/admin/templates/misago/admin/form/dimensions_row.html @@ -0,0 +1,26 @@ +{% load i18n misago_admin_form %} +
+ +
+
+ {% form_input field_width %} + + {% form_input field_height %} +
+ {% for error in field_width.errors %} + + {% translate "Width:" context "admin dimension field" %} {{ error }} + + {% endfor %} + {% for error in field_height.errors %} + + {% translate "Height:" context "admin dimension field" %} {{ error }} + + {% endfor %} + {% if field_width.help_text %} + {{ field_width.help_text }} + {% endif %} +
+
\ No newline at end of file diff --git a/misago/templates/misago/admin/form/image_row.html b/misago/admin/templates/misago/admin/form/image_row.html similarity index 100% rename from misago/templates/misago/admin/form/image_row.html rename to misago/admin/templates/misago/admin/form/image_row.html diff --git a/misago/templates/misago/admin/form/input.html b/misago/admin/templates/misago/admin/form/input.html similarity index 100% rename from misago/templates/misago/admin/form/input.html rename to misago/admin/templates/misago/admin/form/input.html diff --git a/misago/templates/misago/admin/form/multiple_choice.html b/misago/admin/templates/misago/admin/form/multiple_choice.html similarity index 100% rename from misago/templates/misago/admin/form/multiple_choice.html rename to misago/admin/templates/misago/admin/form/multiple_choice.html diff --git a/misago/templates/misago/admin/form/radio_select.html b/misago/admin/templates/misago/admin/form/radio_select.html similarity index 100% rename from misago/templates/misago/admin/form/radio_select.html rename to misago/admin/templates/misago/admin/form/radio_select.html diff --git a/misago/templates/misago/admin/form/row.html b/misago/admin/templates/misago/admin/form/row.html similarity index 95% rename from misago/templates/misago/admin/form/row.html rename to misago/admin/templates/misago/admin/form/row.html index 04a548980e..d28542bc4e 100644 --- a/misago/templates/misago/admin/form/row.html +++ b/misago/admin/templates/misago/admin/form/row.html @@ -1,5 +1,5 @@ {% load i18n misago_admin_form %} -
+
diff --git a/misago/templates/misago/admin/form/select.html b/misago/admin/templates/misago/admin/form/select.html similarity index 100% rename from misago/templates/misago/admin/form/select.html rename to misago/admin/templates/misago/admin/form/select.html diff --git a/misago/admin/templates/misago/admin/generic/filter_form.html b/misago/admin/templates/misago/admin/generic/filter_form.html index 30a6ea1c58..1f413423ba 100644 --- a/misago/admin/templates/misago/admin/generic/filter_form.html +++ b/misago/admin/templates/misago/admin/generic/filter_form.html @@ -4,7 +4,7 @@ {% endif %} diff --git a/misago/admin/templates/misago/admin/generic/form.html b/misago/admin/templates/misago/admin/generic/form.html index 615097f199..8f3ddaaa6e 100644 --- a/misago/admin/templates/misago/admin/generic/form.html +++ b/misago/admin/templates/misago/admin/generic/form.html @@ -22,7 +22,7 @@
{% empty %} {% endfor %} {% endif %} @@ -33,7 +33,7 @@
{% endif %} -{% endblock content%} +{% endblock content %} {% block javascripts %} diff --git a/misago/admin/templates/misago/admin/generic/mass_actions.html b/misago/admin/templates/misago/admin/generic/mass_actions.html index f466ca3a34..017204549c 100644 --- a/misago/admin/templates/misago/admin/generic/mass_actions.html +++ b/misago/admin/templates/misago/admin/generic/mass_actions.html @@ -7,7 +7,7 @@ {{ empty_selection_label }}