diff --git a/app/Resources/translations/messages.en.xlf b/app/Resources/translations/messages.en.xlf index 7752760fd..c8f92fed0 100644 --- a/app/Resources/translations/messages.en.xlf +++ b/app/Resources/translations/messages.en.xlf @@ -202,6 +202,10 @@ label.published_at Published at + + label.tags + Tags + label.actions Actions diff --git a/app/Resources/translations/messages.es.xlf b/app/Resources/translations/messages.es.xlf index c02f992dc..699df5a66 100644 --- a/app/Resources/translations/messages.es.xlf +++ b/app/Resources/translations/messages.es.xlf @@ -206,6 +206,10 @@ label.published_at Publicado el + + label.tags + Etiquetas + label.actions Acciones diff --git a/app/Resources/translations/validators.en.xlf b/app/Resources/translations/validators.en.xlf index 8d20c1cc8..ce99cdb7f 100644 --- a/app/Resources/translations/validators.en.xlf +++ b/app/Resources/translations/validators.en.xlf @@ -14,6 +14,10 @@ post.too_short_content Post content is too short ({{ limit }} characters minimum) + + post.too_much_tags + Too much tags ({{ limit }} maximum) + comment.blank Please don't leave your comment blank! diff --git a/app/Resources/translations/validators.es.xlf b/app/Resources/translations/validators.es.xlf index 85c224ec1..1e5b46531 100644 --- a/app/Resources/translations/validators.es.xlf +++ b/app/Resources/translations/validators.es.xlf @@ -14,6 +14,10 @@ post.too_short_content El contenido del artículo es demasiado corto ({{ limit }} caracteres como mínimo) + + post.too_much_tags + Demasiadas etiquetas ({{ limit }} como máximo) + comment.blank No es posible dejar el contenido del comentario vacío. diff --git a/app/Resources/views/admin/blog/new.html.twig b/app/Resources/views/admin/blog/new.html.twig index 56c48b0b8..7c8ba0fde 100644 --- a/app/Resources/views/admin/blog/new.html.twig +++ b/app/Resources/views/admin/blog/new.html.twig @@ -10,6 +10,7 @@ {{ form_row(form.summary) }} {{ form_row(form.content) }} {{ form_row(form.publishedAt) }} + {{ form_row(form.tags) }} {{ form_widget(form.saveAndCreateNew, { label: 'label.save_and_create_new', attr: { class: 'btn btn-primary' } }) }} diff --git a/app/Resources/views/admin/blog/show.html.twig b/app/Resources/views/admin/blog/show.html.twig index 72a0169aa..3d5c6791e 100644 --- a/app/Resources/views/admin/blog/show.html.twig +++ b/app/Resources/views/admin/blog/show.html.twig @@ -4,6 +4,7 @@ {% block main %}

{{ post.title }}

+

@@ -14,6 +15,8 @@ {{ post.content|md2html }} + + {{ include('blog/_post_tags.html.twig') }} {% endblock %} {% block sidebar %} diff --git a/app/Resources/views/base.html.twig b/app/Resources/views/base.html.twig index 5dcdba48d..61c3daa64 100644 --- a/app/Resources/views/base.html.twig +++ b/app/Resources/views/base.html.twig @@ -16,6 +16,7 @@ + {% endblock %} @@ -141,6 +142,7 @@ + {% endblock %} diff --git a/app/Resources/views/blog/_post_tags.html.twig b/app/Resources/views/blog/_post_tags.html.twig new file mode 100644 index 000000000..4008d2b3f --- /dev/null +++ b/app/Resources/views/blog/_post_tags.html.twig @@ -0,0 +1,10 @@ +{% if not post.tags.empty %} +

+ {% for tag in post.tags %} + + {{ tag.name }} + + {% endfor %} +

+{% endif %} + diff --git a/app/Resources/views/blog/index.html.twig b/app/Resources/views/blog/index.html.twig index 6bf2eaa42..26897f591 100644 --- a/app/Resources/views/blog/index.html.twig +++ b/app/Resources/views/blog/index.html.twig @@ -11,6 +11,8 @@ + {{ include('blog/_post_tags.html.twig') }} +

diff --git a/app/Resources/views/blog/index.xml.twig b/app/Resources/views/blog/index.xml.twig index 9c551100d..f65b48ed4 100644 --- a/app/Resources/views/blog/index.xml.twig +++ b/app/Resources/views/blog/index.xml.twig @@ -16,6 +16,9 @@ {{ url('blog_post', {'slug': post.slug}) }} {{ post.publishedAt|date(format='r', timezone='GMT') }} {{ post.author.email }} + {% for tag in post.tags %} + {{ tag.name }} + {% endfor %} {% endfor %} diff --git a/app/Resources/views/blog/post_show.html.twig b/app/Resources/views/blog/post_show.html.twig index 37e9dc2d9..e1559e5a0 100644 --- a/app/Resources/views/blog/post_show.html.twig +++ b/app/Resources/views/blog/post_show.html.twig @@ -12,6 +12,8 @@ {{ post.content|md2html }} + {{ include('blog/_post_tags.html.twig') }} +

