diff --git a/plugins/AttachmentCollections/AttachmentCollections.php b/plugins/AttachmentCollections/AttachmentCollections.php new file mode 100644 index 0000000000..a5825a8256 --- /dev/null +++ b/plugins/AttachmentCollections/AttachmentCollections.php @@ -0,0 +1,214 @@ +. +// }}} +/** + * Attachments Albums for GNU social + * + * @package GNUsocial + * @category Plugin + * @author Phablulo + * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ + +namespace Plugin\AttachmentCollections; + +use App\Core\Form; +use App\Core\Event; +use App\Core\DB\DB; +use App\Util\Common; +use App\Entity\Feed; +use App\Util\Nickname; +use App\Util\Formatting; +use App\Entity\LocalUser; +use App\Core\Router\Router; +use App\Core\Modules\Plugin; +use function App\Core\I18n\_m; +use App\Core\Router\RouteLoader; +use Symfony\Component\HttpFoundation\Request; +use Plugin\AttachmentCollections\Controller as C; +use Plugin\AttachmentCollections\Entity\Collection; +use Plugin\AttachmentCollections\Entity\CollectionEntry; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; + +class AttachmentCollections extends Plugin +{ + public function onAddRoute(RouteLoader $r): bool + { + // View all collections by actor id and nickname + $r->connect( + id: 'collections_view_by_actor_id', uri_path: '/actor/{id<\d+>}/collections', + target: [C\Controller::class, 'collectionsViewByActorId'] + ); + $r->connect( + id: 'collections_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/collections', + target: [C\Controller::class, 'collectionsByActorNickname'] + ); + // View notes from a collection by actor id and nickname + $r->connect( + id: 'collection_notes_view_by_actor_id', uri_path: '/actor/{id<\d+>}/collections/{cid<\d+>}', + target: [C\Controller::class, 'collectionNotesViewByActorId'] + ); + $r->connect( + id: 'collection_notes_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/collections/{cid<\d+>}', + target: [C\Controller::class, 'collectionNotesByNickname'] + ); + return Event::next; + } + public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering) + { + DB::persist(Feed::create([ + 'actor_id' => $actor_id, + 'url' => Router::url($route = 'collections_view_by_nickname', ['nickname' => $user->getNickname()]), + 'route' => $route, + 'title' => _m('Attachment Collections'), + 'ordering' => $ordering++, + ])); + return Event::next; + } + public function onAppendRightPanelBlock($vars, Request $request, &$res): bool + { + if ($vars['path'] !== 'attachment_show') return Event::next; + $user = Common::user(); + if (\is_null($user)) return Event::next; + + $colls = DB::dql( + 'select coll from Plugin\AttachmentCollections\Entity\Collection coll where coll.actor_id = :id', + ['id' => $user->getId()] + ); + + // add to collection form + $attachment_id = $vars['vars']['attachment_id']; + $choices = []; + foreach ($colls as $col) { + $choices[$col->getName()] = $col->getId(); + } + $already_selected = DB::dql( + 'select entry.collection_id from attachment_album_entry entry ' + . 'inner join attachment_collection collection ' + . 'with collection.id = entry.collection_id ' + . 'where entry.attachment_id = :aid and collection.actor_id = :id', + ['aid' => $attachment_id, 'id' => $user->getId()] + ); + $already_selected = \array_map(fn ($x) => $x['collection_id'], $already_selected); + $add_form = Form::create([ + ['collections', ChoiceType::class, [ + 'choices' => $choices, + 'multiple' => true, + 'required' => false, + 'choice_attr' => function ($id) use ($already_selected) { + if (\in_array($id, $already_selected)) { + return ['selected' => 'selected']; + } + return []; + } + ]], + ['add', SubmitType::class, [ + 'label' => _m('Add to collections'), + 'attr' => [ + 'title' => _m('Add to collection'), + ], + ]], + ]); + $add_form->handleRequest($request); + if ($add_form->isSubmitted() && $add_form->isValid()) { + $collections = $add_form->getData()['collections']; + $removed = \array_filter($already_selected, fn ($x) => !\in_array($x, $collections)); + $added = \array_filter($collections, fn ($x) => !\in_array($x, $already_selected)); + if (\count($removed)) { + DB::dql( + 'delete from Plugin\AttachmentCollections\Entity\CollectionEntry entry ' + . 'where entry.attachment_id = :aid and entry.collection_id in (' + . 'select collection.id from Plugin\AttachmentCollections\Entity\Collection collection ' + . 'where collection.id in (:ids) ' + // prevent user from deleting something he doesn't own: + . 'and collection.actor_id = :id' + . ')', + ['aid' => $attachment_id, 'id' => $user->getId(), 'ids' => $removed] + ); + } + foreach ($added as $cid) { + DB::persist(CollectionEntry::create([ + 'attachment_id' => $attachment_id, + 'collection_id' => $cid, + ])); + } + DB::flush(); + } + // add to new collection form + $create_form = Form::create([ + ['name', TextType::class, [ + 'label' => _m('Collection name'), + 'attr' => [ + 'placeholder' => _m('Name'), + 'required' => 'required' + ], + 'data' => '', + ]], + ['create', SubmitType::class, [ + 'label' => _m('Create a new collection'), + 'attr' => [ + 'title' => _m('Create a new collection'), + ], + ]], + ]); + $create_form->handleRequest($request); + if ($create_form->isSubmitted() && $create_form->isValid()) { + $name = $create_form->getData()['name']; + $coll = Collection::create([ + 'name' => $name, + 'actor_id' => $user->getId(), + ]); + DB::persist($coll); + DB::flush(); + DB::persist(CollectionEntry::create([ + 'attachment_id' => $attachment_id, + 'collection_id' => $coll->getId(), + ])); + DB::flush(); + } + + + $res[] = Formatting::twigRenderFile( + 'AttachmentCollections/widget.html.twig', + [ + 'colls' => $colls, + 'add_form' => $add_form->createView(), + 'create_form' => $create_form->createView(), + ] + ); + return Event::next; + } + /** + * Output our dedicated stylesheet + * + * @param array $styles stylesheets path + * + * @return bool hook value; true means continue processing, false means stop + */ + public function onEndShowStyles(array &$styles, string $route): bool + { + $styles[] = 'plugins/AttachmentCollections/assets/css/widget.css'; + $styles[] = 'plugins/AttachmentCollections/assets/css/pages.css'; + return Event::next; + } +} diff --git a/plugins/AttachmentCollections/Controller/Controller.php b/plugins/AttachmentCollections/Controller/Controller.php new file mode 100644 index 0000000000..482953ac39 --- /dev/null +++ b/plugins/AttachmentCollections/Controller/Controller.php @@ -0,0 +1,182 @@ +. + +// }}} + +namespace Plugin\AttachmentCollections\Controller; + +use App\Core\Form; +use App\Core\DB\DB; +use App\Util\Common; +use App\Core\Router\Router; +use function App\Core\I18n\_m; +use Component\Feed\Util\FeedController; +use Symfony\Component\HttpFoundation\Request; +use Plugin\AttachmentCollections\Entity\Collection; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; + +class Controller extends FeedController +{ + public function collectionsByActorNickname(Request $request, string $nickname): array + { + $user = DB::findOneBy('local_user', ['nickname' => $nickname]); + return self::collectionsView($request, $user->getId(), $nickname); + } + public function collectionsViewByActorId(Request $request, int $id): array + { + return self::collectionsView($request, $id, null); + } + public function collectionsView(Request $request, int $id, ?string $nickname): array + { + $collections = DB::dql( + 'select collection from Plugin\AttachmentCollections\Entity\Collection collection ' + . 'where collection.actor_id = :id', + ['id' => $id] + ); + $create = null; + if (Common::user()?->getId() === $id) { + $create = Form::create([ + ['name', TextType::class, [ + 'label' => _m('Create collection'), + 'attr' => [ + 'placeholder' => _m('Name'), + 'required' => 'required' + ], + 'data' => '', + ]], + ['add_collection', SubmitType::class, [ + 'label' => _m('Create collection'), + 'attr' => [ + 'title' => _m('Create collection'), + ], + ]], + ]); + $create->handleRequest($request); + if ($create->isSubmitted() && $create->isValid()) { + DB::persist(Collection::create([ + 'name' => $create->getData()['name'], + 'actor_id' => $id, + ])); + DB::flush(); + } + } + + $fn = new class ($id, $nickname, $request) + { + private $id; + private $nick; + private $request; + public function __construct($id, $nickname, $request) + { + $this->id = $id; + $this->nick = $nickname; + $this->request = $request; + } + public function getUrl($cid) + { + if (\is_null($this->nick)) { + return Router::url( + 'collection_notes_view_by_actor_id', + ['id' => $this->id, 'cid' => $cid] + ); + } + return Router::url( + 'collection_notes_view_by_nickname', + ['nickname' => $this->nick, 'cid' => $cid] + ); + } + public function editForm($collection) + { + $edit = Form::create([ + ['name', TextType::class, [ + 'attr' => [ + 'placeholder' => 'New name', + 'required' => 'required' + ], + 'data' => '', + ]], + ['update_'.$collection->getId(), SubmitType::class, [ + 'label' => _m('Save'), + 'attr' => [ + 'title' => _m('Save'), + ], + ]], + ]); + $edit->handleRequest($this->request); + if ($edit->isSubmitted() && $edit->isValid()) { + $collection->setName($edit->getData()['name']); + DB::persist($collection); + DB::flush(); + } + return $edit->createView(); + } + public function rmForm($collection) + { + $rm = Form::create([ + ['remove_'.$collection->getId(), SubmitType::class, [ + 'label' => _m('Delete collection'), + 'attr' => [ + 'title' => _m('Delete collection'), + 'class' => 'danger', + ], + ]], + ]); + $rm->handleRequest($this->request); + if ($rm->isSubmitted()) { + DB::remove($collection); + DB::flush(); + } + return $rm->createView(); + } + }; + + return [ + '_template' => 'AttachmentCollections/collections.html.twig', + 'page_title' => 'Attachment Collections list', + 'add_collection' => $create?->createView(), + 'fn' => $fn, + 'collections' => $collections, + ]; + } + + public function collectionNotesByNickname(Request $request, string $nickname, int $cid): array + { + $user = DB::findOneBy('local_user', ['nickname' => $nickname]); + return self::collectionNotesByActorId($request, $user->getId(), $cid); + } + public function collectionNotesByActorId(Request $request, int $id, int $cid): array + { + $collection = DB::findOneBy('attachment_collection', ['id' => $cid]); + $attchs = DB::dql( + 'select attch from attachment_album_entry entry ' + . 'left join Component\Attachment\Entity\Attachment attch ' + . 'with entry.attachment_id = attch.id ' + . 'where entry.collection_id = :cid', + ['cid' => $cid] + ); + return [ + '_template' => 'AttachmentCollections/collection.html.twig', + 'page_title' => $collection->getName(), + 'attachments' => $attchs, + ]; + } +} diff --git a/plugins/AttachmentCollections/Entity/Collection.php b/plugins/AttachmentCollections/Entity/Collection.php new file mode 100644 index 0000000000..1374bafe52 --- /dev/null +++ b/plugins/AttachmentCollections/Entity/Collection.php @@ -0,0 +1,65 @@ +id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setName(?string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setActorId(int $actor_id): self + { + $this->actor_id = $actor_id; + return $this; + } + + public function getActorId(): int + { + return $this->actor_id; + } + + + // @codeCoverageIgnoreEnd + // }}} Autocode + + + public static function schemaDef() + { + return [ + 'name' => 'attachment_collection', + 'fields' => [ + 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], + 'name' => ['type' => 'varchar', 'length' => 255, 'description' => 'collection\'s name'], + 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to many', 'not null' => true, 'description' => 'foreign key to actor table'], + ], + 'primary key' => ['id'], + ]; + } +} + diff --git a/plugins/AttachmentCollections/Entity/CollectionEntry.php b/plugins/AttachmentCollections/Entity/CollectionEntry.php new file mode 100644 index 0000000000..8ac4e3cdd5 --- /dev/null +++ b/plugins/AttachmentCollections/Entity/CollectionEntry.php @@ -0,0 +1,65 @@ +id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAttachmentId(int $attachment_id): self + { + $this->attachment_id = $attachment_id; + return $this; + } + + public function getAttachmentId(): int + { + return $this->attachment_id; + } + + public function setCollectionId(int $collection_id): self + { + $this->collection_id = $collection_id; + return $this; + } + + public function getCollectionId(): int + { + return $this->collection_id; + } + + + // @codeCoverageIgnoreEnd + // }}} Autocode + + + public static function schemaDef() + { + return [ + 'name' => 'attachment_album_entry', + 'fields' => [ + 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], + 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to attachment table'], + 'collection_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'AttachmentCollection.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to attachment_collection table'], + ], + 'primary key' => ['id'], + ]; + } +} + diff --git a/plugins/AttachmentCollections/templates/AttachmentCollections/collection.html.twig b/plugins/AttachmentCollections/templates/AttachmentCollections/collection.html.twig new file mode 100644 index 0000000000..6c55c0ecba --- /dev/null +++ b/plugins/AttachmentCollections/templates/AttachmentCollections/collection.html.twig @@ -0,0 +1,26 @@ +{% extends 'stdgrid.html.twig' %} +{% import '/cards/note/view.html.twig' as noteView %} + +{% block title %}{{ page_title | trans }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock stylesheets %} + +{% block body %} +

