diff --git a/assets/controllers/subject_controller.js b/assets/controllers/subject_controller.js
index 95331ad07..cd38b0eb0 100644
--- a/assets/controllers/subject_controller.js
+++ b/assets/controllers/subject_controller.js
@@ -199,6 +199,46 @@ export default class extends Controller {
}
}
+ /**
+ * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter
+ * with the response from the link
+ */
+ async linkCallback(event) {
+ const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params
+ event.preventDefault();
+
+ const a = event.target.closest('a');
+
+ try {
+ this.loadingValue = true;
+
+ let response = await fetch(a.href, {
+ method: 'GET',
+ });
+
+ response = await ok(response);
+ response = await response.json();
+
+ event.target.closest(`.${cssClass}`).outerHTML = response.html;
+
+ const refreshElement = this.element.querySelector(refreshSelector)
+ console.log("linkCallback refresh stuff", refreshLink, refreshSelector, refreshElement)
+
+ if (!!refreshLink && refreshLink !== "" && !!refreshElement) {
+ let response = await fetch(refreshLink, {
+ method: 'GET',
+ });
+
+ response = await ok(response);
+ response = await response.json();
+ refreshElement.outerHTML = response.html;
+ }
+ } catch (e) {
+ } finally {
+ this.loadingValue = false;
+ }
+ }
+
loadingValueChanged(val) {
const submitButton = this.containerTarget.querySelector('form button[type="submit"]');
diff --git a/assets/styles/app.scss b/assets/styles/app.scss
index 7f60d159a..e9f9b0dc9 100644
--- a/assets/styles/app.scss
+++ b/assets/styles/app.scss
@@ -1,5 +1,6 @@
@import '@fortawesome/fontawesome-free/scss/fontawesome';
@import '@fortawesome/fontawesome-free/scss/solid';
+@import '@fortawesome/fontawesome-free/scss/regular';
@import '@fortawesome/fontawesome-free/scss/brands';
@import 'simple-icons-font/font/simple-icons';
@import 'glightbox/dist/css/glightbox.min.css';
@@ -21,6 +22,7 @@
@import 'layout/alerts';
@import 'layout/forms';
@import 'layout/images';
+@import 'layout/icons';
@import 'components/announcement';
@import 'components/topbar';
@import 'components/header';
@@ -35,6 +37,7 @@
@import 'components/figure_image';
@import 'components/figure_lightbox';
@import 'components/post';
+@import 'components/search';
@import 'components/subject';
@import 'components/login';
@import 'components/modlog';
@@ -51,6 +54,7 @@
@import 'components/settings_row';
@import 'pages/post_single';
@import 'pages/post_front';
+@import 'pages/page_bookmarks';
@import 'themes/kbin';
@import 'themes/default';
@import 'themes/solarized';
diff --git a/assets/styles/components/_search.scss b/assets/styles/components/_search.scss
new file mode 100644
index 000000000..d179dd372
--- /dev/null
+++ b/assets/styles/components/_search.scss
@@ -0,0 +1,24 @@
+.search-container {
+ background: var(--kbin-input-bg);
+ border: var(--kbin-input-border);
+ border-radius: var(--kbin-rounded-edges-radius) !important;
+
+ input.form-control {
+ border-radius: 0 !important;
+ border: none;
+ background: transparent;
+ margin: 0 .5em;
+ padding: .5rem .25rem;
+ }
+
+ button {
+ border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important;
+ border: 0;
+ padding: 1rem 0.5rem;
+
+ &:not(:hover) {
+ background: var(--kbin-input-bg);
+ color: var(--kbin-input-text-color) !important;
+ }
+ }
+}
diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss
index 8d42c269d..e0e606d7a 100644
--- a/assets/styles/layout/_forms.scss
+++ b/assets/styles/layout/_forms.scss
@@ -525,3 +525,9 @@ div.input-box {
border-radius: var(--kbin-rounded-edges-radius) !important;
}
}
+
+.form-control {
+ display: block;
+ width: 100%;
+
+}
diff --git a/assets/styles/layout/_icons.scss b/assets/styles/layout/_icons.scss
new file mode 100644
index 000000000..0f94dbfc0
--- /dev/null
+++ b/assets/styles/layout/_icons.scss
@@ -0,0 +1,3 @@
+i.active {
+ color: var(--kbin-color-icon-active, orange);
+}
diff --git a/assets/styles/layout/_layout.scss b/assets/styles/layout/_layout.scss
index 789c8f20c..c7b387c12 100644
--- a/assets/styles/layout/_layout.scss
+++ b/assets/styles/layout/_layout.scss
@@ -214,7 +214,9 @@ figure {
code,
.ts-control > [data-value].item,
.image-preview-container {
- border-radius: var(--kbin-rounded-edges-radius) !important;
+ &:not(.ignore-edges) {
+ border-radius: var(--kbin-rounded-edges-radius) !important;
+ }
}
.ts-wrapper {
@@ -361,6 +363,12 @@ figure {
gap: .25rem;
}
+@include media-breakpoint-down(lg) {
+ .flex.mobile {
+ display: block;
+ }
+}
+
.flex-wrap {
flex-wrap: wrap;
}
diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss
index 7f44e8166..47e75a8eb 100644
--- a/assets/styles/layout/_section.scss
+++ b/assets/styles/layout/_section.scss
@@ -68,11 +68,3 @@
color: var(--kbin-alert-danger-text-color);
}
}
-
-.page-search {
- .section--top {
- button {
- padding: 1rem 1.5rem;
- }
- }
-}
\ No newline at end of file
diff --git a/assets/styles/pages/page_bookmarks.scss b/assets/styles/pages/page_bookmarks.scss
new file mode 100644
index 000000000..557f314dd
--- /dev/null
+++ b/assets/styles/pages/page_bookmarks.scss
@@ -0,0 +1,6 @@
+.page-bookmarks {
+ .entry, .entry-comment, .post, .post-comment, .comment {
+ margin-top: 0!important;
+ margin-bottom: .5em!important;
+ }
+}
diff --git a/config/kbin_routes/bookmark.yaml b/config/kbin_routes/bookmark.yaml
new file mode 100644
index 000000000..487c8f307
--- /dev/null
+++ b/config/kbin_routes/bookmark.yaml
@@ -0,0 +1,71 @@
+bookmark_front:
+ controller: App\Controller\BookmarkListController::front
+ defaults: { sortBy: hot, time: '∞', federation: all }
+ path: /bookmark-lists/show/{list}/{sortBy}/{time}/{federation}
+ methods: [GET]
+ requirements: &front_requirement
+ sortBy: "%default_sort_options%"
+ time: "%default_time_options%"
+ federation: "%default_federation_options%"
+
+bookmark_lists:
+ controller: App\Controller\BookmarkListController::list
+ path: /bookmark-lists
+ methods: [GET, POST]
+
+bookmark_lists_menu_refresh_status:
+ controller: App\Controller\BookmarkListController::subjectBookmarkMenuListRefresh
+ path: /blr/{subject_id}/{subject_type}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+
+bookmark_lists_make_default:
+ controller: App\Controller\BookmarkListController::makeDefault
+ path: /bookmark-lists/makeDefault
+ methods: [GET]
+
+bookmark_lists_edit_list:
+ controller: App\Controller\BookmarkListController::editList
+ path: /bookmark-lists/editList/{list}
+ methods: [GET, POST]
+
+bookmark_lists_delete_list:
+ controller: App\Controller\BookmarkListController::deleteList
+ path: /bookmark-lists/deleteList/{list}
+ methods: [GET]
+
+subject_bookmark_standard:
+ controller: App\Controller\BookmarkController::subjectBookmarkStandard
+ path: /bos/{subject_id}/{subject_type}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+
+subject_bookmark_refresh_status:
+ controller: App\Controller\BookmarkController::subjectBookmarkRefresh
+ path: /bor/{subject_id}/{subject_type}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+
+subject_bookmark_to_list:
+ controller: App\Controller\BookmarkController::subjectBookmarkToList
+ path: /bol/{subject_id}/{subject_type}/{list}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+
+subject_remove_bookmarks:
+ controller: App\Controller\BookmarkController::subjectRemoveBookmarks
+ path: /rbo/{subject_id}/{subject_type}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+
+subject_remove_bookmark_from_list:
+ controller: App\Controller\BookmarkController::subjectRemoveBookmarkFromList
+ path: /rbol/{subject_id}/{subject_type}/{list}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
diff --git a/config/kbin_routes/bookmark_api.yaml b/config/kbin_routes/bookmark_api.yaml
new file mode 100644
index 000000000..f69ad45eb
--- /dev/null
+++ b/config/kbin_routes/bookmark_api.yaml
@@ -0,0 +1,61 @@
+api_bookmark_front:
+ controller: App\Controller\Api\Bookmark\BookmarkListApiController::front
+ path: /api/bookmark-lists/show
+ methods: [GET]
+ format: json
+
+api_bookmark_lists:
+ controller: App\Controller\Api\Bookmark\BookmarkListApiController::list
+ path: /api/bookmark-lists
+ methods: [GET]
+ format: json
+
+api_bookmark_lists_make_default:
+ controller: App\Controller\Api\Bookmark\BookmarkListApiController::makeDefault
+ path: /api/bookmark-lists/{list_name}/makeDefault
+ methods: [GET]
+ format: json
+
+api_bookmark_lists_edit_list:
+ controller: App\Controller\Api\Bookmark\BookmarkListApiController::editList
+ path: /api/bookmark-lists/{list_name}
+ methods: [POST]
+ format: json
+
+api_bookmark_lists_delete_list:
+ controller: App\Controller\Api\Bookmark\BookmarkListApiController::deleteList
+ path: /api/bookmark-lists/{list_name}
+ methods: [DELETE]
+ format: json
+
+api_subject_bookmark_standard:
+ controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkStandard
+ path: /api/bos/{subject_id}/{subject_type}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+ format: json
+
+api_subject_bookmark_to_list:
+ controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkToList
+ path: /api/bol/{subject_id}/{subject_type}/{list_name}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+ format: json
+
+api_subject_remove_bookmarks:
+ controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarks
+ path: /api/rbo/{subject_id}/{subject_type}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+ format: json
+
+api_subject_remove_bookmark_from_list:
+ controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarkFromList
+ path: /api/rbol/{subject_id}/{subject_type}/{list_name}
+ requirements:
+ subject_type: "%default_subject_type_options%"
+ methods: [ GET ]
+ format: json
diff --git a/config/kbin_routes/magazine_api.yaml b/config/kbin_routes/magazine_api.yaml
index 518a63a02..f364322cc 100644
--- a/config/kbin_routes/magazine_api.yaml
+++ b/config/kbin_routes/magazine_api.yaml
@@ -1,6 +1,13 @@
-# Create an article entry in a magazine
+# Create a thread entry in a magazine
+api_magazine_entry_create_thread:
+ controller: App\Controller\Api\Entry\MagazineEntryCreateApi::thread
+ path: /api/magazine/{magazine_id}/thread
+ methods: [ POST ]
+ format: json
+
+# Create thread entry, same as above, for for backwards compatibility, also points to thread method
api_magazine_entry_create_article:
- controller: App\Controller\Api\Entry\MagazineEntryCreateApi::article
+ controller: App\Controller\Api\Entry\MagazineEntryCreateApi::thread
path: /api/magazine/{magazine_id}/article
methods: [ POST ]
format: json
@@ -148,4 +155,4 @@ api_magazine_modlog:
controller: App\Controller\Api\Magazine\MagazineModLogApi::collection
path: /api/magazine/{magazine_id}/log
methods: [ GET ]
- format: json
\ No newline at end of file
+ format: json
diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml
index 714d49007..221fde7b4 100644
--- a/config/packages/league_oauth2_server.yaml
+++ b/config/packages/league_oauth2_server.yaml
@@ -59,6 +59,13 @@ league_oauth2_server:
"user:profile",
"user:profile:read",
"user:profile:edit",
+ "user:bookmark",
+ "user:bookmark:add",
+ "user:bookmark:remove",
+ "user:bookmark:list",
+ "user:bookmark:list:read",
+ "user:bookmark:list:edit",
+ "user:bookmark:list:delete",
"user:message",
"user:message:read",
"user:message:create",
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index 626baa730..c3dee042c 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -230,6 +230,17 @@ security:
'ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ',
'ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT',
]
+ 'ROLE_OAUTH2_USER:BOOKMARK':
+ [
+ 'ROLE_OAUTH2_USER:BOOKMARK:ADD',
+ 'ROLE_OAUTH2_USER:BOOKMARK:REMOVE',
+ ]
+ 'ROLE_OAUTH2_USER:BOOKMARK_LIST':
+ [
+ 'ROLE_OAUTH2_USER:BOOKMARK_LIST:READ',
+ 'ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT',
+ 'ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE',
+ ]
'ROLE_OAUTH2_MODERATE':
[
'ROLE_OAUTH2_MODERATE:ENTRY',
diff --git a/config/services.yaml b/config/services.yaml
index b5924f19a..3754eeb9e 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -78,10 +78,11 @@ parameters:
front_sort_options: top|hot|active|newest|oldest|commented # TODO remove fallback after tag rework
default_sort_options: top|hot|active|newest|oldest|commented
default_time_options: 3h|6h|12h|1d|1w|1m|1y|all|∞
- default_type_options: article|articles|link|links|video|videos|photo|photos|image|images|all
+ default_type_options: article|articles|thread|threads|link|links|video|videos|photo|photos|image|images|all
default_subscription_options: sub|fav|mod|all|home
default_federation_options: local|all
default_content_options: threads|microblog
+ default_subject_type_options: entry|entry_comment|post|post_comment
comment_sort_options: top|hot|active|newest|oldest
diff --git a/docs/postman/kbin.postman_collection.json b/docs/postman/kbin.postman_collection.json
index 597a5e501..75798c559 100644
--- a/docs/postman/kbin.postman_collection.json
+++ b/docs/postman/kbin.postman_collection.json
@@ -1367,7 +1367,7 @@
]
},
{
- "name": "Create article in magazine",
+ "name": "Create thread in magazine",
"protocolProfileBehavior": {
"disabledSystemHeaders": {
"accept": true
@@ -1392,7 +1392,7 @@
}
},
"url": {
- "raw": "https://{{host}}/api/magazine/:magazine_id/article",
+ "raw": "https://{{host}}/api/magazine/:magazine_id/thread",
"protocol": "https",
"host": [
"{{host}}"
@@ -1401,7 +1401,7 @@
"api",
"magazine",
":magazine_id",
- "article"
+ "thread"
],
"variable": [
{
diff --git a/migrations/Version20240831151328.php b/migrations/Version20240831151328.php
new file mode 100644
index 000000000..d680579ca
--- /dev/null
+++ b/migrations/Version20240831151328.php
@@ -0,0 +1,56 @@
+addSql('CREATE SEQUENCE bookmark_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+ $this->addSql('CREATE SEQUENCE bookmark_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+ $this->addSql('CREATE TABLE bookmark (id INT NOT NULL, list_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE INDEX IDX_DA62921D3DAE168B ON bookmark (list_id)');
+ $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)');
+ $this->addSql('CREATE INDEX IDX_DA62921DBA364942 ON bookmark (entry_id)');
+ $this->addSql('CREATE INDEX IDX_DA62921D60C33421 ON bookmark (entry_comment_id)');
+ $this->addSql('CREATE INDEX IDX_DA62921D4B89032C ON bookmark (post_id)');
+ $this->addSql('CREATE INDEX IDX_DA62921DDB1174D2 ON bookmark (post_comment_id)');
+ $this->addSql('CREATE UNIQUE INDEX bookmark_list_entry_entryComment_post_postComment_idx ON bookmark (list_id, entry_id, entry_comment_id, post_id, post_comment_id)');
+ $this->addSql('COMMENT ON COLUMN bookmark.created_at IS \'(DC2Type:datetimetz_immutable)\'');
+ $this->addSql('CREATE TABLE bookmark_list (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_default BOOLEAN NOT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE INDEX IDX_A650C0C4A76ED395 ON bookmark_list (user_id)');
+ $this->addSql('CREATE UNIQUE INDEX UNIQ_A650C0C4A76ED3955E237E06 ON bookmark_list (user_id, name)');
+ $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D3DAE168B FOREIGN KEY (list_id) REFERENCES bookmark_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DDB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE bookmark_list ADD CONSTRAINT FK_A650C0C4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP SEQUENCE bookmark_id_seq CASCADE');
+ $this->addSql('DROP SEQUENCE bookmark_list_id_seq CASCADE');
+ $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D3DAE168B');
+ $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395');
+ $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DBA364942');
+ $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D60C33421');
+ $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D4B89032C');
+ $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DDB1174D2');
+ $this->addSql('ALTER TABLE bookmark_list DROP CONSTRAINT FK_A650C0C4A76ED395');
+ $this->addSql('DROP TABLE bookmark');
+ $this->addSql('DROP TABLE bookmark_list');
+ }
+}
diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php
index 470391322..4485c7143 100644
--- a/src/Controller/Api/BaseApi.php
+++ b/src/Controller/Api/BaseApi.php
@@ -30,6 +30,8 @@
use App\Factory\PostCommentFactory;
use App\Factory\PostFactory;
use App\Form\Constraint\ImageConstraint;
+use App\Repository\BookmarkListRepository;
+use App\Repository\BookmarkRepository;
use App\Repository\Criteria;
use App\Repository\EntryCommentRepository;
use App\Repository\EntryRepository;
@@ -39,12 +41,13 @@
use App\Repository\PostRepository;
use App\Repository\TagLinkRepository;
use App\Schema\PaginationSchema;
+use App\Service\BookmarkManager;
use App\Service\IpResolver;
use App\Service\ReportManager;
use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
-use Pagerfanta\Pagerfanta;
+use Pagerfanta\PagerfantaInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
@@ -85,6 +88,9 @@ public function __construct(
protected readonly EntryCommentRepository $entryCommentRepository,
protected readonly PostRepository $postRepository,
protected readonly PostCommentRepository $postCommentRepository,
+ protected readonly BookmarkListRepository $bookmarkListRepository,
+ protected readonly BookmarkRepository $bookmarkRepository,
+ protected readonly BookmarkManager $bookmarkManager,
private readonly ImageRepository $imageRepository,
private readonly ReportManager $reportManager,
private readonly OAuth2ClientAccessRepository $clientAccessRepository,
@@ -189,7 +195,7 @@ public function getAccessToken(?OAuth2Token $oAuth2Token): ?AccessToken
->findOneBy(['identifier' => $oAuth2Token->getAttribute('access_token_id')]);
}
- public function serializePaginated(array $serializedItems, Pagerfanta $pagerfanta): array
+ public function serializePaginated(array $serializedItems, PagerfantaInterface $pagerfanta): array
{
return [
'items' => $serializedItems,
diff --git a/src/Controller/Api/Bookmark/BookmarkApiController.php b/src/Controller/Api/Bookmark/BookmarkApiController.php
new file mode 100644
index 000000000..e7a79f9f5
--- /dev/null
+++ b/src/Controller/Api/Bookmark/BookmarkApiController.php
@@ -0,0 +1,265 @@
+getUserOrThrow();
+ $headers = $this->rateLimit($apiUpdateLimiter);
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);
+ if (null === $subject) {
+ throw new NotFoundHttpException(code: 404, headers: $headers);
+ }
+ $this->bookmarkManager->addBookmarkToDefaultList($user, $subject);
+
+ return new JsonResponse(status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Add a bookmark for the subject in the specified list',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: null
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 404,
+ description: 'The specified subject or list does not exist',
+ content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Parameter(
+ name: 'subject_id',
+ description: 'The id of the subject to be added to the specified list',
+ in: 'path',
+ schema: new OA\Schema(type: 'integer')
+ )]
+ #[OA\Parameter(
+ name: 'subject_type',
+ description: 'the type of the subject',
+ in: 'path',
+ schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])
+ )]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:add'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:ADD')]
+ public function subjectBookmarkToList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiUpdateLimiter);
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);
+ if (null === $subject) {
+ throw new NotFoundHttpException(code: 404, headers: $headers);
+ }
+ $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);
+ if (null === $list) {
+ throw new NotFoundHttpException(code: 404, headers: $headers);
+ }
+ $this->bookmarkManager->addBookmark($user, $list, $subject);
+
+ return new JsonResponse(status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Remove bookmark for the subject from the specified list',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: null
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 404,
+ description: 'The specified subject or list does not exist',
+ content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Parameter(
+ name: 'subject_id',
+ description: 'The id of the subject to be removed',
+ in: 'path',
+ schema: new OA\Schema(type: 'integer')
+ )]
+ #[OA\Parameter(
+ name: 'subject_type',
+ description: 'the type of the subject',
+ in: 'path',
+ schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])
+ )]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')]
+ public function subjectRemoveBookmarkFromList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiUpdateLimiter);
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);
+ if (null === $subject) {
+ throw new NotFoundHttpException(code: 404, headers: $headers);
+ }
+ $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);
+ if (null === $list) {
+ throw new NotFoundHttpException(code: 404, headers: $headers);
+ }
+ $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subject);
+
+ return new JsonResponse(status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Remove all bookmarks for the subject',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: null
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 404,
+ description: 'The specified subject does not exist',
+ content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Parameter(
+ name: 'subject_id',
+ description: 'The id of the subject to be removed',
+ in: 'path',
+ schema: new OA\Schema(type: 'integer')
+ )]
+ #[OA\Parameter(
+ name: 'subject_type',
+ description: 'the type of the subject',
+ in: 'path',
+ schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])
+ )]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')]
+ public function subjectRemoveBookmarks(int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiUpdateLimiter);
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);
+ if (null === $subject) {
+ throw new NotFoundHttpException(code: 404, headers: $headers);
+ }
+ $this->bookmarkRepository->removeAllBookmarksForContent($user, $subject);
+
+ return new JsonResponse(status: 200, headers: $headers);
+ }
+}
diff --git a/src/Controller/Api/Bookmark/BookmarkListApiController.php b/src/Controller/Api/Bookmark/BookmarkListApiController.php
new file mode 100644
index 000000000..d7e5c10bc
--- /dev/null
+++ b/src/Controller/Api/Bookmark/BookmarkListApiController.php
@@ -0,0 +1,378 @@
+getUserOrThrow();
+ $headers = $this->rateLimit($apiReadLimiter);
+ $criteria = new EntryPageView($p ?? 1);
+ $criteria->setTime($criteria->resolveTime($time ?? Criteria::TIME_ALL));
+ $criteria->setType($criteria->resolveType($type ?? 'all'));
+ $criteria->showSortOption($criteria->resolveSort($sort ?? Criteria::SORT_NEW));
+ $criteria->setFederation($federation ?? Criteria::AP_ALL);
+
+ if (null !== $list_id) {
+ $bookmarkList = $this->bookmarkListRepository->findOneBy(['id' => $list_id, 'user' => $user]);
+ if (null === $bookmarkList) {
+ return new JsonResponse(status: 404, headers: $headers);
+ }
+ } else {
+ $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user);
+ }
+ $pagerfanta = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria, $perPage);
+ $objects = $pagerfanta->getCurrentPageResults();
+ $items = array_map(fn (ContentInterface $item) => $this->serializeContentInterface($item), $objects);
+ $result = $this->serializePaginated($items, $pagerfanta);
+
+ return new JsonResponse($result, status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Returns all bookmark lists from the user',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(
+ property: 'items',
+ type: 'array',
+ items: new OA\Items(ref: new Model(type: BookmarkListDto::class))
+ ),
+ ],
+ type: 'object'
+ )
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:list:read'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:READ')]
+ public function list(RateLimiterFactory $apiReadLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiReadLimiter);
+ $items = array_map(fn (BookmarkList $list) => BookmarkListDto::fromList($list), $this->bookmarkListRepository->findByUser($user));
+ $response = [
+ 'items' => $items,
+ ];
+
+ return new JsonResponse($response, status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Sets the provided list as the default',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: null
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 404,
+ description: 'The requested list does not exist',
+ content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Parameter(
+ name: 'list_name',
+ description: 'The name of the list to be made the default',
+ in: 'path',
+ schema: new OA\Schema(type: 'string')
+ )]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')]
+ public function makeDefault(string $list_name, RateLimiterFactory $apiUpdateLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiUpdateLimiter);
+ $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);
+ if (null === $list) {
+ throw new NotFoundHttpException(headers: $headers);
+ }
+ $this->bookmarkListRepository->makeListDefault($user, $list);
+
+ return new JsonResponse(status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Edits the supplied list',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new Model(type: BookmarkListDto::class),
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 404,
+ description: 'The requested list does not exist',
+ content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Parameter(
+ name: 'list_name',
+ description: 'The name of the list to be edited',
+ in: 'path',
+ schema: new OA\Schema(type: 'string')
+ )]
+ #[OA\RequestBody(content: new Model(
+ type: BookmarkListDto::class,
+ groups: ['common']
+ ))]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')]
+ public function editList(string $list_name, #[MapRequestPayload] BookmarkListDto $dto, RateLimiterFactory $apiUpdateLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiUpdateLimiter);
+ $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);
+ if (null === $list) {
+ throw new NotFoundHttpException(headers: $headers);
+ }
+ $this->bookmarkListRepository->editList($user, $list, $dto);
+ $list = $this->bookmarkListRepository->findOneBy(['id' => $list->getId()]);
+
+ return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers);
+ }
+
+ #[OA\Response(
+ response: 200,
+ description: 'Deletes the provided list',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: null
+ )]
+ #[OA\Response(
+ response: 401,
+ description: 'Permission denied due to missing or expired token',
+ content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 404,
+ description: 'The requested list does not exist',
+ content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
+ )]
+ #[OA\Response(
+ response: 429,
+ description: 'You are being rate limited',
+ headers: [
+ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
+ new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
+ ],
+ content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
+ )]
+ #[OA\Parameter(
+ name: 'list_name',
+ description: 'The name of the list to be deleted',
+ in: 'path',
+ schema: new OA\Schema(type: 'string')
+ )]
+ #[OA\Tag(name: 'bookmark:list')]
+ #[Security(name: 'oauth2', scopes: ['user:bookmark:list:delete'])]
+ #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE')]
+ public function deleteList(string $list_name, RateLimiterFactory $apiDeleteLimiter): JsonResponse
+ {
+ $user = $this->getUserOrThrow();
+ $headers = $this->rateLimit($apiDeleteLimiter);
+ $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);
+ if (null === $list) {
+ throw new NotFoundHttpException(headers: $headers);
+ }
+ $this->bookmarkListRepository->deleteList($list);
+
+ return new JsonResponse(status: 200, headers: $headers);
+ }
+}
diff --git a/src/Controller/Api/Entry/EntriesUpdateApi.php b/src/Controller/Api/Entry/EntriesUpdateApi.php
index 80221433f..86f49ae0f 100644
--- a/src/Controller/Api/Entry/EntriesUpdateApi.php
+++ b/src/Controller/Api/Entry/EntriesUpdateApi.php
@@ -65,7 +65,7 @@ class EntriesUpdateApi extends EntriesBaseApi
type: EntryRequestDto::class,
groups: [
'common',
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
]
))]
#[OA\Tag(name: 'entry')]
@@ -84,7 +84,7 @@ public function __invoke(
$dto = $this->deserializeEntry($manager->createDto($entry), context: [
'groups' => [
'common',
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
],
]);
diff --git a/src/Controller/Api/Entry/MagazineEntryCreateApi.php b/src/Controller/Api/Entry/MagazineEntryCreateApi.php
index 9c6d08e3f..d31205976 100644
--- a/src/Controller/Api/Entry/MagazineEntryCreateApi.php
+++ b/src/Controller/Api/Entry/MagazineEntryCreateApi.php
@@ -78,14 +78,14 @@ class MagazineEntryCreateApi extends EntriesBaseApi
#[OA\RequestBody(content: new Model(
type: EntryRequestDto::class,
groups: [
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
'common',
]
))]
#[OA\Tag(name: 'magazine')]
#[Security(name: 'oauth2', scopes: ['entry:create'])]
#[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')]
- public function article(
+ public function thread(
#[MapEntity(id: 'magazine_id')]
Magazine $magazine,
EntryManager $manager,
@@ -95,7 +95,7 @@ public function article(
$entry = $this->createEntry($magazine, $manager, context: [
'groups' => [
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
'common',
],
]);
diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php
index c92b4a0ec..f2499fcad 100644
--- a/src/Controller/Api/Search/SearchRetrieveApi.php
+++ b/src/Controller/Api/Search/SearchRetrieveApi.php
@@ -103,6 +103,27 @@ class SearchRetrieveApi extends BaseApi
required: true,
schema: new OA\Schema(type: 'string')
)]
+ #[OA\Parameter(
+ name: 'authorId',
+ description: 'User id of the author',
+ in: 'query',
+ required: false,
+ schema: new OA\Schema(type: 'integer')
+ )]
+ #[OA\Parameter(
+ name: 'magazineId',
+ description: 'Id of the magazine',
+ in: 'query',
+ required: false,
+ schema: new OA\Schema(type: 'integer')
+ )]
+ #[OA\Parameter(
+ name: 'type',
+ description: 'The type of content',
+ in: 'query',
+ required: false,
+ schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post'])
+ )]
#[OA\Tag(name: 'search')]
public function __invoke(
SearchManager $manager,
@@ -122,8 +143,16 @@ public function __invoke(
$page = $this->getPageNb($request);
$perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE));
+ $authorIdRaw = $request->get('authorId');
+ $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw);
+ $magazineIdRaw = $request->get('magazineId');
+ $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw);
+ $type = $request->get('type');
+ if ('entry' !== $type && 'post' !== $type && null !== $type) {
+ throw new BadRequestHttpException();
+ }
- $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage);
+ $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type);
$dtos = [];
foreach ($items->getCurrentPageResults() as $value) {
\assert($value instanceof ContentInterface);
diff --git a/src/Controller/Api/User/UserRetrieveApi.php b/src/Controller/Api/User/UserRetrieveApi.php
index 97ce14fef..ab95009af 100644
--- a/src/Controller/Api/User/UserRetrieveApi.php
+++ b/src/Controller/Api/User/UserRetrieveApi.php
@@ -275,6 +275,18 @@ public function settings(
in: 'query',
schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS)
)]
+ #[OA\Parameter(
+ name: 'q',
+ description: 'The term to search for',
+ in: 'query',
+ schema: new OA\Schema(type: 'string')
+ )]
+ #[OA\Parameter(
+ name: 'withAbout',
+ description: 'Only include users with a filled in profile',
+ in: 'query',
+ schema: new OA\Schema(type: 'boolean')
+ )]
#[OA\Tag(name: 'user')]
public function collection(
UserRepository $userRepository,
@@ -286,11 +298,15 @@ public function collection(
$request = $this->request->getCurrentRequest();
$group = $request->get('group', UserRepository::USERS_ALL);
+ $withAboutRaw = $request->get('withAbout');
+ $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw);
- $users = $userRepository->findWithAboutPaginated(
+ $users = $userRepository->findPaginated(
$this->getPageNb($request),
+ $withAbout,
$group,
- $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))
+ $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)),
+ $request->get('q'),
);
$dtos = [];
diff --git a/src/Controller/BookmarkController.php b/src/Controller/BookmarkController.php
new file mode 100644
index 000000000..55f4c1b18
--- /dev/null
+++ b/src/Controller/BookmarkController.php
@@ -0,0 +1,145 @@
+entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);
+ $this->bookmarkManager->addBookmarkToDefaultList($this->getUserOrThrow(), $subjectEntity);
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('components/_ajax.html.twig', [
+ 'component' => 'bookmark_standard',
+ 'attributes' => [
+ 'subject' => $subjectEntity,
+ 'subjectClass' => $subjectClass,
+ ],
+ ]
+ ),
+ ]);
+ }
+
+ return $this->redirect($request->headers->get('Referer'));
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function subjectBookmarkRefresh(int $subject_id, string $subject_type, Request $request): Response
+ {
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('components/_ajax.html.twig', [
+ 'component' => 'bookmark_standard',
+ 'attributes' => [
+ 'subject' => $subjectEntity,
+ 'subjectClass' => $subjectClass,
+ ],
+ ]
+ ),
+ ]);
+ }
+
+ return $this->redirect($request->headers->get('Referer'));
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function subjectBookmarkToList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response
+ {
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);
+ $user = $this->getUserOrThrow();
+ if ($user->getId() !== $list->user->getId()) {
+ throw new AccessDeniedHttpException();
+ }
+ $this->bookmarkManager->addBookmark($user, $list, $subjectEntity);
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('components/_ajax.html.twig', [
+ 'component' => 'bookmark_list',
+ 'attributes' => [
+ 'subject' => $subjectEntity,
+ 'subjectClass' => $subjectClass,
+ 'list' => $list,
+ ],
+ ]
+ ),
+ ]);
+ }
+
+ return $this->redirect($request->headers->get('Referer'));
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function subjectRemoveBookmarks(int $subject_id, string $subject_type, Request $request): Response
+ {
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);
+ $this->bookmarkRepository->removeAllBookmarksForContent($this->getUserOrThrow(), $subjectEntity);
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('components/_ajax.html.twig', [
+ 'component' => 'bookmark_standard',
+ 'attributes' => [
+ 'subject' => $subjectEntity,
+ 'subjectClass' => $subjectClass,
+ ],
+ ]
+ ),
+ ]);
+ }
+
+ return $this->redirect($request->headers->get('Referer'));
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function subjectRemoveBookmarkFromList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response
+ {
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);
+ $user = $this->getUserOrThrow();
+ if ($user->getId() !== $list->user->getId()) {
+ throw new AccessDeniedHttpException();
+ }
+ $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subjectEntity);
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('components/_ajax.html.twig', [
+ 'component' => 'bookmark_list',
+ 'attributes' => [
+ 'subject' => $subjectEntity,
+ 'subjectClass' => $subjectClass,
+ 'list' => $list,
+ ],
+ ]
+ ),
+ ]);
+ }
+
+ return $this->redirect($request->headers->get('Referer'));
+ }
+}
diff --git a/src/Controller/BookmarkListController.php b/src/Controller/BookmarkListController.php
new file mode 100644
index 000000000..098f5bcbe
--- /dev/null
+++ b/src/Controller/BookmarkListController.php
@@ -0,0 +1,180 @@
+getPageNb($request);
+ $user = $this->getUserOrThrow();
+ $criteria = new EntryPageView($page);
+ $criteria->setTime($criteria->resolveTime($time));
+ $criteria->setType($criteria->resolveType($type));
+ $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_NEW));
+ $criteria->setFederation($federation);
+
+ if (null !== $list) {
+ $bookmarkList = $this->bookmarkListRepository->findOneByUserAndName($user, $list);
+ } else {
+ $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user);
+ }
+ $res = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria);
+ $objects = $res->getCurrentPageResults();
+ $lists = $this->bookmarkListRepository->findByUser($user);
+
+ $this->logger->info('got results in list {l}: {r}', ['l' => $list, 'r' => $objects]);
+
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('layout/_subject_list.html.twig', [
+ 'results' => $objects,
+ 'pagination' => $res,
+ ]),
+ ]);
+ }
+
+ return $this->render(
+ 'bookmark/front.html.twig',
+ [
+ 'criteria' => $criteria,
+ 'list' => $bookmarkList,
+ 'lists' => $lists,
+ 'results' => $objects,
+ 'pagination' => $res,
+ ]
+ );
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function list(Request $request): Response
+ {
+ $user = $this->getUserOrThrow();
+ $dto = new BookmarkListDto();
+ $form = $this->createForm(BookmarkListType::class, $dto);
+ $form->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var BookmarkListDto $dto */
+ $dto = $form->getData();
+ $list = $this->bookmarkManager->createList($user, $dto->name);
+ if ($dto->isDefault) {
+ $this->bookmarkListRepository->makeListDefault($user, $list);
+ }
+
+ return $this->redirectToRoute('bookmark_lists');
+ }
+
+ return $this->render('bookmark/overview.html.twig', [
+ 'lists' => $this->bookmarkListRepository->findByUser($user),
+ 'form' => $form->createView(),
+ ],
+ new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)
+ );
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function subjectBookmarkMenuListRefresh(int $subject_id, string $subject_type, Request $request): Response
+ {
+ $user = $this->getUserOrThrow();
+ $bookmarkLists = $this->bookmarkListRepository->findByUser($user);
+ $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);
+ $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);
+ if ($request->isXmlHttpRequest()) {
+ return new JsonResponse([
+ 'html' => $this->renderView('components/_ajax.html.twig', [
+ 'component' => 'bookmark_menu_list',
+ 'attributes' => [
+ 'subject' => $subjectEntity,
+ 'subjectClass' => $subjectClass,
+ 'bookmarkLists' => $bookmarkLists,
+ ],
+ ]
+ ),
+ ]);
+ }
+
+ return $this->redirect($request->headers->get('Referer'));
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function makeDefault(#[MapQueryParameter] ?int $makeDefault): Response
+ {
+ $user = $this->getUserOrThrow();
+ $this->logger->info('making list id {id} default for user {u}', ['user' => $user->username, 'id' => $makeDefault]);
+ if (null !== $makeDefault) {
+ $list = $this->bookmarkListRepository->findOneBy(['id' => $makeDefault]);
+ $this->bookmarkListRepository->makeListDefault($user, $list);
+ }
+
+ return $this->redirectToRoute('bookmark_lists');
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function editList(#[MapEntity] BookmarkList $list, Request $request): Response
+ {
+ $user = $this->getUserOrThrow();
+ $dto = BookmarkListDto::fromList($list);
+ $form = $this->createForm(BookmarkListType::class, $dto);
+ $form->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ $dto = $form->getData();
+ $this->bookmarkListRepository->editList($user, $list, $dto);
+
+ return $this->redirectToRoute('bookmark_lists');
+ }
+
+ return $this->render('bookmark/edit.html.twig', [
+ 'list' => $list,
+ 'form' => $form->createView(),
+ ]);
+ }
+
+ #[IsGranted('ROLE_USER')]
+ public function deleteList(#[MapEntity] BookmarkList $list): Response
+ {
+ $user = $this->getUserOrThrow();
+ if ($user->getId() !== $list->user->getId()) {
+ $this->logger->error('user {u} tried to delete a list that is not his own: {l}', ['u' => $user->username, 'l' => "$list->name ({$list->getId()})"]);
+ throw new AccessDeniedHttpException();
+ }
+ $this->bookmarkListRepository->deleteList($list);
+
+ return $this->redirectToRoute('bookmark_lists');
+ }
+}
diff --git a/src/Controller/Entry/EntryFormTrait.php b/src/Controller/Entry/EntryFormTrait.php
index 400e47224..d00da5038 100644
--- a/src/Controller/Entry/EntryFormTrait.php
+++ b/src/Controller/Entry/EntryFormTrait.php
@@ -18,7 +18,7 @@ trait EntryFormTrait
{
private function createFormByType(string $type, ?EntryDto $dto = null): FormInterface
{
- if (Entry::ENTRY_TYPE_ARTICLE === $type) {
+ if (Entry::ENTRY_TYPE_THREAD === $type) {
return $this->createForm(EntryArticleType::class, $dto);
}
diff --git a/src/Controller/Entry/EntryTemplateTrait.php b/src/Controller/Entry/EntryTemplateTrait.php
index 514ff2855..2c38aaa6f 100644
--- a/src/Controller/Entry/EntryTemplateTrait.php
+++ b/src/Controller/Entry/EntryTemplateTrait.php
@@ -12,8 +12,8 @@ private function getTemplateName(?string $type, ?bool $edit = false): string
{
$prefix = $edit ? 'edit' : 'create';
- if (!$type || Entry::ENTRY_TYPE_ARTICLE === $type) {
- return "entry/{$prefix}_article.html.twig";
+ if (!$type || Entry::ENTRY_TYPE_THREAD === $type) {
+ return "entry/{$prefix}_thread.html.twig";
}
if (Entry::ENTRY_TYPE_IMAGE === $type) {
diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php
index c7451b83e..1bbe8ea38 100644
--- a/src/Controller/SearchController.php
+++ b/src/Controller/SearchController.php
@@ -5,8 +5,10 @@
namespace App\Controller;
use App\ActivityPub\ActorHandle;
+use App\DTO\SearchDto;
use App\Entity\Magazine;
use App\Entity\User;
+use App\Form\SearchType;
use App\Message\ActivityPub\Inbox\ActivityMessage;
use App\Service\ActivityPub\ApHttpClient;
use App\Service\ActivityPubManager;
@@ -33,52 +35,63 @@ public function __construct(
public function __invoke(Request $request): Response
{
- $query = $request->query->get('q') ? trim($request->query->get('q')) : null;
-
- if (!$query) {
- return $this->render(
- 'search/front.html.twig',
- [
- 'objects' => [],
- 'results' => [],
- 'q' => '',
- ]
- );
- }
-
- $this->logger->debug('searching for {query}', ['query' => $query]);
-
- $objects = [];
+ $dto = new SearchDto();
+ $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]);
+ try {
+ $form = $form->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var SearchDto $dto */
+ $dto = $form->getData();
+ $query = $dto->q;
+ $this->logger->debug('searching for {query}', ['query' => $query]);
+
+ $objects = [];
+
+ // looking up handles (users and mags)
+ if (str_contains($query, '@') && $this->federatedSearchAllowed()) {
+ if ($handle = ActorHandle::parse($query)) {
+ $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]);
+ $objects = array_merge($objects, $this->lookupHandle($handle));
+ } else {
+ $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]);
+ }
+ }
- // looking up handles (users and mags)
- if (str_contains($query, '@') && $this->federatedSearchAllowed()) {
- if ($handle = ActorHandle::parse($query)) {
- $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]);
- $objects = array_merge($objects, $this->lookupHandle($handle));
- } else {
- $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]);
- }
- }
+ // looking up object by AP id (i.e. urls)
+ if (false !== filter_var($query, FILTER_VALIDATE_URL)) {
+ $objects = $this->manager->findByApId($query);
+ if (!$objects) {
+ $body = $this->apHttpClient->getActivityObject($query, false);
+ $this->bus->dispatch(new ActivityMessage($body));
+ }
+ }
- // looking up object by AP id (i.e. urls)
- if (false !== filter_var($query, FILTER_VALIDATE_URL)) {
- $objects = $this->manager->findByApId($query);
- if (!$objects) {
- $body = $this->apHttpClient->getActivityObject($query, false);
- $this->bus->dispatch(new ActivityMessage($body));
+ $user = $this->getUser();
+ $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type);
+
+ $this->logger->debug('results: {num}', ['num' => $res->count()]);
+
+ return $this->render(
+ 'search/front.html.twig',
+ [
+ 'objects' => $objects,
+ 'results' => $this->overviewManager->buildList($res),
+ 'pagination' => $res,
+ 'form' => $form->createView(),
+ 'q' => $query,
+ ]
+ );
}
+ } catch (\Exception $e) {
+ $this->logger->error($e);
}
- $user = $this->getUser();
- $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request));
-
return $this->render(
'search/front.html.twig',
[
- 'objects' => $objects,
- 'results' => $this->overviewManager->buildList($res),
- 'pagination' => $res,
- 'q' => $request->query->get('q'),
+ 'objects' => [],
+ 'results' => [],
+ 'form' => $form->createView(),
]
);
}
diff --git a/src/DTO/BookmarkListDto.php b/src/DTO/BookmarkListDto.php
new file mode 100644
index 000000000..39bc3d8e2
--- /dev/null
+++ b/src/DTO/BookmarkListDto.php
@@ -0,0 +1,42 @@
+name = $list->name;
+ $dto->isDefault = $list->isDefault;
+ $dto->count = $list->entities->count();
+
+ return $dto;
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'isDefault' => $this->isDefault,
+ 'count' => $this->count,
+ ];
+ }
+}
diff --git a/src/DTO/ContentRequestDto.php b/src/DTO/ContentRequestDto.php
index b223b1ac3..cce28498c 100644
--- a/src/DTO/ContentRequestDto.php
+++ b/src/DTO/ContentRequestDto.php
@@ -12,7 +12,7 @@
class ContentRequestDto extends ImageUploadDto
{
#[Groups([
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
Entry::ENTRY_TYPE_LINK,
'post',
'comment',
diff --git a/src/DTO/EntryDto.php b/src/DTO/EntryDto.php
index fb8eff5cc..c81ad52b7 100644
--- a/src/DTO/EntryDto.php
+++ b/src/DTO/EntryDto.php
@@ -31,7 +31,7 @@ class EntryDto implements ContentVisibilityInterface
#[Assert\Length(max: Entry::MAX_BODY_LENGTH)]
public ?string $body = null;
public ?string $lang = null;
- public string $type = Entry::ENTRY_TYPE_ARTICLE;
+ public string $type = Entry::ENTRY_TYPE_THREAD;
public int $comments = 0;
public int $uv = 0;
public int $dv = 0;
@@ -145,7 +145,7 @@ public function getType(): string
$type = Entry::ENTRY_TYPE_IMAGE;
if ($this->body) {
- $type = Entry::ENTRY_TYPE_ARTICLE;
+ $type = Entry::ENTRY_TYPE_THREAD;
}
return $type;
diff --git a/src/DTO/EntryRequestDto.php b/src/DTO/EntryRequestDto.php
index 3068fbd45..d23bb8466 100644
--- a/src/DTO/EntryRequestDto.php
+++ b/src/DTO/EntryRequestDto.php
@@ -13,7 +13,7 @@
class EntryRequestDto extends ContentRequestDto
{
#[Groups([
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
Entry::ENTRY_TYPE_LINK,
Entry::ENTRY_TYPE_IMAGE,
Entry::ENTRY_TYPE_VIDEO,
@@ -26,7 +26,7 @@ class EntryRequestDto extends ContentRequestDto
])]
public ?string $url = null;
#[Groups([
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
Entry::ENTRY_TYPE_LINK,
Entry::ENTRY_TYPE_IMAGE,
Entry::ENTRY_TYPE_VIDEO,
@@ -36,7 +36,7 @@ class EntryRequestDto extends ContentRequestDto
// TODO: Support badges whenever/however they're implemented
// #[Groups([
- // Entry::ENTRY_TYPE_ARTICLE,
+ // Entry::ENTRY_TYPE_THREAD,
// Entry::ENTRY_TYPE_LINK,
// Entry::ENTRY_TYPE_IMAGE,
// Entry::ENTRY_TYPE_VIDEO,
@@ -45,7 +45,7 @@ class EntryRequestDto extends ContentRequestDto
// public ?array $badges = null;
#[Groups([
- Entry::ENTRY_TYPE_ARTICLE,
+ Entry::ENTRY_TYPE_THREAD,
Entry::ENTRY_TYPE_LINK,
Entry::ENTRY_TYPE_IMAGE,
Entry::ENTRY_TYPE_VIDEO,
diff --git a/src/DTO/EntryResponseDto.php b/src/DTO/EntryResponseDto.php
index 8d761a624..40e06db6a 100644
--- a/src/DTO/EntryResponseDto.php
+++ b/src/DTO/EntryResponseDto.php
@@ -40,7 +40,7 @@ class EntryResponseDto implements \JsonSerializable
public ?\DateTimeImmutable $createdAt = null;
public ?\DateTimeImmutable $editedAt = null;
public ?\DateTime $lastActive = null;
- #[OA\Property(example: Entry::ENTRY_TYPE_ARTICLE, enum: Entry::ENTRY_TYPE_OPTIONS)]
+ #[OA\Property(example: Entry::ENTRY_TYPE_THREAD, enum: Entry::ENTRY_TYPE_OPTIONS)]
public ?string $type = null;
public ?string $slug = null;
public ?string $apId = null;
diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php
index a35598d40..4d4eed9af 100644
--- a/src/DTO/OAuth2ClientDto.php
+++ b/src/DTO/OAuth2ClientDto.php
@@ -62,6 +62,13 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable
'user:profile',
'user:profile:read',
'user:profile:edit',
+ 'user:bookmark',
+ 'user:bookmark:add',
+ 'user:bookmark:remove',
+ 'user:bookmark:list',
+ 'user:bookmark:list:read',
+ 'user:bookmark:list:edit',
+ 'user:bookmark:list:delete',
'user:message',
'user:message:read',
'user:message:create',
diff --git a/src/DTO/SearchDto.php b/src/DTO/SearchDto.php
index c9070f0df..de26533c6 100644
--- a/src/DTO/SearchDto.php
+++ b/src/DTO/SearchDto.php
@@ -4,7 +4,13 @@
namespace App\DTO;
+use App\Entity\Magazine;
+use App\Entity\User;
+
class SearchDto
{
- public string $val;
+ public string $q;
+ public ?string $type = null;
+ public ?User $user = null;
+ public ?Magazine $magazine = null;
}
diff --git a/src/Entity/Bookmark.php b/src/Entity/Bookmark.php
new file mode 100644
index 000000000..3902e3948
--- /dev/null
+++ b/src/Entity/Bookmark.php
@@ -0,0 +1,70 @@
+user = $user;
+ $this->list = $list;
+ $this->createdAtTraitConstruct();
+ }
+
+ public function setContent(Post|EntryComment|PostComment|Entry $content): void
+ {
+ if ($content instanceof Entry) {
+ $this->entry = $content;
+ } elseif ($content instanceof EntryComment) {
+ $this->entryComment = $content;
+ } elseif ($content instanceof Post) {
+ $this->post = $content;
+ } elseif ($content instanceof PostComment) {
+ $this->postComment = $content;
+ }
+ }
+
+ public function getContent(): Entry|EntryComment|Post|PostComment
+ {
+ return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment;
+ }
+}
diff --git a/src/Entity/BookmarkList.php b/src/Entity/BookmarkList.php
new file mode 100644
index 000000000..52673e1cf
--- /dev/null
+++ b/src/Entity/BookmarkList.php
@@ -0,0 +1,51 @@
+user = $user;
+ $this->name = $name;
+ $this->isDefault = $isDefault;
+ $this->entities = new ArrayCollection();
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+}
diff --git a/src/Entity/Entry.php b/src/Entity/Entry.php
index 75f96955d..745ab6a97 100644
--- a/src/Entity/Entry.php
+++ b/src/Entity/Entry.php
@@ -56,12 +56,12 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi
CreatedAtTrait::__construct as createdAtTraitConstruct;
}
- public const ENTRY_TYPE_ARTICLE = 'article';
+ public const ENTRY_TYPE_THREAD = 'thread';
public const ENTRY_TYPE_LINK = 'link';
public const ENTRY_TYPE_IMAGE = 'image';
public const ENTRY_TYPE_VIDEO = 'video';
public const ENTRY_TYPE_OPTIONS = [
- self::ENTRY_TYPE_ARTICLE,
+ self::ENTRY_TYPE_THREAD,
self::ENTRY_TYPE_LINK,
self::ENTRY_TYPE_IMAGE,
self::ENTRY_TYPE_VIDEO,
@@ -90,7 +90,7 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi
#[Column(type: 'text', length: self::MAX_BODY_LENGTH, nullable: true)]
public ?string $body = null;
#[Column(type: 'string', nullable: false)]
- public string $type = self::ENTRY_TYPE_ARTICLE;
+ public string $type = self::ENTRY_TYPE_THREAD;
#[Column(type: 'string', nullable: false)]
public string $lang = 'en';
#[Column(type: 'boolean', options: ['default' => false])]
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 8c39eca36..8095a2300 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -223,6 +223,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil
public Collection $notifications;
#[OneToMany(mappedBy: 'user', targetEntity: UserPushSubscription::class, fetch: 'EXTRA_LAZY')]
public Collection $pushSubscriptions;
+ #[OneToMany(mappedBy: 'user', targetEntity: BookmarkList::class, fetch: 'EXTRA_LAZY')]
+ public Collection $bookmarkLists;
#[Id]
#[GeneratedValue]
#[Column(type: 'integer')]
diff --git a/src/Form/BookmarkListType.php b/src/Form/BookmarkListType.php
new file mode 100644
index 000000000..f10ba5cf1
--- /dev/null
+++ b/src/Form/BookmarkListType.php
@@ -0,0 +1,35 @@
+add('name', TextType::class)
+ ->add('isDefault', CheckboxType::class, [
+ 'required' => false,
+ ])
+ ->add('submit', SubmitType::class);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults(
+ [
+ 'data_class' => BookmarkListDto::class,
+ ]
+ );
+ }
+}
diff --git a/src/Form/SearchType.php b/src/Form/SearchType.php
new file mode 100644
index 000000000..5d7410e50
--- /dev/null
+++ b/src/Form/SearchType.php
@@ -0,0 +1,36 @@
+setMethod('GET')
+ ->add('q', TextType::class, [
+ 'required' => true,
+ 'attr' => [
+ 'placeholder' => 'type_search_term',
+ ],
+ ])
+ ->add('magazine', MagazineAutocompleteType::class, ['required' => false])
+ ->add('user', UserAutocompleteType::class, ['required' => false])
+ ->add('type', ChoiceType::class, [
+ 'choices' => [
+ 'search_type_all' => null,
+ 'search_type_entry' => 'entry',
+ 'search_type_post' => 'post',
+ ],
+ ]);
+ }
+}
diff --git a/src/Form/Type/UserAutocompleteType.php b/src/Form/Type/UserAutocompleteType.php
new file mode 100644
index 000000000..d1cc01909
--- /dev/null
+++ b/src/Form/Type/UserAutocompleteType.php
@@ -0,0 +1,59 @@
+setDefaults([
+ 'class' => User::class,
+ 'choice_label' => 'username',
+ 'placeholder' => 'select_user',
+ 'filter_query' => function (QueryBuilder $qb, string $query) {
+ if ($currentUser = $this->security->getUser()) {
+ $qb
+ ->andWhere(
+ \sprintf(
+ 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)',
+ UserBlock::class,
+ )
+ )
+ ->setParameter('user', $currentUser);
+ }
+
+ if (!$query) {
+ return;
+ }
+
+ $qb->andWhere('entity.username LIKE :filter')
+ ->andWhere('entity.visibility = :visibility')
+ ->setParameter('filter', '%'.$query.'%')
+ ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)
+ ;
+ },
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return BaseEntityAutocompleteType::class;
+ }
+}
diff --git a/src/Pagination/NativeQueryAdapter.php b/src/Pagination/NativeQueryAdapter.php
index 959d47b60..4888ca604 100644
--- a/src/Pagination/NativeQueryAdapter.php
+++ b/src/Pagination/NativeQueryAdapter.php
@@ -8,7 +8,9 @@
use App\Pagination\Transformation\VoidTransformer;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Statement;
+use Doctrine\DBAL\Types\Types;
use Pagerfanta\Adapter\AdapterInterface;
/**
@@ -35,7 +37,7 @@ public function __construct(
$sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub';
$stmt2 = $this->conn->prepare($sql2);
foreach ($this->parameters as $key => $value) {
- $stmt2->bindValue($key, $value);
+ $stmt2->bindValue($key, $value, $this->getSqlType($value));
}
$result = $stmt2->executeQuery()->fetchAllAssociative();
$this->numOfResults = $result[0]['cnt'];
@@ -43,7 +45,7 @@ public function __construct(
$this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset');
foreach ($this->parameters as $key => $value) {
- $this->statement->bindValue($key, $value);
+ $this->statement->bindValue($key, $value, $this->getSqlType($value));
}
}
@@ -59,4 +61,15 @@ public function getSlice(int $offset, int $length): iterable
return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative());
}
+
+ private function getSqlType(mixed $value): mixed
+ {
+ if ($value instanceof \DateTimeImmutable) {
+ return Types::DATETIMETZ_IMMUTABLE;
+ } elseif ($value instanceof \DateTime) {
+ return Types::DATETIMETZ_MUTABLE;
+ }
+
+ return ParameterType::STRING;
+ }
}
diff --git a/src/Pagination/Transformation/ContentPopulationTransformer.php b/src/Pagination/Transformation/ContentPopulationTransformer.php
index 522e98d97..41b005136 100644
--- a/src/Pagination/Transformation/ContentPopulationTransformer.php
+++ b/src/Pagination/Transformation/ContentPopulationTransformer.php
@@ -19,6 +19,7 @@ public function __construct(
public function transform(iterable $input): iterable
{
+ $positionsArray = $this->buildPositionArray($input);
$entries = $this->entityManager->getRepository(Entry::class)->findBy(
['id' => $this->getOverviewIds((array) $input, 'entry')]
);
@@ -32,10 +33,7 @@ public function transform(iterable $input): iterable
['id' => $this->getOverviewIds((array) $input, 'post_comment')]
);
- $result = array_merge($entries, $entryComments, $post, $postComment);
- uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1);
-
- return $result;
+ return $this->applyPositions($positionsArray, $entries, $entryComments, $post, $postComment);
}
private function getOverviewIds(array $result, string $type): array
@@ -44,4 +42,67 @@ private function getOverviewIds(array $result, string $type): array
return array_map(fn ($subject) => $subject['id'], $result);
}
+
+ /**
+ * @return int[][]
+ */
+ private function buildPositionArray(iterable $input): array
+ {
+ $entryPositions = [];
+ $entryCommentPositions = [];
+ $postPositions = [];
+ $postCommentPositions = [];
+ $i = 0;
+ foreach ($input as $current) {
+ switch ($current['type']) {
+ case 'entry':
+ $entryPositions[$current['id']] = $i;
+ break;
+ case 'entry_comment':
+ $entryCommentPositions[$current['id']] = $i;
+ break;
+ case 'post':
+ $postPositions[$current['id']] = $i;
+ break;
+ case 'post_comment':
+ $postCommentPositions[$current['id']] = $i;
+ break;
+ }
+ ++$i;
+ }
+
+ return [
+ 'entry' => $entryPositions,
+ 'entry_comment' => $entryCommentPositions,
+ 'post' => $postPositions,
+ 'post_comment' => $postCommentPositions,
+ ];
+ }
+
+ /**
+ * @param int[][] $positionsArray
+ * @param Entry[] $entries
+ * @param EntryComment[] $entryComments
+ * @param Post[] $posts
+ * @param PostComment[] $postComments
+ */
+ private function applyPositions(array $positionsArray, array $entries, array $entryComments, array $posts, array $postComments): array
+ {
+ $result = [];
+ foreach ($entries as $entry) {
+ $result[$positionsArray['entry'][$entry->getId()]] = $entry;
+ }
+ foreach ($entryComments as $entryComment) {
+ $result[$positionsArray['entry_comment'][$entryComment->getId()]] = $entryComment;
+ }
+ foreach ($posts as $post) {
+ $result[$positionsArray['post'][$post->getId()]] = $post;
+ }
+ foreach ($postComments as $postComment) {
+ $result[$positionsArray['post_comment'][$postComment->getId()]] = $postComment;
+ }
+ ksort($result, SORT_NUMERIC);
+
+ return $result;
+ }
}
diff --git a/src/Repository/BookmarkListRepository.php b/src/Repository/BookmarkListRepository.php
new file mode 100644
index 000000000..c942dc500
--- /dev/null
+++ b/src/Repository/BookmarkListRepository.php
@@ -0,0 +1,83 @@
+findBy(['user' => $user]);
+ }
+
+ public function findOneByUserAndName(User $user, string $name): ?BookmarkList
+ {
+ return $this->findOneBy(['user' => $user, 'name' => $name]);
+ }
+
+ public function findOneByUserDefault(User $user): BookmarkList
+ {
+ $list = $this->findOneBy(['user' => $user, 'isDefault' => true]);
+ if (null === $list) {
+ $list = new BookmarkList($user, 'Default', true);
+ $this->entityManager->persist($list);
+ $this->entityManager->flush();
+ }
+
+ return $list;
+ }
+
+ public function makeListDefault(User $user, BookmarkList $list): void
+ {
+ $sql = 'UPDATE bookmark_list SET is_default = false WHERE user_id = :user';
+ $conn = $this->entityManager->getConnection();
+ $stmt = $conn->prepare($sql);
+ $stmt->executeStatement(['user' => $user->getId()]);
+
+ $sql = 'UPDATE bookmark_list SET is_default = true WHERE user_id = :user AND id = :id';
+ $stmt = $conn->prepare($sql);
+ $stmt->executeStatement(['user' => $user->getId(), 'id' => $list->getId()]);
+ }
+
+ public function deleteList(BookmarkList $list): void
+ {
+ $sql = 'DELETE FROM bookmark_list WHERE id = :id';
+ $conn = $this->entityManager->getConnection();
+ $stmt = $conn->prepare($sql);
+ $stmt->executeStatement(['id' => $list->getId()]);
+ }
+
+ public function editList(User $user, BookmarkList $list, BookmarkListDto $dto): void
+ {
+ $sql = 'UPDATE bookmark_list SET name = :name WHERE id = :id';
+ $conn = $this->entityManager->getConnection();
+ $stmt = $conn->prepare($sql);
+ $stmt->executeStatement(['id' => $list->getId(), 'name' => $dto->name]);
+
+ if ($dto->isDefault) {
+ $this->makeListDefault($user, $list);
+ }
+ }
+}
diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php
new file mode 100644
index 000000000..e8982f4c6
--- /dev/null
+++ b/src/Repository/BookmarkRepository.php
@@ -0,0 +1,162 @@
+createQueryBuilder('b')
+ ->where('b.user = :user')
+ ->andWhere('b.list = :list')
+ ->setParameter('user', $user)
+ ->setParameter('list', $list)
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function removeAllBookmarksForContent(User $user, Entry|EntryComment|Post|PostComment $content): void
+ {
+ if ($content instanceof Entry) {
+ $contentWhere = 'entry_id = :id';
+ } elseif ($content instanceof EntryComment) {
+ $contentWhere = 'entry_comment_id = :id';
+ } elseif ($content instanceof Post) {
+ $contentWhere = 'post_id = :id';
+ } elseif ($content instanceof PostComment) {
+ $contentWhere = 'post_comment_id = :id';
+ } else {
+ throw new \LogicException();
+ }
+
+ $sql = "DELETE FROM bookmark WHERE user_id = :u AND $contentWhere";
+ $conn = $this->entityManager->getConnection();
+ $stmt = $conn->prepare($sql);
+ $stmt->executeStatement(['u' => $user->getId(), 'id' => $content->getId()]);
+ }
+
+ public function removeBookmarkFromList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void
+ {
+ if ($content instanceof Entry) {
+ $contentWhere = 'entry_id = :id';
+ } elseif ($content instanceof EntryComment) {
+ $contentWhere = 'entry_comment_id = :id';
+ } elseif ($content instanceof Post) {
+ $contentWhere = 'post_id = :id';
+ } elseif ($content instanceof PostComment) {
+ $contentWhere = 'post_comment_id = :id';
+ } else {
+ throw new \LogicException();
+ }
+
+ $sql = "DELETE FROM bookmark WHERE user_id = :u AND list_id = :l AND $contentWhere";
+ $conn = $this->entityManager->getConnection();
+ $stmt = $conn->prepare($sql);
+ $stmt->executeStatement(['u' => $user->getId(), 'l' => $list->getId(), 'id' => $content->getId()]);
+ }
+
+ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $perPage = null): PagerfantaInterface
+ {
+ $entryWhereArr = ['b.list_id = :list'];
+ $entryCommentWhereArr = ['b.list_id = :list'];
+ $postWhereArr = ['b.list_id = :list'];
+ $postCommentWhereArr = ['b.list_id = :list'];
+ $parameters = [
+ 'list' => $list->getId(),
+ ];
+
+ $orderBy = match ($criteria->sortOption) {
+ Criteria::SORT_OLD => 'ORDER BY i.created_at ASC',
+ Criteria::SORT_TOP => 'ORDER BY i.score DESC, i.created_at DESC',
+ Criteria::SORT_HOT => 'ORDER BY i.ranking DESC, i.created_at DESC',
+ default => 'ORDER BY created_at DESC',
+ };
+
+ if (Criteria::AP_LOCAL === $criteria->federation) {
+ $entryWhereArr[] = 'e.ap_id IS NULL';
+ $entryCommentWhereArr[] = 'ec.ap_id IS NULL';
+ $postWhereArr[] = 'p.ap_id IS NULL';
+ $postCommentWhereArr[] = 'pc.ap_id IS NULL';
+ }
+
+ if ('all' !== $criteria->type) {
+ $entryWhereArr[] = 'e.type = :type';
+ $entryCommentWhereArr[] = 'false';
+ $postWhereArr[] = 'false';
+ $postCommentWhereArr[] = 'false';
+
+ $parameters['type'] = $criteria->type;
+ }
+
+ if (Criteria::TIME_ALL !== $criteria->time) {
+ $entryWhereArr[] = 'b.created_at > :time';
+ $entryCommentWhereArr[] = 'b.created_at > :time';
+ $postWhereArr[] = 'b.created_at > :time';
+ $postCommentWhereArr[] = 'b.created_at > :time';
+
+ $parameters['time'] = $criteria->getSince();
+ }
+
+ $entryWhere = SqlHelpers::makeWhereString($entryWhereArr);
+ $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr);
+ $postWhere = SqlHelpers::makeWhereString($postWhereArr);
+ $postCommentWhere = SqlHelpers::makeWhereString($postCommentWhereArr);
+
+ $sql = "
+ SELECT * FROM (
+ SELECT e.id AS id, e.ap_id AS ap_id, e.score AS score, e.ranking AS ranking, b.created_at AS created_at, 'entry' AS type FROM bookmark b
+ INNER JOIN entry e ON b.entry_id = e.id $entryWhere
+ UNION
+ SELECT ec.id AS id, ec.ap_id AS ap_id, (ec.up_votes + ec.favourite_count - ec.down_votes) AS score, ec.up_votes AS ranking, b.created_at AS created_at, 'entry_comment' AS type FROM bookmark b
+ INNER JOIN entry_comment ec ON b.entry_comment_id = ec.id $entryCommentWhere
+ UNION
+ SELECT p.id AS id, p.ap_id AS ap_id, p.score AS score, p.ranking AS ranking, b.created_at AS created_at, 'post' AS type FROM bookmark b
+ INNER JOIN post p ON b.post_id = p.id $postWhere
+ UNION
+ SELECT pc.id AS id, pc.ap_id AS ap_id, (pc.up_votes + pc.favourite_count - pc.down_votes) AS score, pc.up_votes AS ranking, b.created_at AS created_at, 'post_comment' AS type FROM bookmark b
+ INNER JOIN post_comment pc ON b.post_comment_id = pc.id $postCommentWhere
+ ) i $orderBy
+ ";
+
+ $this->logger->info('bookmark list sql: {sql}', ['sql' => $sql]);
+
+ $conn = $this->entityManager->getConnection();
+ $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer);
+
+ return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE);
+ }
+}
diff --git a/src/Repository/Criteria.php b/src/Repository/Criteria.php
index ce495155f..530033033 100644
--- a/src/Repository/Criteria.php
+++ b/src/Repository/Criteria.php
@@ -231,7 +231,7 @@ public function resolveTime(?string $value, bool $reverse = false): ?string
public function resolveType(?string $value): ?string
{
return match ($value) {
- 'article', 'articles' => Entry::ENTRY_TYPE_ARTICLE,
+ 'article', 'articles', 'thread' => Entry::ENTRY_TYPE_THREAD,
'link', 'links' => Entry::ENTRY_TYPE_LINK,
'video', 'videos' => Entry::ENTRY_TYPE_VIDEO,
'photo', 'photos', 'image', 'images' => Entry::ENTRY_TYPE_IMAGE,
@@ -242,7 +242,7 @@ public function resolveType(?string $value): ?string
public function translateType(): string
{
return match ($this->resolveType($this->type)) {
- Entry::ENTRY_TYPE_ARTICLE => 'threads',
+ Entry::ENTRY_TYPE_THREAD => 'threads',
Entry::ENTRY_TYPE_LINK => 'links',
Entry::ENTRY_TYPE_VIDEO => 'videos',
Entry::ENTRY_TYPE_IMAGE => 'photos',
diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php
index 2dfcbe7e8..cc7540e2f 100644
--- a/src/Repository/EntryRepository.php
+++ b/src/Repository/EntryRepository.php
@@ -24,6 +24,7 @@
use App\PageView\EntryPageView;
use App\Pagination\AdapterFactory;
use App\Service\SettingsManager;
+use App\Utils\SqlHelpers;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Types\Types;
@@ -56,6 +57,7 @@ public function __construct(
private readonly CacheInterface $cache,
private readonly AdapterFactory $adapterFactory,
private readonly SettingsManager $settingsManager,
+ private readonly SqlHelpers $sqlHelpers
) {
parent::__construct($registry, Entry::class);
}
@@ -151,6 +153,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void
private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder
{
+ /** @var User $user */
$user = $this->security->getUser();
if (Criteria::AP_LOCAL === $criteria->federation) {
@@ -339,12 +342,11 @@ public function findToDelete(User $user, int $limit): array
->getResult();
}
- public function findRelatedByTag(string $tag, ?int $limit = 1): array
+ public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array
{
$qb = $this->createQueryBuilder('e');
- return $qb
- ->andWhere('e.visibility = :visibility')
+ $qb->andWhere('e.visibility = :visibility')
->andWhere('m.visibility = :visibility')
->andWhere('u.visibility = :visibility')
->andWhere('u.isDeleted = false')
@@ -360,16 +362,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array
'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,
'tag' => $tag,
])
- ->setMaxResults($limit)
- ->getQuery()
+ ->setMaxResults($limit);
+
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))
+ ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
+ return $qb->getQuery()
->getResult();
}
- public function findRelatedByMagazine(string $name, ?int $limit = 1): array
+ public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array
{
$qb = $this->createQueryBuilder('e');
- return $qb->where('m.name LIKE :name OR m.title LIKE :title')
+ $qb->where('m.name LIKE :name OR m.title LIKE :title')
->andWhere('e.visibility = :visibility')
->andWhere('m.visibility = :visibility')
->andWhere('u.visibility = :visibility')
@@ -382,12 +391,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array
->setParameters(
['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE]
)
- ->setMaxResults($limit)
- ->getQuery()
+ ->setMaxResults($limit);
+
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))
+ ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
+ return $qb->getQuery()
->getResult();
}
- public function findLast(int $limit): array
+ public function findLast(int $limit, ?User $user = null): array
{
$qb = $this->createQueryBuilder('e');
@@ -401,10 +417,16 @@ public function findLast(int $limit): array
$qb = $qb->andWhere('m.apId IS NULL');
}
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))
+ ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
return $qb->join('e.magazine', 'm')
->join('e.user', 'u')
->orderBy('e.createdAt', 'DESC')
- ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE])
+ ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)
->setMaxResults($limit)
->getQuery()
->getResult();
diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php
index 0f42034f4..ebd92131e 100644
--- a/src/Repository/MagazineRepository.php
+++ b/src/Repository/MagazineRepository.php
@@ -16,6 +16,7 @@
use App\Entity\User;
use App\PageView\MagazinePageView;
use App\Service\SettingsManager;
+use App\Utils\SqlHelpers;
use App\Utils\SubscriptionSort;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\Collection;
@@ -49,7 +50,7 @@ class MagazineRepository extends ServiceEntityRepository
self::SORT_NEWEST,
];
- public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager)
+ public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers)
{
parent::__construct($registry, Magazine::class);
}
@@ -478,21 +479,23 @@ public function search(string $magazine, int $page, int $perPage = self::PER_PAG
return $pagerfanta;
}
- public function findRandom(): array
+ public function findRandom(?User $user = null): array
{
$conn = $this->getEntityManager()->getConnection();
- $sql = '
- SELECT id FROM magazine
- ';
+ $whereClauses = [];
+ $parameters = [];
if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_LOCAL_ONLY')) {
- $sql .= 'WHERE ap_id IS NULL';
+ $whereClauses[] = 'm.ap_id IS NULL';
+ }
+ if (null !== $user) {
+ $subSql = 'SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :user';
+ $whereClauses[] = "NOT EXISTS($subSql)";
+ $parameters['user'] = $user->getId();
}
- $sql .= '
- ORDER BY random()
- LIMIT 5
- ';
+ $whereString = SqlHelpers::makeWhereString($whereClauses);
+ $sql = "SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5";
$stmt = $conn->prepare($sql);
- $stmt = $stmt->executeQuery();
+ $stmt = $stmt->executeQuery($parameters);
$ids = $stmt->fetchAllAssociative();
return $this->createQueryBuilder('m')
@@ -505,17 +508,23 @@ public function findRandom(): array
->getResult();
}
- public function findRelated(string $magazine): array
+ public function findRelated(string $magazine, ?User $user = null): array
{
- return $this->createQueryBuilder('m')
+ $qb = $this->createQueryBuilder('m')
->where('m.entryCount > 0 OR m.postCount > 0')
->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine')
->andWhere('m.isAdult = false')
->andWhere('m.visibility = :visibility')
->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)
->setParameter('magazine', "%{$magazine}%")
- ->setMaxResults(5)
- ->getQuery()
+ ->setMaxResults(5);
+
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
+ return $qb->getQuery()
->getResult();
}
diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php
index 870b20490..380e7470d 100644
--- a/src/Repository/PostRepository.php
+++ b/src/Repository/PostRepository.php
@@ -23,6 +23,7 @@
use App\PageView\PostPageView;
use App\Pagination\AdapterFactory;
use App\Service\SettingsManager;
+use App\Utils\SqlHelpers;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Types\Types;
@@ -54,6 +55,7 @@ public function __construct(
private readonly CacheInterface $cache,
private readonly AdapterFactory $adapterFactory,
private readonly SettingsManager $settingsManager,
+ private readonly SqlHelpers $sqlHelpers,
) {
parent::__construct($registry, Post::class);
}
@@ -143,6 +145,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void
private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder
{
+ /** @var User|null $user */
$user = $this->security->getUser();
if (Criteria::AP_LOCAL === $criteria->federation) {
@@ -168,8 +171,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder
if ($criteria->subscribed) {
$qb->andWhere(
- 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine)
- OR
+ 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine)
+ OR
EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user)
OR
p.user = :user'
@@ -307,11 +310,11 @@ public function findToDelete(User $user, int $limit): array
->getResult();
}
- public function findRelatedByTag(string $tag, ?int $limit = 1): array
+ public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array
{
$qb = $this->createQueryBuilder('p');
- return $qb
+ $qb = $qb
->andWhere('p.visibility = :visibility')
->andWhere('m.visibility = :visibility')
->andWhere('u.visibility = :visibility')
@@ -328,16 +331,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array
'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,
'name' => $tag,
])
- ->setMaxResults($limit)
- ->getQuery()
+ ->setMaxResults($limit);
+
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))
+ ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
+ return $qb->getQuery()
->getResult();
}
- public function findRelatedByMagazine(string $name, ?int $limit = 1): array
+ public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array
{
$qb = $this->createQueryBuilder('p');
- return $qb->where('m.name LIKE :name OR m.title LIKE :title')
+ $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title')
->andWhere('p.visibility = :visibility')
->andWhere('m.visibility = :visibility')
->andWhere('u.visibility = :visibility')
@@ -349,12 +359,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array
->setParameters(
['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE]
)
- ->setMaxResults($limit)
- ->getQuery()
+ ->setMaxResults($limit);
+
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))
+ ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
+ return $qb->getQuery()
->getResult();
}
- public function findLast(int $limit = 1): array
+ public function findLast(int $limit = 1, ?User $user = null): array
{
$qb = $this->createQueryBuilder('p');
@@ -365,9 +382,16 @@ public function findLast(int $limit = 1): array
$qb = $qb->andWhere('m.apId IS NULL');
}
+ if (null !== $user) {
+ $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))
+ ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));
+ $qb->setParameter('user', $user);
+ }
+
return $qb->join('p.magazine', 'm')
+ ->join('p.user', 'u')
->orderBy('p.createdAt', 'DESC')
- ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE])
+ ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)
->setMaxResults($limit)
->getQuery()
->getResult();
diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php
index a5d89e5eb..84a412133 100644
--- a/src/Repository/SearchRepository.php
+++ b/src/Repository/SearchRepository.php
@@ -13,6 +13,7 @@
use Doctrine\ORM\EntityManagerInterface;
use Pagerfanta\Pagerfanta;
use Pagerfanta\PagerfantaInterface;
+use Psr\Log\LoggerInterface;
class SearchRepository
{
@@ -21,6 +22,7 @@ class SearchRepository
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ContentPopulationTransformer $transformer,
+ private readonly LoggerInterface $logger,
) {
}
@@ -79,10 +81,15 @@ public function findBoosts(int $page, User $user): PagerfantaInterface
return $pagerfanta;
}
- public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface
+ /**
+ * @param 'entry'|'post'|null $specificType
+ */
+ public function search(?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface
{
+ $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : '';
+ $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : '';
$conn = $this->entityManager->getConnection();
- $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e
+ $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e
INNER JOIN public.user u ON u.id = user_id
INNER JOIN magazine m ON e.magazine_id = m.id
WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true)
@@ -91,6 +98,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page
AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)
AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)
AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id)
+ $authorWhere $magazineWhere
UNION ALL
SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e
INNER JOIN public.user u ON u.id = user_id
@@ -101,8 +109,9 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page
AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)
AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)
AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id)
- UNION ALL
- SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e
+ $authorWhere $magazineWhere
+ ";
+ $sqlPost = "SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e
INNER JOIN public.user u ON u.id = user_id
INNER JOIN magazine m ON e.magazine_id = m.id
WHERE body_ts @@ plainto_tsquery( :query ) = true
@@ -111,6 +120,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page
AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)
AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)
AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id)
+ $authorWhere $magazineWhere
UNION ALL
SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e
INNER JOIN public.user u ON u.id = user_id
@@ -121,12 +131,38 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page
AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)
AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)
AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id)
- ORDER BY created_at DESC";
- $adapter = new NativeQueryAdapter($conn, $sql, [
+ $authorWhere $magazineWhere
+ ";
+
+ if (null === $specificType) {
+ $sql = "$sqlEntry UNION ALL $sqlPost ORDER BY created_at DESC";
+ } else {
+ if ('entry' === $specificType) {
+ $sql = "$sqlEntry ORDER BY created_at DESC";
+ } elseif ('post' === $specificType) {
+ $sql = "$sqlPost ORDER BY created_at DESC";
+ } else {
+ throw new \LogicException($specificType.' is not supported');
+ }
+ }
+
+ $this->logger->debug('Search query: {sql}', ['sql' => $sql]);
+
+ $parameters = [
'query' => $query,
'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,
'queryingUser' => $searchingUser?->getId() ?? -1,
- ], transformer: $this->transformer);
+ ];
+
+ if (null !== $authorId) {
+ $parameters['authorId'] = $authorId;
+ }
+
+ if (null !== $magazineId) {
+ $parameters['magazineId'] = $magazineId;
+ }
+
+ $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer);
$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setCurrentPage($page);
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 2c7c91dda..2b714b151 100644
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -477,12 +477,9 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr
->orderBy('u.lastActive', 'DESC');
}
- public function findWithAboutPaginated(
- int $page,
- string $group = self::USERS_ALL,
- int $perPage = self::PER_PAGE
- ): PagerfantaInterface {
- $query = $this->findWithAboutQueryBuilder($group)->getQuery();
+ public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface
+ {
+ $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery();
$pagerfanta = new Pagerfanta(
new QueryAdapter(
@@ -500,11 +497,19 @@ public function findWithAboutPaginated(
return $pagerfanta;
}
- private function findWithAboutQueryBuilder(string $group): QueryBuilder
+ private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder
{
- $qb = $this->createQueryBuilder('u')
- ->andWhere('u.about != \'\'')
- ->andWhere('u.about IS NOT NULL');
+ $qb = $this->createQueryBuilder('u');
+
+ if ($needsAbout) {
+ $qb->andWhere('u.about != \'\'')
+ ->andWhere('u.about IS NOT NULL');
+ }
+
+ if (null !== $query) {
+ $qb->andWhere('u.username LIKE :query')
+ ->setParameter('query', '%'.$query.'%');
+ }
switch ($group) {
case self::USERS_LOCAL:
diff --git a/src/Schema/PaginationSchema.php b/src/Schema/PaginationSchema.php
index a311d533d..15b1c9021 100644
--- a/src/Schema/PaginationSchema.php
+++ b/src/Schema/PaginationSchema.php
@@ -5,7 +5,7 @@
namespace App\Schema;
use OpenApi\Attributes as OA;
-use Pagerfanta\Pagerfanta;
+use Pagerfanta\PagerfantaInterface;
#[OA\Schema()]
class PaginationSchema implements \JsonSerializable
@@ -19,7 +19,7 @@ class PaginationSchema implements \JsonSerializable
#[OA\Property(description: 'Max number of items per page')]
public int $perPage = 0;
- public function __construct(Pagerfanta $pagerfanta)
+ public function __construct(PagerfantaInterface $pagerfanta)
{
$this->count = $pagerfanta->count();
$this->currentPage = $pagerfanta->getCurrentPage();
diff --git a/src/Service/BookmarkManager.php b/src/Service/BookmarkManager.php
new file mode 100644
index 000000000..11529a06b
--- /dev/null
+++ b/src/Service/BookmarkManager.php
@@ -0,0 +1,90 @@
+entityManager->persist($list);
+ $this->entityManager->flush();
+
+ return $list;
+ }
+
+ public function isBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool
+ {
+ if ($content instanceof Entry) {
+ return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entry' => $content]));
+ } elseif ($content instanceof EntryComment) {
+ return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entryComment' => $content]));
+ } elseif ($content instanceof Post) {
+ return !empty($this->bookmarkRepository->findBy(['user' => $user, 'post' => $content]));
+ } elseif ($content instanceof PostComment) {
+ return !empty($this->bookmarkRepository->findBy(['user' => $user, 'postComment' => $content]));
+ }
+
+ return false;
+ }
+
+ public function isBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool
+ {
+ if ($content instanceof Entry) {
+ return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entry' => $content]);
+ } elseif ($content instanceof EntryComment) {
+ return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entryComment' => $content]);
+ } elseif ($content instanceof Post) {
+ return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'post' => $content]);
+ } elseif ($content instanceof PostComment) {
+ return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'postComment' => $content]);
+ }
+
+ return false;
+ }
+
+ public function addBookmarkToDefaultList(User $user, Entry|EntryComment|Post|PostComment $content): void
+ {
+ $list = $this->bookmarkListRepository->findOneByUserDefault($user);
+ $this->addBookmark($user, $list, $content);
+ }
+
+ public function addBookmark(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void
+ {
+ $bookmark = new Bookmark($user, $list);
+ $bookmark->setContent($content);
+ $this->entityManager->persist($bookmark);
+ $this->entityManager->flush();
+ }
+
+ public static function GetClassFromSubjectType(string $subjectType): string
+ {
+ return match ($subjectType) {
+ 'entry' => Entry::class,
+ 'entry_comment' => EntryComment::class,
+ 'post' => Post::class,
+ 'post_comment' => PostComment::class,
+ default => throw new \LogicException("cannot match type $subjectType")
+ };
+ }
+}
diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php
index 0d600a1f9..6ff9775ed 100644
--- a/src/Service/EntryManager.php
+++ b/src/Service/EntryManager.php
@@ -164,7 +164,7 @@ private function setType(EntryDto $dto, Entry $entry): Entry
}
if ($dto->body) {
- $entry->type = Entry::ENTRY_TYPE_ARTICLE;
+ $entry->type = Entry::ENTRY_TYPE_THREAD;
$entry->hasEmbed = false;
}
diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php
index 8b62a943f..fbdae9916 100644
--- a/src/Service/SearchManager.php
+++ b/src/Service/SearchManager.php
@@ -47,9 +47,9 @@ public function findDomainsPaginated(string $domain, int $page = 1, int $perPage
return $this->domainRepository->search($domain, $page, $perPage);
}
- public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE): PagerfantaInterface
+ public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface
{
- return $this->repository->search($queryingUser, $val, $page, $perPage);
+ return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType);
}
public function findByApId(string $url): array
diff --git a/src/Twig/Components/BookmarkListComponent.php b/src/Twig/Components/BookmarkListComponent.php
new file mode 100644
index 000000000..95ac59965
--- /dev/null
+++ b/src/Twig/Components/BookmarkListComponent.php
@@ -0,0 +1,20 @@
+comment->root?->getId() ?? $this->comment->getId();
- $userId = $this->security->getUser()?->getId();
-
- return $this->cache->get(
- "entry_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}",
- function (ItemInterface $item) use ($commentId, $userId) {
- $item->expiresAfter(3600);
- $item->tag(['entry_comments_user_'.$userId]);
- $item->tag(['entry_comment_'.$commentId]);
-
- return $this->twig->render(
- 'components/entry_comments_nested.html.twig',
- [
- 'comment' => $this->comment,
- 'level' => $this->level,
- 'view' => $this->view,
- ]
- );
- }
- );
- }
}
diff --git a/src/Twig/Components/PostCommentsNestedComponent.php b/src/Twig/Components/PostCommentsNestedComponent.php
index 97035da45..f812856e2 100644
--- a/src/Twig/Components/PostCommentsNestedComponent.php
+++ b/src/Twig/Components/PostCommentsNestedComponent.php
@@ -6,53 +6,12 @@
use App\Controller\User\ThemeSettingsController;
use App\Entity\PostComment;
-use Symfony\Bundle\SecurityBundle\Security;
-use Symfony\Component\HttpFoundation\RequestStack;
-use Symfony\Contracts\Cache\CacheInterface;
-use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
-use Symfony\UX\TwigComponent\ComponentAttributes;
-use Twig\Environment;
-#[AsTwigComponent('post_comments_nested', template: 'components/_cached.html.twig')]
+#[AsTwigComponent('post_comments_nested')]
final class PostCommentsNestedComponent
{
public PostComment $comment;
public int $level;
public string $view = ThemeSettingsController::TREE;
-
- public function __construct(
- private readonly Environment $twig,
- private readonly Security $security,
- private readonly CacheInterface $cache,
- private readonly RequestStack $requestStack
- ) {
- }
-
- public function getHtml(ComponentAttributes $attributes): string
- {
- $comment = $this->comment->root ?? $this->comment;
- $commentId = $comment->getId();
- $postId = $comment->post->getId();
- $userId = $this->security->getUser()?->getId();
-
- return $this->cache->get(
- "post_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}",
- function (ItemInterface $item) use ($commentId, $userId, $postId) {
- $item->expiresAfter(3600);
- $item->tag(['post_comments_user_'.$userId]);
- $item->tag(['post_comment_'.$commentId]);
- $item->tag(['post_'.$postId]);
-
- return $this->twig->render(
- 'components/post_comments_nested.html.twig',
- [
- 'comment' => $this->comment,
- 'level' => $this->level,
- 'view' => $this->view,
- ]
- );
- }
- );
- }
}
diff --git a/src/Twig/Components/RelatedEntriesComponent.php b/src/Twig/Components/RelatedEntriesComponent.php
index febe94156..4615006c3 100644
--- a/src/Twig/Components/RelatedEntriesComponent.php
+++ b/src/Twig/Components/RelatedEntriesComponent.php
@@ -5,9 +5,11 @@
namespace App\Twig\Components;
use App\Entity\Entry;
+use App\Entity\User;
use App\Repository\EntryRepository;
use App\Service\MentionManager;
use App\Service\SettingsManager;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@@ -31,7 +33,8 @@ public function __construct(
private readonly EntryRepository $repository,
private readonly CacheInterface $cache,
private readonly SettingsManager $settingsManager,
- private readonly MentionManager $mentionManager
+ private readonly MentionManager $mentionManager,
+ private readonly Security $security,
) {
}
@@ -49,19 +52,23 @@ public function mount(?string $magazine, ?string $tag): void
$entryId = $this->entry?->getId();
$magazine = str_replace('@', '', $magazine ?? '');
+ /** @var User|null $user */
+ $user = $this->security->getUser();
+ $cacheKey = "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}";
$entryIds = $this->cache->get(
- "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}",
- function (ItemInterface $item) use ($magazine, $tag) {
+ $cacheKey,
+ function (ItemInterface $item) use ($magazine, $tag, $user) {
$item->expiresAfter(60 * 5); // 5 minutes
$entries = match ($this->type) {
- self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20),
+ self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user),
self::TYPE_MAGAZINE => $this->repository->findRelatedByTag(
$this->mentionManager->getUsername($magazine),
- $this->limit + 20
+ $this->limit + 20,
+ user: $user,
),
- default => $this->repository->findLast($this->limit + 150),
+ default => $this->repository->findLast($this->limit + 150, user: $user),
};
$entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult);
diff --git a/src/Twig/Components/RelatedMagazinesComponent.php b/src/Twig/Components/RelatedMagazinesComponent.php
index 741dea67f..0871cda57 100644
--- a/src/Twig/Components/RelatedMagazinesComponent.php
+++ b/src/Twig/Components/RelatedMagazinesComponent.php
@@ -5,8 +5,10 @@
namespace App\Twig\Components;
use App\Entity\Magazine;
+use App\Entity\User;
use App\Repository\MagazineRepository;
use App\Service\SettingsManager;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@@ -28,6 +30,7 @@ public function __construct(
private readonly MagazineRepository $repository,
private readonly CacheInterface $cache,
private readonly SettingsManager $settingsManager,
+ private readonly Security $security,
) {
}
@@ -44,16 +47,18 @@ public function mount(?string $magazine, ?string $tag): void
}
$magazine = str_replace('@', '', $magazine ?? '');
+ /** @var User|null $user */
+ $user = $this->security->getUser();
$magazineIds = $this->cache->get(
- "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}",
- function (ItemInterface $item) use ($magazine, $tag) {
+ "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}",
+ function (ItemInterface $item) use ($magazine, $tag, $user) {
$item->expiresAfter(60 * 5); // 5 minutes
$magazines = match ($this->type) {
- self::TYPE_TAG => $this->repository->findRelated($tag),
- self::TYPE_MAGAZINE => $this->repository->findRelated($magazine),
- default => $this->repository->findRandom(),
+ self::TYPE_TAG => $this->repository->findRelated($tag, user: $user),
+ self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user),
+ default => $this->repository->findRandom(user: $user),
};
$magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine);
diff --git a/src/Twig/Components/RelatedPostsComponent.php b/src/Twig/Components/RelatedPostsComponent.php
index bfc1d944d..a5a033950 100644
--- a/src/Twig/Components/RelatedPostsComponent.php
+++ b/src/Twig/Components/RelatedPostsComponent.php
@@ -5,9 +5,11 @@
namespace App\Twig\Components;
use App\Entity\Post;
+use App\Entity\User;
use App\Repository\PostRepository;
use App\Service\MentionManager;
use App\Service\SettingsManager;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@@ -30,7 +32,8 @@ public function __construct(
private readonly PostRepository $repository,
private readonly CacheInterface $cache,
private readonly SettingsManager $settingsManager,
- private readonly MentionManager $mentionManager
+ private readonly MentionManager $mentionManager,
+ private readonly Security $security,
) {
}
@@ -46,21 +49,25 @@ public function mount(?string $magazine, ?string $tag): void
$this->type = self::TYPE_MAGAZINE;
}
+ /** @var User|null $user */
+ $user = $this->security->getUser();
+
$postId = $this->post?->getId();
$magazine = str_replace('@', '', $magazine ?? '');
$postIds = $this->cache->get(
- "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}",
- function (ItemInterface $item) use ($magazine, $tag) {
+ "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}",
+ function (ItemInterface $item) use ($magazine, $tag, $user) {
$item->expiresAfter(60 * 5); // 5 minutes
$posts = match ($this->type) {
- self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20),
+ self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user),
self::TYPE_MAGAZINE => $this->repository->findRelatedByTag(
$this->mentionManager->getUsername($magazine),
- $this->limit + 20
+ $this->limit + 20,
+ user: $user
),
- default => $this->repository->findLast($this->limit + 150),
+ default => $this->repository->findLast($this->limit + 150, user: $user),
};
$posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult);
diff --git a/src/Twig/Extension/BookmarkExtension.php b/src/Twig/Extension/BookmarkExtension.php
new file mode 100644
index 000000000..e3ac3367c
--- /dev/null
+++ b/src/Twig/Extension/BookmarkExtension.php
@@ -0,0 +1,22 @@
+bookmarkListRepository->findByUser($user);
+ }
+
+ public function getBookmarkListEntryCount(BookmarkList $list): int
+ {
+ return $list->entities->count();
+ }
+
+ public function isContentBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool
+ {
+ return $this->bookmarkManager->isBookmarked($user, $content);
+ }
+
+ public function isContentBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool
+ {
+ return $this->bookmarkManager->isBookmarkedInList($user, $list, $content);
+ }
+}
diff --git a/src/Twig/Runtime/FrontExtensionRuntime.php b/src/Twig/Runtime/FrontExtensionRuntime.php
index 5bb43294c..b52735041 100644
--- a/src/Twig/Runtime/FrontExtensionRuntime.php
+++ b/src/Twig/Runtime/FrontExtensionRuntime.php
@@ -4,6 +4,10 @@
namespace App\Twig\Runtime;
+use App\Entity\Entry;
+use App\Entity\EntryComment;
+use App\Entity\Post;
+use App\Entity\PostComment;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Extension\RuntimeExtensionInterface;
@@ -63,4 +67,24 @@ private function getFrontRoute(string $currentRoute, array $params): string
return 'front_short';
}
}
+
+ public function getClass(mixed $object): string
+ {
+ return \get_class($object);
+ }
+
+ public function getSubjectType(mixed $object): string
+ {
+ if ($object instanceof Entry) {
+ return 'entry';
+ } elseif ($object instanceof EntryComment) {
+ return 'entry_comment';
+ } elseif ($object instanceof Post) {
+ return 'post';
+ } elseif ($object instanceof PostComment) {
+ return 'post_comment';
+ } else {
+ throw new \LogicException('unknown class '.\get_class($object));
+ }
+ }
}
diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php
new file mode 100644
index 000000000..39e9de186
--- /dev/null
+++ b/src/Utils/SqlHelpers.php
@@ -0,0 +1,59 @@
+ 0) {
+ $where .= ' AND ';
+ }
+ $where .= $whereClause;
+ ++$i;
+ }
+
+ return $where;
+ }
+
+ public function getBlockedMagazinesDql(User $user): string
+ {
+ return $this->entityManager->createQueryBuilder()
+ ->select('bm')
+ ->from(MagazineBlock::class, 'bm')
+ ->where('bm.magazine = m')
+ ->andWhere('bm.user = :user')
+ ->setParameter('user', $user)
+ ->getDQL();
+ }
+
+ public function getBlockedUsersDql(User $user): string
+ {
+ return $this->entityManager->createQueryBuilder()
+ ->select('ub')
+ ->from(UserBlock::class, 'ub')
+ ->where('ub.blocker = :user')
+ ->andWhere('ub.blocked = u')
+ ->setParameter('user', $user)
+ ->getDql();
+ }
+}
diff --git a/templates/bookmark/_form_edit.html.twig b/templates/bookmark/_form_edit.html.twig
new file mode 100644
index 000000000..ca58c553f
--- /dev/null
+++ b/templates/bookmark/_form_edit.html.twig
@@ -0,0 +1,14 @@
+{{ form_start(form, {attr: {class: 'bookmark_edit'}}) }}
+
+{{ form_row(form.name, {label: 'bookmark_list_create_label'}) }}
+
+
+ {{ form_row(form.isDefault, {label: 'bookmark_list_make_default', row_attr: {class: 'checkbox'}}) }}
+
+
+
+ {% set btn_label = is_create ? 'bookmark_list_create' : 'bookmark_list_edit' %}
+ {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }}
+
+
+{{ form_end(form) }}
diff --git a/templates/bookmark/_options.html.twig b/templates/bookmark/_options.html.twig
new file mode 100644
index 000000000..d57833c0f
--- /dev/null
+++ b/templates/bookmark/_options.html.twig
@@ -0,0 +1,231 @@
+{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %}
+
+
+
+
+
+
+ {{ criteria.getOption('sort')|trans }}
+
+
+
+
+
+
+ {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('time') != 'all') %}
+ {{ criteria.getOption('time')|trans }}
+ {% endif %}
+
+
+
+
+
+
+
+ {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('type') != 'all') %}
+ {{ criteria.getOption('type')|trans }}
+ {% endif %}
+
+
+
+
+
+
+
+ {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('federation') != 'all') %}
+ {{ criteria.federation|trans }}
+ {% endif %}
+
+
+
+ {% if lists is defined and lists is not empty %}
+
+
+
+
+ {% if showFilterLabels == 'on' or showFilterLabels == 'auto' %}
+ {{ list.name }}
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
diff --git a/templates/bookmark/edit.html.twig b/templates/bookmark/edit.html.twig
new file mode 100644
index 000000000..4aec0c0b8
--- /dev/null
+++ b/templates/bookmark/edit.html.twig
@@ -0,0 +1,24 @@
+{% extends 'base.html.twig' %}
+
+{%- block title -%}
+ {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}}
+{%- endblock -%}
+
+{% block mainClass %}page-bookmarks{% endblock %}
+
+{% block header_nav %}
+{% endblock %}
+
+{% block sidebar_top %}
+{% endblock %}
+
+{% block body %}
+ {{ 'bookmarks_list_edit'|trans }}
+
+
+
+ {% include 'bookmark/_form_edit.html.twig' with {is_create: false} %}
+
+
+
+{% endblock %}
diff --git a/templates/bookmark/front.html.twig b/templates/bookmark/front.html.twig
new file mode 100644
index 000000000..627370b74
--- /dev/null
+++ b/templates/bookmark/front.html.twig
@@ -0,0 +1,25 @@
+{% extends 'base.html.twig' %}
+
+{%- block title -%}
+ {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}}
+{%- endblock -%}
+
+{% block mainClass %}page-bookmarks{% endblock %}
+
+{% block header_nav %}
+{% endblock %}
+
+{% block sidebar_top %}
+{% endblock %}
+
+{% block body %}
+ {{ 'bookmarks'|trans }}
+
+ {% include 'bookmark/_options.html.twig' %}
+
+{% endblock %}
diff --git a/templates/bookmark/overview.html.twig b/templates/bookmark/overview.html.twig
new file mode 100644
index 000000000..4972ea4e6
--- /dev/null
+++ b/templates/bookmark/overview.html.twig
@@ -0,0 +1,77 @@
+{% extends 'base.html.twig' %}
+
+{%- block title -%}
+ {{- 'bookmark_lists'|trans }} - {{ parent() -}}
+{%- endblock -%}
+
+{% block mainClass %}page-bookmark-lists{% endblock %}
+
+{% block header_nav %}
+{% endblock %}
+
+{% block sidebar_top %}
+{% endblock %}
+
+{% block body %}
+ {{ 'bookmark_lists'|trans }}
+
+
+
+ {% include('bookmark/_form_edit.html.twig') with {is_create: true} %}
+
+
+
+ {% if lists|length %}
+
+
+
+
+
+
+ {{ 'name'|trans }}
+ {{ 'count'|trans }}
+
+
+
+
+ {% for list in lists %}
+
+
+ {% if list.isDefault %}
+
+ {% endif %}
+
+ {{ list.name }}
+ {{ get_bookmark_list_entry_count(list) }}
+
+ {% if not list.isDefault %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+ {% endif %}
+{% endblock %}
diff --git a/templates/components/bookmark_list.html.twig b/templates/components/bookmark_list.html.twig
new file mode 100644
index 000000000..be65c8849
--- /dev/null
+++ b/templates/components/bookmark_list.html.twig
@@ -0,0 +1,19 @@
+
+ {% if is_bookmarked_in_list(app.user, list, subject) %}
+
+
+ {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }}
+
+ {% else %}
+
+
+ {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }}
+
+ {% endif %}
+
diff --git a/templates/components/bookmark_menu_list.html.twig b/templates/components/bookmark_menu_list.html.twig
new file mode 100644
index 000000000..3a6126616
--- /dev/null
+++ b/templates/components/bookmark_menu_list.html.twig
@@ -0,0 +1,5 @@
+
diff --git a/templates/components/bookmark_standard.html.twig b/templates/components/bookmark_standard.html.twig
new file mode 100644
index 000000000..d8d502a9e
--- /dev/null
+++ b/templates/components/bookmark_standard.html.twig
@@ -0,0 +1,17 @@
+
+ {% if is_bookmarked(app.user, subject) %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig
index 59cd3242f..c58c9c8d3 100644
--- a/templates/components/entry.html.twig
+++ b/templates/components/entry.html.twig
@@ -113,7 +113,7 @@
{% if entry.image %}
{% if entry.type is same as 'link' or entry.type is same as 'video' %}
{{ include('components/_figure_entry.html.twig', {entry: entry, type: 'link'}) }}
- {% elseif entry.type is same as 'image' or entry.type is same as 'article' %}
+ {% elseif entry.type is same as 'image' or entry.type is same as 'article' or entry.type is same as 'thread' %}
{{ include('components/_figure_entry.html.twig', {entry: entry, type: 'image'}) }}
{% endif %}
{% else %}
@@ -141,9 +141,9 @@
{% endif %}
- {% if entry.type is same as 'article' %}
+ {% if entry.type is same as 'article' or entry.type is same as 'thread' %}
-
+
@@ -171,6 +171,9 @@
subject: entry
}) }}
+ {% if app.user is defined and app.user is not same as null %}
+ {{ component('bookmark_standard', { subject: entry }) }}
+ {% endif %}
{% include 'entry/_menu.html.twig' %}
diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig
index 2bbec6930..1f87f77b5 100644
--- a/templates/components/entry_comment.html.twig
+++ b/templates/components/entry_comment.html.twig
@@ -102,6 +102,9 @@
{{ component('boost', {subject: comment}) }}
+ {% if app.user is defined and app.user is not same as null %}
+ {{ component('bookmark_standard', { subject: comment }) }}
+ {% endif %}
{% include 'entry/comment/_menu.html.twig' %}
diff --git a/templates/components/entry_cross.html.twig b/templates/components/entry_cross.html.twig
index 6fbfbb9cd..49019364c 100644
--- a/templates/components/entry_cross.html.twig
+++ b/templates/components/entry_cross.html.twig
@@ -1,4 +1,4 @@
-
+
diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig
index 04a79d7e6..d03e769ca 100644
--- a/templates/components/post.html.twig
+++ b/templates/components/post.html.twig
@@ -110,6 +110,9 @@
subject: post
}) }}
+ {% if app.user is defined and app.user is not same as null %}
+ {{ component('bookmark_standard', { subject: post }) }}
+ {% endif %}
{% include 'post/_menu.html.twig' %}
diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig
index b91eb650a..7755aceac 100644
--- a/templates/components/post_comment.html.twig
+++ b/templates/components/post_comment.html.twig
@@ -102,6 +102,9 @@
subject: comment
}) }}
+ {% if app.user is defined and app.user is not same as null %}
+ {{ component('bookmark_standard', { subject: comment }) }}
+ {% endif %}
{% include 'post/comment/_menu.html.twig' %}
diff --git a/templates/domain/_options.html.twig b/templates/domain/_options.html.twig
index c50c3326c..ea827aa85 100644
--- a/templates/domain/_options.html.twig
+++ b/templates/domain/_options.html.twig
@@ -134,7 +134,7 @@
-
+
{{ 'threads'|trans }}
diff --git a/templates/entry/_create_options.html.twig b/templates/entry/_create_options.html.twig
index bd1db7c3b..c8a7bcc7d 100644
--- a/templates/entry/_create_options.html.twig
+++ b/templates/entry/_create_options.html.twig
@@ -11,9 +11,9 @@
-
- {{ 'type.article'|trans }}
+
+ {{ 'type.thread'|trans }}
@@ -36,9 +36,9 @@
-
- {{ 'type.article'|trans }}
+
+ {{ 'type.thread'|trans }}
diff --git a/templates/entry/_form_article.html.twig b/templates/entry/_form_thread.html.twig
similarity index 93%
rename from templates/entry/_form_article.html.twig
rename to templates/entry/_form_thread.html.twig
index ca937bf19..efe8b208b 100644
--- a/templates/entry/_form_article.html.twig
+++ b/templates/entry/_form_thread.html.twig
@@ -14,7 +14,7 @@
'data-action' : 'input-length#updateDisplay',
'data-input-length-max-value' : constant('App\\Entity\\Entry::MAX_TITLE_LENGTH')
}}) }}
- {{ component('editor_toolbar', {id: 'entry_article_body'}) }}
+ {{ component('editor_toolbar', {id: 'entry_thread_body'}) }}
{{ form_row(form.body, {
label: false, attr: {
placeholder: 'body',
@@ -59,7 +59,7 @@
{{ form_row(form.lang, {label: false}) }}
- {{ form_row(form.submit, {label: edit ? 'edit_article' : 'add_new_article', attr: {class: 'btn btn__primary'}}) }}
+ {{ form_row(form.submit, {label: edit ? 'edit_thread' : 'add_new_thread', attr: {class: 'btn btn__primary'}}) }}
diff --git a/templates/entry/_menu.html.twig b/templates/entry/_menu.html.twig
index e94186bfd..5dae6bf39 100644
--- a/templates/entry/_menu.html.twig
+++ b/templates/entry/_menu.html.twig
@@ -20,6 +20,14 @@
{% endif %}
+ {% if app.user is defined and app.user is not same as null %}
+ {% set bookmarkLists = get_bookmark_lists(app.user) %}
+ {% if bookmarkLists|length %}
+
+ {{ component('bookmark_menu_list', { bookmarkLists: bookmarkLists, subject: entry }) }}
+ {% endif %}
+ {% endif %}
+
{{ 'copy_url'|trans }}
+
{% if is_granted('edit', entry) or (app.user and entry.isAuthor(app.user)) or is_granted('moderate', entry) %}
{% endif %}
diff --git a/templates/entry/_options.html.twig b/templates/entry/_options.html.twig
index 0562dfe53..728de76b4 100644
--- a/templates/entry/_options.html.twig
+++ b/templates/entry/_options.html.twig
@@ -134,7 +134,7 @@
-
+
{{ 'threads'|trans }}
diff --git a/templates/entry/comment/_menu.html.twig b/templates/entry/comment/_menu.html.twig
index 7b1fc2c80..6c250689d 100644
--- a/templates/entry/comment/_menu.html.twig
+++ b/templates/entry/comment/_menu.html.twig
@@ -14,6 +14,17 @@
{{ 'activity'|trans }}
+
+ {% if app.user is defined and app.user is not same as null %}
+ {% set bookmarkLists = get_bookmark_lists(app.user) %}
+ {% if bookmarkLists|length %}
+
+ {% for list in bookmarkLists %}
+ {{ component('bookmark_list', { subject: comment, subjectType: 'entry_comment', list: list }) }}
+ {% endfor %}
+ {% endif %}
+ {% endif %}
+
- {{ 'add_new_article'|trans }}
+ {{ 'add_new_thread'|trans }}
{% include 'layout/_flash.html.twig' %}
{% include('user/_visibility_info.html.twig') %}
@@ -23,7 +23,7 @@
{% if user.visibility is same as 'visible' %}
- {% include 'entry/_form_article.html.twig' %}
+ {% include 'entry/_form_thread.html.twig' %}
{% endif %}
diff --git a/templates/entry/edit_entry.html.twig b/templates/entry/edit_entry.html.twig
index 8ef11af74..1cacd6e82 100644
--- a/templates/entry/edit_entry.html.twig
+++ b/templates/entry/edit_entry.html.twig
@@ -1,10 +1,10 @@
{% extends 'base.html.twig' %}
{%- block title -%}
- {{- 'edit_entry'|trans }} - {{ parent() -}}
+ {{- 'edit_thread'|trans }} - {{ parent() -}}
{%- endblock -%}
-{% block mainClass %}page-entry-create page-entry-edit-article{% endblock %}
+{% block mainClass %}page-entry-create page-entry-edit-thread{% endblock %}
{% block header_nav %}
{% endblock %}
@@ -19,7 +19,7 @@
}) }}
- {{ 'edit_entry'|trans }}
+ {{ 'edit_thread'|trans }}
{% include 'layout/_flash.html.twig' %}
diff --git a/templates/layout/_header.html.twig b/templates/layout/_header.html.twig
index d6c216481..5f0a2c06c 100644
--- a/templates/layout/_header.html.twig
+++ b/templates/layout/_header.html.twig
@@ -52,9 +52,9 @@
-
- {{ 'add_new_article'|trans }}
+
+ {{ 'add_new_thread'|trans }}
@@ -76,9 +76,9 @@
-
- {{ 'add_new_article'|trans }}
+
+ {{ 'add_new_thread'|trans }}
@@ -145,6 +145,12 @@
{{ app.user.countNewNotifications }}
+
+
+ {{ 'bookmark_lists'|trans }}
+
+
{% if is_granted('ROLE_ADMIN') %}
{{ form_start(form) }}
-
- {{ form_widget(form.query) }}
-
+
+ {{ form_widget(form.query, {'attr': {'class': 'form-control'}}) }}
+
diff --git a/templates/post/_menu.html.twig b/templates/post/_menu.html.twig
index e4db4f07a..dbdadc6f5 100644
--- a/templates/post/_menu.html.twig
+++ b/templates/post/_menu.html.twig
@@ -14,6 +14,17 @@
{{ 'activity'|trans }}
+
+ {% if app.user is defined and app.user is not same as null %}
+ {% set bookmarkLists = get_bookmark_lists(app.user) %}
+ {% if bookmarkLists|length %}
+
+ {% for list in bookmarkLists %}
+ {{ component('bookmark_list', { subject: post, subjectType: 'post', list: list }) }}
+ {% endfor %}
+ {% endif %}
+ {% endif %}
+
-
+
{{ 'threads'|trans }}
diff --git a/templates/post/comment/_menu.html.twig b/templates/post/comment/_menu.html.twig
index b9f9aed7b..582b56c90 100644
--- a/templates/post/comment/_menu.html.twig
+++ b/templates/post/comment/_menu.html.twig
@@ -14,6 +14,17 @@
{{ 'activity'|trans }}
+
+ {% if app.user is defined and app.user is not same as null %}
+ {% set bookmarkLists = get_bookmark_lists(app.user) %}
+ {% if bookmarkLists|length %}
+
+ {% for list in bookmarkLists %}
+ {{ component('bookmark_list', { subject: comment, subjectType: 'post_comment', list: list }) }}
+ {% endfor %}
+ {% endif %}
+ {% endif %}
+
+ {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }}
+
+
+
+
+
+
+
+ {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }}
+ {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }}
+
+ {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }}
+
+
+
+{{ form_end(form) }}
diff --git a/templates/search/front.html.twig b/templates/search/front.html.twig
index feac7ebf2..0b72851d0 100644
--- a/templates/search/front.html.twig
+++ b/templates/search/front.html.twig
@@ -15,17 +15,7 @@
{% block body %}
{{ 'search'|trans }}
-
+ {% include 'search/form.html.twig' %}