{# The 'IS_AUTHENTICATED_FULLY' role ensures that the user has entered his/her credentials (login + password) during this session. If he/she diff --git a/app/Resources/views/form/fields.html.twig b/app/Resources/views/form/fields.html.twig index 7bbaa06be..a8bde52e8 100644 --- a/app/Resources/views/form/fields.html.twig +++ b/app/Resources/views/form/fields.html.twig @@ -15,3 +15,12 @@
{% endblock %} + +{% block tags_input_widget %} +
+ {{ form_widget(form, {'attr': {'data-toggle': 'tagsinput', 'data-tags': tags|json_encode}}) }} + + + +
+{% endblock %} diff --git a/app/config/services.yml b/app/config/services.yml index 7fedcc5d3..a4ec074ac 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -22,6 +22,16 @@ services: tags: - { name: twig.extension } + # This is only needed if your form type requires some dependencies to be injected + # by the container, otherwise it is unnecessary overhead and therefore not recommended + # to do this for all form type classes. + # See http://symfony.com/doc/current/best_practices/forms.html + app.form.type.tagsinput: + class: AppBundle\Form\Type\TagsInputType + arguments: ['@doctrine.orm.entity_manager'] + tags: + - { name: form.type } + # Event Listeners are classes that listen to one or more specific events. # Those events are defined in the tags added to the service definition. # See http://symfony.com/doc/current/event_dispatcher.html#creating-an-event-listener diff --git a/src/AppBundle/DataFixtures/ORM/LoadFixtures.php b/src/AppBundle/DataFixtures/ORM/PostFixtures.php similarity index 72% rename from src/AppBundle/DataFixtures/ORM/LoadFixtures.php rename to src/AppBundle/DataFixtures/ORM/PostFixtures.php index 9f0f42f18..c54ba956c 100644 --- a/src/AppBundle/DataFixtures/ORM/LoadFixtures.php +++ b/src/AppBundle/DataFixtures/ORM/PostFixtures.php @@ -13,8 +13,9 @@ use AppBundle\Entity\Comment; use AppBundle\Entity\Post; -use AppBundle\Entity\User; +use AppBundle\Entity\Tag; use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerAwareTrait; @@ -31,8 +32,9 @@ * * @author Ryan Weaver * @author Javier Eguiluz + * @author Yonel Ceruto */ -class LoadFixtures extends AbstractFixture implements ContainerAwareInterface +class PostFixtures extends AbstractFixture implements DependentFixtureInterface, ContainerAwareInterface { use ContainerAwareTrait; @@ -41,46 +43,23 @@ class LoadFixtures extends AbstractFixture implements ContainerAwareInterface */ public function load(ObjectManager $manager) { - $this->loadUsers($manager); - $this->loadPosts($manager); - } - - private function loadUsers(ObjectManager $manager) - { - $passwordEncoder = $this->container->get('security.password_encoder'); - - $johnUser = new User(); - $johnUser->setUsername('john_user'); - $johnUser->setEmail('john_user@symfony.com'); - $encodedPassword = $passwordEncoder->encodePassword($johnUser, 'kitten'); - $johnUser->setPassword($encodedPassword); - $manager->persist($johnUser); - $this->addReference('john-user', $johnUser); - - $annaAdmin = new User(); - $annaAdmin->setUsername('anna_admin'); - $annaAdmin->setEmail('anna_admin@symfony.com'); - $annaAdmin->setRoles(['ROLE_ADMIN']); - $encodedPassword = $passwordEncoder->encodePassword($annaAdmin, 'kitten'); - $annaAdmin->setPassword($encodedPassword); - $manager->persist($annaAdmin); - $this->addReference('anna-admin', $annaAdmin); - - $manager->flush(); - } + $phrases = $this->getPhrases(); + shuffle($phrases); - private function loadPosts(ObjectManager $manager) - { - foreach (range(1, 30) as $i) { + foreach ($phrases as $i => $title) { $post = new Post(); - $post->setTitle($this->getRandomPostTitle()); + $post->setTitle($title); $post->setSummary($this->getRandomPostSummary()); $post->setSlug($this->container->get('slugger')->slugify($post->getTitle())); $post->setContent($this->getPostContent()); + // This reference has been added in UserFixtures class and contains + // an instance of User entity. $post->setAuthor($this->getReference('anna-admin')); $post->setPublishedAt(new \DateTime('now - '.$i.'days')); + $this->addRandomTags($post); + foreach (range(1, 5) as $j) { $comment = new Comment(); @@ -99,6 +78,34 @@ private function loadPosts(ObjectManager $manager) $manager->flush(); } + /** + * This method must return an array of fixtures classes + * on which the implementing class depends on. + * + * @return array + */ + public function getDependencies() + { + return [ + TagFixtures::class, + UserFixtures::class, + ]; + } + + private function addRandomTags(Post $post) + { + if (0 === $count = mt_rand(0, 3)) { + return; + } + + $indexes = (array) array_rand(TagFixtures::$names, $count); + foreach ($indexes as $index) { + /** @var Tag $tag */ + $tag = $this->getReference('tag-'.$index); + $post->addTag($tag); + } + } + private function getPostContent() { return <<<'MARKDOWN' @@ -157,16 +164,24 @@ private function getPhrases() 'Sed varius a risus eget aliquam', 'Nunc viverra elit ac laoreet suscipit', 'Pellentesque et sapien pulvinar consectetur', + 'Ubi est barbatus nix', + 'Abnobas sunt hilotaes de placidus vita', + 'Ubi est audax amicitia', + 'Eposs sunt solems de superbus fortis', + 'Vae humani generis', + 'Diatrias tolerare tanquam noster caesium', + 'Teres talis orgias saepe tractare de camerarius flavum sensorem', + 'Silva de secundus galatae demitto quadra', + 'Sunt accentores vitare salvus flavum parses', + 'Potus sensim ducunt ad ferox abnoba', + 'Sunt seculaes transferre talis camerarius fluctuies', + 'Era brevis ratione est', + 'Sunt torquises imitari velox mirabilis medicinaes', + 'Cum mineralis persuadere omnes finises desiderium bi-color', + 'Bassus fatalis classiss virtualiter transferre de flavum', ]; } - private function getRandomPostTitle() - { - $titles = $this->getPhrases(); - - return $titles[array_rand($titles)]; - } - private function getRandomPostSummary($maxLength = 255) { $phrases = $this->getPhrases(); diff --git a/src/AppBundle/DataFixtures/ORM/TagFixtures.php b/src/AppBundle/DataFixtures/ORM/TagFixtures.php new file mode 100644 index 000000000..de1082035 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/TagFixtures.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AppBundle\DataFixtures\ORM; + +use AppBundle\Entity\Tag; +use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\Persistence\ObjectManager; + +/** + * Defines the sample data to load in the database when running the unit and + * functional tests. + * + * Execute this command to load the data: + * + * $ php bin/console doctrine:fixtures:load + * + * See http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html + * + * @author Yonel Ceruto + */ +class TagFixtures extends AbstractFixture +{ + public static $names = [ + 'Lorem', + 'ipsum', + 'consectetur', + 'adipiscing', + 'incididunt', + 'labore', + 'voluptate', + 'dolore', + 'pariatur', + ]; + + /** + * {@inheritdoc} + */ + public function load(ObjectManager $manager) + { + foreach (self::$names as $index => $name) { + $tag = new Tag(); + $tag->setName($name); + + $manager->persist($tag); + $this->addReference('tag-'.$index, $tag); + } + + $manager->flush(); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/UserFixtures.php b/src/AppBundle/DataFixtures/ORM/UserFixtures.php new file mode 100644 index 000000000..7512bb418 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/UserFixtures.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AppBundle\DataFixtures\ORM; + +use AppBundle\Entity\User; +use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\Persistence\ObjectManager; +use Symfony\Component\DependencyInjection\ContainerAwareInterface; +use Symfony\Component\DependencyInjection\ContainerAwareTrait; + +/** + * Defines the sample data to load in the database when running the unit and + * functional tests. + * + * Execute this command to load the data: + * + * $ php bin/console doctrine:fixtures:load + * + * See http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html + * + * @author Ryan Weaver + * @author Javier Eguiluz + * @author Yonel Ceruto + */ +class UserFixtures extends AbstractFixture implements ContainerAwareInterface +{ + use ContainerAwareTrait; + + /** + * {@inheritdoc} + */ + public function load(ObjectManager $manager) + { + $passwordEncoder = $this->container->get('security.password_encoder'); + + $annaAdmin = new User(); + $annaAdmin->setUsername('anna_admin'); + $annaAdmin->setEmail('anna_admin@symfony.com'); + $annaAdmin->setRoles(['ROLE_ADMIN']); + $encodedPassword = $passwordEncoder->encodePassword($annaAdmin, 'kitten'); + $annaAdmin->setPassword($encodedPassword); + $manager->persist($annaAdmin); + // In case if fixture objects have relations to other fixtures, adds a reference + // to that object by name and later reference it to form a relation. + // See https://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html#sharing-objects-between-fixtures + $this->addReference('anna-admin', $annaAdmin); + + $johnUser = new User(); + $johnUser->setUsername('john_user'); + $johnUser->setEmail('john_user@symfony.com'); + $encodedPassword = $passwordEncoder->encodePassword($johnUser, 'kitten'); + $johnUser->setPassword($encodedPassword); + $manager->persist($johnUser); + $this->addReference('john-user', $johnUser); + + $manager->flush(); + } +} diff --git a/src/AppBundle/Entity/Post.php b/src/AppBundle/Entity/Post.php index 7482d243c..2b557b533 100644 --- a/src/AppBundle/Entity/Post.php +++ b/src/AppBundle/Entity/Post.php @@ -19,6 +19,7 @@ * * @author Ryan Weaver * @author Javier Eguiluz + * @author Yonel Ceruto */ class Post { @@ -99,10 +100,20 @@ class Post */ private $comments; + /** + * @var Tag[]|ArrayCollection + * + * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Tag", cascade={"persist"}) + * @ORM\JoinTable(name="symfony_demo_post_tag") + * @Assert\Count(max="4", maxMessage="post.too_much_tags") + */ + private $tags; + public function __construct() { $this->publishedAt = new \DateTime(); $this->comments = new ArrayCollection(); + $this->tags = new ArrayCollection(); } public function getId() @@ -211,4 +222,21 @@ public function setSummary($summary) { $this->summary = $summary; } + + public function addTag(Tag $tag) + { + if (!$this->tags->contains($tag)) { + $this->tags->add($tag); + } + } + + public function removeTag(Tag $tag) + { + $this->tags->removeElement($tag); + } + + public function getTags() + { + return $this->tags; + } } diff --git a/src/AppBundle/Entity/Tag.php b/src/AppBundle/Entity/Tag.php new file mode 100644 index 000000000..0ba291f92 --- /dev/null +++ b/src/AppBundle/Entity/Tag.php @@ -0,0 +1,65 @@ + + */ +class Tag implements \JsonSerializable +{ + /** + * @var int + * + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(type="string", unique=true) + */ + private $name; + + public function getId() + { + return $this->id; + } + + /** + * @param string $name + */ + public function setName($name) + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + /** + * {@inheritdoc} + */ + public function jsonSerialize() + { + return $this->name; + } + + public function __toString() + { + return $this->name; + } +} diff --git a/src/AppBundle/Entity/User.php b/src/AppBundle/Entity/User.php index b5b7dcc83..c163c7dbc 100644 --- a/src/AppBundle/Entity/User.php +++ b/src/AppBundle/Entity/User.php @@ -132,7 +132,7 @@ public function getSalt() // we're using bcrypt in security.yml to encode the password, so // the salt value is built-in and you don't have to generate one - return null; + return; } /** diff --git a/src/AppBundle/Form/DataTransformer/TagArrayToStringTransformer.php b/src/AppBundle/Form/DataTransformer/TagArrayToStringTransformer.php new file mode 100644 index 000000000..19c22f741 --- /dev/null +++ b/src/AppBundle/Form/DataTransformer/TagArrayToStringTransformer.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AppBundle\Form\DataTransformer; + +use AppBundle\Entity\Tag; +use Doctrine\Common\Persistence\ObjectManager; +use Symfony\Component\Form\DataTransformerInterface; + +/** + * This data transformer is used to translate the array of tags into a comma separated format + * that can be displayed and managed by Bootstrap-tagsinput js plugin (and back on submit). + * + * See http://symfony.com/doc/current/form/data_transformers.html + * + * @author Yonel Ceruto + */ +class TagArrayToStringTransformer implements DataTransformerInterface +{ + private $manager; + + public function __construct(ObjectManager $manager) + { + $this->manager = $manager; + } + + /** + * {@inheritdoc} + */ + public function transform($array) + { + /* @var Tag[] $array */ + return implode(',', $array); + } + + /** + * {@inheritdoc} + */ + public function reverseTransform($string) + { + if ('' === $string || null === $string) { + return []; + } + + $names = explode(',', $string); + + $tags = $this->manager->getRepository(Tag::class)->findBy([ + 'name' => $names, + ]); + + $newNames = array_diff($names, $tags); + foreach ($newNames as $name) { + $tag = new Tag(); + $tag->setName($name); + $tags[] = $tag; + } + + return $tags; + } +} diff --git a/src/AppBundle/Form/PostType.php b/src/AppBundle/Form/PostType.php index 45e6ac0a2..743df1ad8 100644 --- a/src/AppBundle/Form/PostType.php +++ b/src/AppBundle/Form/PostType.php @@ -13,6 +13,7 @@ use AppBundle\Entity\Post; use AppBundle\Form\Type\DateTimePickerType; +use AppBundle\Form\Type\TagsInputType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; @@ -23,6 +24,7 @@ * * @author Ryan Weaver * @author Javier Eguiluz + * @author Yonel Ceruto */ class PostType extends AbstractType { @@ -55,6 +57,10 @@ public function buildForm(FormBuilderInterface $builder, array $options) ->add('publishedAt', DateTimePickerType::class, [ 'label' => 'label.published_at', ]) + ->add('tags', TagsInputType::class, [ + 'label' => 'label.tags', + 'required' => false, + ]) ; } diff --git a/src/AppBundle/Form/Type/TagsInputType.php b/src/AppBundle/Form/Type/TagsInputType.php new file mode 100644 index 000000000..666757f08 --- /dev/null +++ b/src/AppBundle/Form/Type/TagsInputType.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AppBundle\Form\Type; + +use AppBundle\Entity\Tag; +use AppBundle\Form\DataTransformer\TagArrayToStringTransformer; +use Doctrine\Common\Persistence\ObjectManager; +use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; + +/** + * Defines the custom form field type used to manipulate tags values across + * Bootstrap-tagsinput javascript plugin. + * + * See http://symfony.com/doc/current/cookbook/form/create_custom_field_type.html + * + * @author Yonel Ceruto + */ +class TagsInputType extends AbstractType +{ + private $manager; + + public function __construct(ObjectManager $manager) + { + $this->manager = $manager; + } + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->setByReference(false) + ->addModelTransformer(new TagArrayToStringTransformer($this->manager)) + ->addModelTransformer(new CollectionToArrayTransformer()) + ; + } + + /** + * {@inheritdoc} + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['tags'] = $this->manager->getRepository(Tag::class)->findAll(); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return TextType::class; + } +} diff --git a/var/data/blog.sqlite b/var/data/blog.sqlite index f15d78071..86188e51c 100644 Binary files a/var/data/blog.sqlite and b/var/data/blog.sqlite differ diff --git a/var/data/blog_test.sqlite b/var/data/blog_test.sqlite index 287a6fc07..86188e51c 100644 Binary files a/var/data/blog_test.sqlite and b/var/data/blog_test.sqlite differ diff --git a/web/css/bootstrap-tagsinput.css b/web/css/bootstrap-tagsinput.css new file mode 100644 index 000000000..5e1e3e2e0 --- /dev/null +++ b/web/css/bootstrap-tagsinput.css @@ -0,0 +1,191 @@ +/* ------------------------------------------------------------------------------ + * + * # Twiter Typeahead + * + * Styles for tagsinput.js - input suggestion engine + * + * ---------------------------------------------------------------------------- */ +.twitter-typeahead { + width: 100%; +} +.typeahead, +.tt-query, +.tt-hint { + outline: 0; +} +.tt-hint { + color: #999; +} +.tt-menu{ + width: 100%; + margin-top: 1px; + min-width: 180px; + padding: 7px 0; + background-color: #fff; + border: 1px solid rgba(0,0,0,0.15); + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + -webkit-box-shadow: 0 6px 12px rgba(0,0,0,0.175); + box-shadow: 0 6px 12px rgba(0,0,0,0.175); + -webkit-background-clip: padding-box; + background-clip: padding-box; +} +.typeahead-scrollable .tt-menu{ + max-height: 250px; +} +.typeahead-rtl .tt-menu{ + text-align: right; +} +.tt-suggestion { + padding: 8px 15px; + cursor: pointer; +} +.tt-suggestion.tt-cursor { + background-color: #f5f5f5; +} +.tt-suggestion p { + margin: 0; +} +.tt-suggestion.tt-selectable:before { + content: '\f02b'; + font-family: 'FontAwesome'; + display: inline-block; + font-size: 15px; + margin-right: 0.5em; + color: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.tt-dataset-group .tt-suggestion { + padding-left: 24px; + padding-right: 24px; +} +.tt-heading { + font-size: 11px; + line-height: 1.82; + padding: 8px 15px; + text-transform: uppercase; + display: block; + font-weight: 700; + margin-top: 2px; + margin-bottom: 2px; +} +.tt-suggestion:hover, +.tt-suggestion:focus { + color: #ffffff; + text-decoration: none; + outline: 0; + background-color: #18bc9c; +} +/* ------------------------------------------------------------------------------ +* +* # Bootstrap tags input +* +* Styles for tagsinput.js - tags input for Bootstrap +* +* ---------------------------------------------------------------------------- */ +.bootstrap-tagsinput { + display: table-cell; + vertical-align: middle; + width: 100%; + height: 45px; + padding: 0; + font-size: 15px; + line-height: 1.42857143; + color: #2c3e50; + background-color: #ffffff; + background-image: none; + border: 2px solid #dce4ec; + border-radius: 4px; + border-bottom-right-radius: 0; + border-top-right-radius: 0; + -webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s; +} +.has-error .bootstrap-tagsinput { + border-color: #e74c3c !important; +} +.bootstrap-tagsinput.focus { + border-color: #2c3e50; + outline: 0; + box-shadow: none; +} +.bootstrap-tagsinput input { + border: 0; + outline: 0; + background-color: transparent; + padding: 5px 11px; + margin-top: 2px; + margin-left: 2px; + width: auto !important; + min-width: 100px; + font-size: 15px; + line-height: 1.6666667; + -webkit-box-shadow: none; + box-shadow: none; +} +.bootstrap-tagsinput input:focus { + border: none; + box-shadow: none; +} +.bootstrap-tagsinput .twitter-typeahead { + width: auto; +} +.bootstrap-tagsinput .tt-menu { + margin-top: 5px; + min-width: 200px; +} +.bootstrap-tagsinput .tag { + margin: 1px 0 0 3px; + border: 0; + border-radius: .25em; + padding: 5px 11px; + padding-right: 30px; + float: left; + font-size: 15px; + line-height: 1.6666667; + font-weight: 400; + text-transform: none; + position: relative; + background-color: #18bc9c; + color: #fff; +} +.has-error .bootstrap-tagsinput .tag { + background-color: #e74c3c !important; +} +.bootstrap-tagsinput .tag [data-role="remove"] { + cursor: pointer; + color: inherit; + position: absolute; + top: 50%; + right: 11px; + line-height: 1; + margin-top: -5.5px; + opacity: 0.7; + filter: alpha(opacity=70); +} +.bootstrap-tagsinput .tag [data-role="remove"]:hover { + opacity: 1; + filter: alpha(opacity=100); +} +.bootstrap-tagsinput .tag:before { + content: '\f02b'; + font-family: 'FontAwesome'; + display: inline-block; + font-size: 15px; + margin-right: 0.5em; + color: #fff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.bootstrap-tagsinput .tag [data-role="remove"]:after { + content: '\f00d'; + font-family: 'FontAwesome'; + display: block; + font-size: 13px; + color: #fff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/web/css/main.css b/web/css/main.css index 362fc749f..f66edc1c2 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -218,6 +218,10 @@ body#login #login-users-help p .console { margin-right: 1.5em; } +.post-tags .label { + margin-right: 5px; +} + /* Page: 'Blog index' ------------------------------------------------------------------------- */ body#blog_index h1 { @@ -233,6 +237,10 @@ body#blog_index .post-metadata { margin-bottom: 8px; } +body#blog_index .post-tags { + margin-bottom: 15px; +} + /* Page: 'Blog post show' ------------------------------------------------------------------------- */ body#blog_post_show h3 { diff --git a/web/js/bootstrap-tagsinput.min.js b/web/js/bootstrap-tagsinput.min.js new file mode 100644 index 000000000..611333a08 --- /dev/null +++ b/web/js/bootstrap-tagsinput.min.js @@ -0,0 +1,15 @@ +/*! + * typeahead.js 0.11.1 + * https://github.com/twitter/typeahead.js + * Copyright 2013-2015 Twitter, Inc. and other contributors; Licensed MIT + */ + +!function(a,b){"function"==typeof define&&define.amd?define("bloodhound",["jquery"],function(c){return a.Bloodhound=b(c)}):"object"==typeof exports?module.exports=b(require("jquery")):a.Bloodhound=b(jQuery)}(this,function(a){var b=function(){"use strict";return{isMsie:function(){return/(msie|trident)/i.test(navigator.userAgent)?navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2]:!1},isBlankString:function(a){return!a||/^\s*$/.test(a)},escapeRegExChars:function(a){return a.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isString:function(a){return"string"==typeof a},isNumber:function(a){return"number"==typeof a},isArray:a.isArray,isFunction:a.isFunction,isObject:a.isPlainObject,isUndefined:function(a){return"undefined"==typeof a},isElement:function(a){return!(!a||1!==a.nodeType)},isJQuery:function(b){return b instanceof a},toStr:function(a){return b.isUndefined(a)||null===a?"":a+""},bind:a.proxy,each:function(b,c){function d(a,b){return c(b,a)}a.each(b,d)},map:a.map,filter:a.grep,every:function(b,c){var d=!0;return b?(a.each(b,function(a,e){return(d=c.call(null,e,a,b))?void 0:!1}),!!d):d},some:function(b,c){var d=!1;return b?(a.each(b,function(a,e){return(d=c.call(null,e,a,b))?!1:void 0}),!!d):d},mixin:a.extend,identity:function(a){return a},clone:function(b){return a.extend(!0,{},b)},getIdGenerator:function(){var a=0;return function(){return a++}},templatify:function(b){function c(){return String(b)}return a.isFunction(b)?b:c},defer:function(a){setTimeout(a,0)},debounce:function(a,b,c){var d,e;return function(){var f,g,h=this,i=arguments;return f=function(){d=null,c||(e=a.apply(h,i))},g=c&&!d,clearTimeout(d),d=setTimeout(f,b),g&&(e=a.apply(h,i)),e}},throttle:function(a,b){var c,d,e,f,g,h;return g=0,h=function(){g=new Date,e=null,f=a.apply(c,d)},function(){var i=new Date,j=b-(i-g);return c=this,d=arguments,0>=j?(clearTimeout(e),e=null,g=i,f=a.apply(c,d)):e||(e=setTimeout(h,j)),f}},stringify:function(a){return b.isString(a)?a:JSON.stringify(a)},noop:function(){}}}(),c="0.11.1",d=function(){"use strict";function a(a){return a=b.toStr(a),a?a.split(/\s+/):[]}function c(a){return a=b.toStr(a),a?a.split(/\W+/):[]}function d(a){return function(c){return c=b.isArray(c)?c:[].slice.call(arguments,0),function(d){var e=[];return b.each(c,function(c){e=e.concat(a(b.toStr(d[c])))}),e}}}return{nonword:c,whitespace:a,obj:{nonword:d(c),whitespace:d(a)}}}(),e=function(){"use strict";function c(c){this.maxSize=b.isNumber(c)?c:100,this.reset(),this.maxSize<=0&&(this.set=this.get=a.noop)}function d(){this.head=this.tail=null}function e(a,b){this.key=a,this.val=b,this.prev=this.next=null}return b.mixin(c.prototype,{set:function(a,b){var c,d=this.list.tail;this.size>=this.maxSize&&(this.list.remove(d),delete this.hash[d.key],this.size--),(c=this.hash[a])?(c.val=b,this.list.moveToFront(c)):(c=new e(a,b),this.list.add(c),this.hash[a]=c,this.size++)},get:function(a){var b=this.hash[a];return b?(this.list.moveToFront(b),b.val):void 0},reset:function(){this.size=0,this.hash={},this.list=new d}}),b.mixin(d.prototype,{add:function(a){this.head&&(a.next=this.head,this.head.prev=a),this.head=a,this.tail=this.tail||a},remove:function(a){a.prev?a.prev.next=a.next:this.head=a.next,a.next?a.next.prev=a.prev:this.tail=a.prev},moveToFront:function(a){this.remove(a),this.add(a)}}),c}(),f=function(){"use strict";function c(a,c){this.prefix=["__",a,"__"].join(""),this.ttlKey="__ttl__",this.keyMatcher=new RegExp("^"+b.escapeRegExChars(this.prefix)),this.ls=c||h,!this.ls&&this._noop()}function d(){return(new Date).getTime()}function e(a){return JSON.stringify(b.isUndefined(a)?null:a)}function f(b){return a.parseJSON(b)}function g(a){var b,c,d=[],e=h.length;for(b=0;e>b;b++)(c=h.key(b)).match(a)&&d.push(c.replace(a,""));return d}var h;try{h=window.localStorage,h.setItem("~~~","!"),h.removeItem("~~~")}catch(i){h=null}return b.mixin(c.prototype,{_prefix:function(a){return this.prefix+a},_ttlKey:function(a){return this._prefix(a)+this.ttlKey},_noop:function(){this.get=this.set=this.remove=this.clear=this.isExpired=b.noop},_safeSet:function(a,b){try{this.ls.setItem(a,b)}catch(c){"QuotaExceededError"===c.name&&(this.clear(),this._noop())}},get:function(a){return this.isExpired(a)&&this.remove(a),f(this.ls.getItem(this._prefix(a)))},set:function(a,c,f){return b.isNumber(f)?this._safeSet(this._ttlKey(a),e(d()+f)):this.ls.removeItem(this._ttlKey(a)),this._safeSet(this._prefix(a),e(c))},remove:function(a){return this.ls.removeItem(this._ttlKey(a)),this.ls.removeItem(this._prefix(a)),this},clear:function(){var a,b=g(this.keyMatcher);for(a=b.length;a--;)this.remove(b[a]);return this},isExpired:function(a){var c=f(this.ls.getItem(this._ttlKey(a)));return b.isNumber(c)&&d()>c?!0:!1}}),c}(),g=function(){"use strict";function c(a){a=a||{},this.cancelled=!1,this.lastReq=null,this._send=a.transport,this._get=a.limiter?a.limiter(this._get):this._get,this._cache=a.cache===!1?new e(0):h}var d=0,f={},g=6,h=new e(10);return c.setMaxPendingRequests=function(a){g=a},c.resetCache=function(){h.reset()},b.mixin(c.prototype,{_fingerprint:function(b){return b=b||{},b.url+b.type+a.param(b.data||{})},_get:function(a,b){function c(a){b(null,a),k._cache.set(i,a)}function e(){b(!0)}function h(){d--,delete f[i],k.onDeckRequestArgs&&(k._get.apply(k,k.onDeckRequestArgs),k.onDeckRequestArgs=null)}var i,j,k=this;i=this._fingerprint(a),this.cancelled||i!==this.lastReq||((j=f[i])?j.done(c).fail(e):g>d?(d++,f[i]=this._send(a).done(c).fail(e).always(h)):this.onDeckRequestArgs=[].slice.call(arguments,0))},get:function(c,d){var e,f;d=d||a.noop,c=b.isString(c)?{url:c}:c||{},f=this._fingerprint(c),this.cancelled=!1,this.lastReq=f,(e=this._cache.get(f))?d(null,e):this._get(c,d)},cancel:function(){this.cancelled=!0}}),c}(),h=window.SearchIndex=function(){"use strict";function c(c){c=c||{},c.datumTokenizer&&c.queryTokenizer||a.error("datumTokenizer and queryTokenizer are both required"),this.identify=c.identify||b.stringify,this.datumTokenizer=c.datumTokenizer,this.queryTokenizer=c.queryTokenizer,this.reset()}function d(a){return a=b.filter(a,function(a){return!!a}),a=b.map(a,function(a){return a.toLowerCase()})}function e(){var a={};return a[i]=[],a[h]={},a}function f(a){for(var b={},c=[],d=0,e=a.length;e>d;d++)b[a[d]]||(b[a[d]]=!0,c.push(a[d]));return c}function g(a,b){var c=0,d=0,e=[];a=a.sort(),b=b.sort();for(var f=a.length,g=b.length;f>c&&g>d;)a[c]b[d]?d++:(e.push(a[c]),c++,d++);return e}var h="c",i="i";return b.mixin(c.prototype,{bootstrap:function(a){this.datums=a.datums,this.trie=a.trie},add:function(a){var c=this;a=b.isArray(a)?a:[a],b.each(a,function(a){var f,g;c.datums[f=c.identify(a)]=a,g=d(c.datumTokenizer(a)),b.each(g,function(a){var b,d,g;for(b=c.trie,d=a.split("");g=d.shift();)b=b[h][g]||(b[h][g]=e()),b[i].push(f)})})},get:function(a){var c=this;return b.map(a,function(a){return c.datums[a]})},search:function(a){var c,e,j=this;return c=d(this.queryTokenizer(a)),b.each(c,function(a){var b,c,d,f;if(e&&0===e.length)return!1;for(b=j.trie,c=a.split("");b&&(d=c.shift());)b=b[h][d];return b&&0===c.length?(f=b[i].slice(0),void(e=e?g(e,f):f)):(e=[],!1)}),e?b.map(f(e),function(a){return j.datums[a]}):[]},all:function(){var a=[];for(var b in this.datums)a.push(this.datums[b]);return a},reset:function(){this.datums={},this.trie=e()},serialize:function(){return{datums:this.datums,trie:this.trie}}}),c}(),i=function(){"use strict";function a(a){this.url=a.url,this.ttl=a.ttl,this.cache=a.cache,this.prepare=a.prepare,this.transform=a.transform,this.transport=a.transport,this.thumbprint=a.thumbprint,this.storage=new f(a.cacheKey)}var c;return c={data:"data",protocol:"protocol",thumbprint:"thumbprint"},b.mixin(a.prototype,{_settings:function(){return{url:this.url,type:"GET",dataType:"json"}},store:function(a){this.cache&&(this.storage.set(c.data,a,this.ttl),this.storage.set(c.protocol,location.protocol,this.ttl),this.storage.set(c.thumbprint,this.thumbprint,this.ttl))},fromCache:function(){var a,b={};return this.cache?(b.data=this.storage.get(c.data),b.protocol=this.storage.get(c.protocol),b.thumbprint=this.storage.get(c.thumbprint),a=b.thumbprint!==this.thumbprint||b.protocol!==location.protocol,b.data&&!a?b.data:null):null},fromNetwork:function(a){function b(){a(!0)}function c(b){a(null,e.transform(b))}var d,e=this;a&&(d=this.prepare(this._settings()),this.transport(d).fail(b).done(c))},clear:function(){return this.storage.clear(),this}}),a}(),j=function(){"use strict";function a(a){this.url=a.url,this.prepare=a.prepare,this.transform=a.transform,this.transport=new g({cache:a.cache,limiter:a.limiter,transport:a.transport})}return b.mixin(a.prototype,{_settings:function(){return{url:this.url,type:"GET",dataType:"json"}},get:function(a,b){function c(a,c){b(a?[]:e.transform(c))}var d,e=this;if(b)return a=a||"",d=this.prepare(a,this._settings()),this.transport.get(d,c)},cancelLastRequest:function(){this.transport.cancel()}}),a}(),k=function(){"use strict";function d(d){var e;return d?(e={url:null,ttl:864e5,cache:!0,cacheKey:null,thumbprint:"",prepare:b.identity,transform:b.identity,transport:null},d=b.isString(d)?{url:d}:d,d=b.mixin(e,d),!d.url&&a.error("prefetch requires url to be set"),d.transform=d.filter||d.transform,d.cacheKey=d.cacheKey||d.url,d.thumbprint=c+d.thumbprint,d.transport=d.transport?h(d.transport):a.ajax,d):null}function e(c){var d;if(c)return d={url:null,cache:!0,prepare:null,replace:null,wildcard:null,limiter:null,rateLimitBy:"debounce",rateLimitWait:300,transform:b.identity,transport:null},c=b.isString(c)?{url:c}:c,c=b.mixin(d,c),!c.url&&a.error("remote requires url to be set"),c.transform=c.filter||c.transform,c.prepare=f(c),c.limiter=g(c),c.transport=c.transport?h(c.transport):a.ajax,delete c.replace,delete c.wildcard,delete c.rateLimitBy,delete c.rateLimitWait,c}function f(a){function b(a,b){return b.url=f(b.url,a),b}function c(a,b){return b.url=b.url.replace(g,encodeURIComponent(a)),b}function d(a,b){return b}var e,f,g;return e=a.prepare,f=a.replace,g=a.wildcard,e?e:e=f?b:a.wildcard?c:d}function g(a){function c(a){return function(c){return b.debounce(c,a)}}function d(a){return function(c){return b.throttle(c,a)}}var e,f,g;return e=a.limiter,f=a.rateLimitBy,g=a.rateLimitWait,e||(e=/^throttle$/i.test(f)?d(g):c(g)),e}function h(c){return function(d){function e(a){b.defer(function(){g.resolve(a)})}function f(a){b.defer(function(){g.reject(a)})}var g=a.Deferred();return c(d,e,f),g}}return function(c){var f,g;return f={initialize:!0,identify:b.stringify,datumTokenizer:null,queryTokenizer:null,sufficient:5,sorter:null,local:[],prefetch:null,remote:null},c=b.mixin(f,c||{}),!c.datumTokenizer&&a.error("datumTokenizer is required"),!c.queryTokenizer&&a.error("queryTokenizer is required"),g=c.sorter,c.sorter=g?function(a){return a.sort(g)}:b.identity,c.local=b.isFunction(c.local)?c.local():c.local,c.prefetch=d(c.prefetch),c.remote=e(c.remote),c}}(),l=function(){"use strict";function c(a){a=k(a),this.sorter=a.sorter,this.identify=a.identify,this.sufficient=a.sufficient,this.local=a.local,this.remote=a.remote?new j(a.remote):null,this.prefetch=a.prefetch?new i(a.prefetch):null,this.index=new h({identify:this.identify,datumTokenizer:a.datumTokenizer,queryTokenizer:a.queryTokenizer}),a.initialize!==!1&&this.initialize()}var e;return e=window&&window.Bloodhound,c.noConflict=function(){return window&&(window.Bloodhound=e),c},c.tokenizers=d,b.mixin(c.prototype,{__ttAdapter:function(){function a(a,b,d){return c.search(a,b,d)}function b(a,b){return c.search(a,b)}var c=this;return this.remote?a:b},_loadPrefetch:function(){function b(a,b){return a?c.reject():(e.add(b),e.prefetch.store(e.index.serialize()),void c.resolve())}var c,d,e=this;return c=a.Deferred(),this.prefetch?(d=this.prefetch.fromCache())?(this.index.bootstrap(d),c.resolve()):this.prefetch.fromNetwork(b):c.resolve(),c.promise()},_initialize:function(){function a(){b.add(b.local)}var b=this;return this.clear(),(this.initPromise=this._loadPrefetch()).done(a),this.initPromise},initialize:function(a){return!this.initPromise||a?this._initialize():this.initPromise},add:function(a){return this.index.add(a),this},get:function(a){return a=b.isArray(a)?a:[].slice.call(arguments),this.index.get(a)},search:function(a,c,d){function e(a){var c=[];b.each(a,function(a){!b.some(f,function(b){return g.identify(a)===g.identify(b)})&&c.push(a)}),d&&d(c)}var f,g=this;return f=this.sorter(this.index.search(a)),c(this.remote?f.slice():f),this.remote&&f.length=j?(clearTimeout(e),e=null,g=i,f=a.apply(c,d)):e||(e=setTimeout(h,j)),f}},stringify:function(a){return b.isString(a)?a:JSON.stringify(a)},noop:function(){}}}(),c=function(){"use strict";function a(a){var g,h;return h=b.mixin({},f,a),g={css:e(),classes:h,html:c(h),selectors:d(h)},{css:g.css,html:g.html,classes:g.classes,selectors:g.selectors,mixin:function(a){b.mixin(a,g)}}}function c(a){return{wrapper:'',menu:'
'}}function d(a){var c={};return b.each(a,function(a,b){c[b]="."+a}),c}function e(){var a={wrapper:{position:"relative",display:"inline-block"},hint:{position:"absolute",top:"0",left:"0",borderColor:"transparent",boxShadow:"none",opacity:"1"},input:{position:"relative",verticalAlign:"top",backgroundColor:"transparent"},inputWithNoHint:{position:"relative",verticalAlign:"top"},menu:{position:"absolute",top:"100%",left:"0",zIndex:"100",display:"none"},ltr:{left:"0",right:"auto"},rtl:{left:"auto",right:" 0"}};return b.isMsie()&&b.mixin(a.input,{backgroundImage:"url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"}),a}var f={wrapper:"twitter-typeahead",input:"tt-input",hint:"tt-hint",menu:"tt-menu",dataset:"tt-dataset",suggestion:"tt-suggestion",selectable:"tt-selectable",empty:"tt-empty",open:"tt-open",cursor:"tt-cursor",highlight:"tt-highlight"};return a}(),d=function(){"use strict";function c(b){b&&b.el||a.error("EventBus initialized without el"),this.$el=a(b.el)}var d,e;return d="typeahead:",e={render:"rendered",cursorchange:"cursorchanged",select:"selected",autocomplete:"autocompleted"},b.mixin(c.prototype,{_trigger:function(b,c){var e;return e=a.Event(d+b),(c=c||[]).unshift(e),this.$el.trigger.apply(this.$el,c),e},before:function(a){var b,c;return b=[].slice.call(arguments,1),c=this._trigger("before"+a,b),c.isDefaultPrevented()},trigger:function(a){var b;this._trigger(a,[].slice.call(arguments,1)),(b=e[a])&&this._trigger(b,[].slice.call(arguments,1))}}),c}(),e=function(){"use strict";function a(a,b,c,d){var e;if(!c)return this;for(b=b.split(i),c=d?h(c,d):c,this._callbacks=this._callbacks||{};e=b.shift();)this._callbacks[e]=this._callbacks[e]||{sync:[],async:[]},this._callbacks[e][a].push(c);return this}function b(b,c,d){return a.call(this,"async",b,c,d)}function c(b,c,d){return a.call(this,"sync",b,c,d)}function d(a){var b;if(!this._callbacks)return this;for(a=a.split(i);b=a.shift();)delete this._callbacks[b];return this}function e(a){var b,c,d,e,g;if(!this._callbacks)return this;for(a=a.split(i),d=[].slice.call(arguments,1);(b=a.shift())&&(c=this._callbacks[b]);)e=f(c.sync,this,[b].concat(d)),g=f(c.async,this,[b].concat(d)),e()&&j(g);return this}function f(a,b,c){function d(){for(var d,e=0,f=a.length;!d&&f>e;e+=1)d=a[e].apply(b,c)===!1;return!d}return d}function g(){var a;return a=window.setImmediate?function(a){setImmediate(function(){a()})}:function(a){setTimeout(function(){a()},0)}}function h(a,b){return a.bind?a.bind(b):function(){a.apply(b,[].slice.call(arguments,0))}}var i=/\s+/,j=g();return{onSync:c,onAsync:b,off:d,trigger:e}}(),f=function(a){"use strict";function c(a,c,d){for(var e,f=[],g=0,h=a.length;h>g;g++)f.push(b.escapeRegExChars(a[g]));return e=d?"\\b("+f.join("|")+")\\b":"("+f.join("|")+")",c?new RegExp(e):new RegExp(e,"i")}var d={node:null,pattern:null,tagName:"strong",className:null,wordsOnly:!1,caseSensitive:!1};return function(e){function f(b){var c,d,f;return(c=h.exec(b.data))&&(f=a.createElement(e.tagName),e.className&&(f.className=e.className),d=b.splitText(c.index),d.splitText(c[0].length),f.appendChild(d.cloneNode(!0)),b.parentNode.replaceChild(f,d)),!!c}function g(a,b){for(var c,d=3,e=0;e