{{ page_title | trans }}

+ {# Backwards compatibility with hAtom 0.1 #} +
+
+ {% for attachment in attachments %} +
+ {% include '/cards/attachments/view.html.twig' with {'attachment': attachment, 'note': null} only%} + {{ 'Download link' | trans }} +
+ {% else %} +

{% trans %}No attachments here.{% endtrans %}

+ {% endfor %} +
+
+{% endblock body %} diff --git a/plugins/AttachmentCollections/templates/AttachmentCollections/collections.html.twig b/plugins/AttachmentCollections/templates/AttachmentCollections/collections.html.twig new file mode 100644 index 0000000000..27b303feae --- /dev/null +++ b/plugins/AttachmentCollections/templates/AttachmentCollections/collections.html.twig @@ -0,0 +1,43 @@ +{% extends 'stdgrid.html.twig' %} +{% import '/cards/note/view.html.twig' as noteView %} + +{% block title %}{{ page_title | trans }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock stylesheets %} + +{% block body %} +

{{ 'Attachment Collections' | trans }}

+ {# Backwards compatibility with hAtom 0.1 #} +
+
+ {% if add_collection %} +
+ {{ form(add_collection) }} +
+ {% endif %} +
+

{{ 'Your collections' | trans }}

+ {% for col in collections %} +
+ {{ col.name }} +
+ +
{{ icon('edit') | raw }}
+
+ {{ form(fn.editForm(col)) }} +
+
+ +
{{ icon('delete') | raw }}
+
+ {{ form(fn.rmForm(col)) }} +
+
+ {% endfor %} +
+
+
+{% endblock body %} diff --git a/plugins/AttachmentCollections/templates/AttachmentCollections/widget.html.twig b/plugins/AttachmentCollections/templates/AttachmentCollections/widget.html.twig new file mode 100644 index 0000000000..21ce61803a --- /dev/null +++ b/plugins/AttachmentCollections/templates/AttachmentCollections/widget.html.twig @@ -0,0 +1,18 @@ +
+
+ +

{% trans %}Add to collection{% endtrans %}

+ {{ icon('arrow-down', 'icon icon-details-open') | raw }} +
+ {{ form(add_form) }} +
+
+ +

{% trans %}More options{% endtrans %}

+ {{ icon('arrow-down', 'icon icon-details-close') | raw }} +
+ {{ form(create_form) }} +
+
+
+ diff --git a/public/assets/icons/delete.svg.twig b/public/assets/icons/delete.svg.twig new file mode 100644 index 0000000000..63809a3eee --- /dev/null +++ b/public/assets/icons/delete.svg.twig @@ -0,0 +1,9 @@ + + + + + + + Delete + + diff --git a/public/plugins/AttachmentCollections/assets/css/pages.css b/public/plugins/AttachmentCollections/assets/css/pages.css new file mode 100644 index 0000000000..055728f08f --- /dev/null +++ b/public/plugins/AttachmentCollections/assets/css/pages.css @@ -0,0 +1,73 @@ +.attachment-collection-add, .attachment-collections-list { + padding: 10px 12px; +} +.attachment-collection-add > form > div { + display: flex; + align-items: flex-end; +} +.attachment-collection-add > form > div .mb-6 { + margin-right: 12px; +} +.attachment-collection-add > form > div button { + margin-top: 0px; + margin-bottom: var(--s); +} +@media only screen and (max-width:465px) { + .attachment-collection-add > form, .attachment-collection-add > form > div .mb-6 { + width: 100%; + margin: 0px; + } + .attachment-collection-add > form > div { + flex-direction: column; + } + .attachment-collection-add > form > div button { + margin-top: var(--s);; + margin-bottom: 0px; + } +} + +:root { + --collections-list-quantity: 3; +} +.attachment-collections-list { + display: grid !important; + grid-gap: 12px; + grid-template-columns: repeat(var(--collections-list-quantity), 1fr); +} +.attachment-collections-list h3, .attachment-collections-list h2, .attachment-collections-list h1 { + grid-column-start: 1; + grid-column-end: calc(var(--collections-list-quantity) + 1); +} +.attachment-collections-list .attachment-collection-item { + border: 2px solid var(--border); + border-radius: var(--s); + padding: 10px 20px; + display: flex; + flex-direction: column; + position: relative; +} +.attachment-collections-list .attachment-collection-item .name { + margin-right: auto; + flex-grow: 1; + flex-shrink: 1; + word-break: break-word; + margin-right: 60px; +} +.attachment-collections-list .attachment-collection-item summary { + position: absolute; + top: 10px; + right: 50px; + width: 16px; +} +.attachment-collections-list .attachment-collection-item details + details > summary { + right: 20px; +} +.attachment-collections-list .attachment-collection-item details { + margin-top: 12px; +} +.attachment-collections-list .attachment-collection-item details label { + display: none; +} +.attachment-collections-list .attachment-collection-item details .danger { + color: #cb2d2d; +} diff --git a/public/plugins/AttachmentCollections/assets/css/widget.css b/public/plugins/AttachmentCollections/assets/css/widget.css new file mode 100644 index 0000000000..2cdf2c2ecb --- /dev/null +++ b/public/plugins/AttachmentCollections/assets/css/widget.css @@ -0,0 +1,12 @@ +.attachment-collections { + background-color: red; +} +.attachment-collections .attachment-collections-selections-options { + display: flex; + margin-top: 12px; + align-items: center; + justify-content: space-between; +} +.attachment-collections .attachment-collections-selections-options button { + margin: 0 !important; +}