]> Raphaël G. Git Repositories - airbundle/blobdiff - Controller/DefaultController.php
Shorten air bundle route alias
[airbundle] / Controller / DefaultController.php
index 3a7980551c620877ac15c9f082c861d585678d11..54250c91b56387a36fca38f05c41952d4e532e55 100644 (file)
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys AirBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Rapsys\AirBundle\Controller;
 
 use Symfony\Bridge\Twig\Mime\TemplatedEmail;
 
 namespace Rapsys\AirBundle\Controller;
 
 use Symfony\Bridge\Twig\Mime\TemplatedEmail;
-use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Symfony\Bundle\FrameworkBundle\Controller\ControllerTrait;
-use Symfony\Component\Asset\Packages;
 use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
 use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
 use Symfony\Component\Filesystem\Filesystem;
-use Symfony\Component\DependencyInjection\ContainerAwareTrait;
-use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\Form\FormError;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Form\FormError;
 use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\RequestStack;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
 use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
-use Symfony\Component\Mailer\MailerInterface;
 use Symfony\Component\Mime\Address;
 use Symfony\Component\Mime\Address;
-use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Routing\RouterInterface;
-use Symfony\Component\Translation\TranslatorInterface;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
 
 
-use Rapsys\AirBundle\Entity\Application;
+use Rapsys\AirBundle\Entity\Dance;
 use Rapsys\AirBundle\Entity\Location;
 use Rapsys\AirBundle\Entity\Session;
 use Rapsys\AirBundle\Entity\Location;
 use Rapsys\AirBundle\Entity\Session;
-use Rapsys\AirBundle\Entity\Slot;
+use Rapsys\AirBundle\Entity\Snippet;
 use Rapsys\AirBundle\Entity\User;
 use Rapsys\AirBundle\Entity\User;
-use Rapsys\AirBundle\Pdf\DisputePdf;
-use Rapsys\UserBundle\Utils\Slugger;
-
-
-class DefaultController {
-       use ControllerTrait {
-               //Rename render as _render
-               render as protected _render;
-       }
-
-       ///Config array
-       protected $config;
-
-       ///Context array
-       protected $context;
-
-       ///Router instance
-       protected $router;
-
-       ///Translator instance
-       protected $translator;
-
-       ///Packages instance
-       protected $asset;
-
-       ///RequestStack instance
-       protected $stack;
-
-       ///Request instance
-       protected $request;
-
-       ///Locale instance
-       protected $locale;
-
-       /**
-        * @var ContainerInterface
-        */
-       protected $container;
-
-       ///Facebook image array
-       protected $facebookImage = [];
-
-       /**
-        * Inject container and translator interface
-        *
-        * @param ContainerInterface $container The container instance
-        * @param RouterInterface $router The router instance
-        * @param RequestStack $stack The request stack
-        * @param TranslatorInterface $translator The translator instance
-        */
-       public function __construct(ContainerInterface $container, RouterInterface $router, RequestStack $stack, TranslatorInterface $translator, Packages $asset) {
-               //Retrieve config
-               $this->config = $container->getParameter($this->getAlias());
-
-               //Set the container
-               $this->container = $container;
-
-               //Set the router
-               $this->router = $router;
-
-               //Set the translator
-               $this->translator = $translator;
-
-               //Set the asset
-               $this->asset = $asset;
-
-               //Set the request stack
-               $this->stack = $stack;
-
-               //Set the context
-               $this->context = [
-                       'contact' => [
-                               'title' => $translator->trans($this->config['contact']['title']),
-                               'mail' => $this->config['contact']['mail']
-                       ],
-                       'copy' => [
-                               'by' => $translator->trans($this->config['copy']['by']),
-                               'link' => $this->config['copy']['link'],
-                               'long' => $translator->trans($this->config['copy']['long']),
-                               'short' => $translator->trans($this->config['copy']['short']),
-                               'title' => $this->config['copy']['title']
-                       ],
-                       'page' => [
-                               'description' => null,
-                               'section' => null,
-                               'title' => null
-                       ],
-                       'site' => [
-                               'donate' => $this->config['site']['donate'],
-                               'ico' => $this->config['site']['ico'],
-                               'logo' => $this->config['site']['logo'],
-                               'png' => $this->config['site']['png'],
-                               'svg' => $this->config['site']['svg'],
-                               'title' => $translator->trans($this->config['site']['title']),
-                               'url' => $router->generate($this->config['site']['url'])
-                       ],
-                       'canonical' => null,
-                       'alternates' => [],
-                       'ogps' => [
-                               'type' => 'article',
-                               'site_name' => $this->translator->trans($this->config['site']['title'])
-                       ],
-                       'facebooks' => [
-                               #'admins' => $this->config['facebook']['admins'],
-                               'app_id' => $this->config['facebook']['apps']
-                       ],
-                       'forms' => []
-               ];
-
-               //Get current request
-               $this->request = $stack->getCurrentRequest();
-
-               //Get current locale
-               #$this->locale = $router->getContext()->getParameters()['_locale'];
-               $this->locale = $this->request->getLocale();
-
-               //Set translator locale
-               //XXX: allow LocaleSubscriber on the fly locale change for first page
-               $this->translator->setLocale($this->locale);
-
-               //Iterate on locales excluding current one
-               foreach($this->config['locales'] as $locale) {
-                       //Set titles
-                       $titles = [];
-
-                       //Iterate on other locales
-                       foreach(array_diff($this->config['locales'], [$locale]) as $other) {
-                               $titles[$other] = $translator->trans($this->config['languages'][$locale], [], null, $other);
-                       }
-
-                       //Get context path
-                       $path = $router->getContext()->getPathInfo();
-
-                       //Retrieve route matching path
-                       $route = $router->match($path);
-
-                       //Get route name
-                       $name = $route['_route'];
-
-                       //Unset route name
-                       unset($route['_route']);
-
-                       //With current locale
-                       if ($locale == $this->locale) {
-                               //Set locale locales context
-                               $this->context['canonical'] = $router->generate($name, ['_locale' => $locale]+$route, UrlGeneratorInterface::ABSOLUTE_URL);
-                       } else {
-                               //Set locale locales context
-                               $this->context['alternates'][$locale] = [
-                                       'absolute' => $router->generate($name, ['_locale' => $locale]+$route, UrlGeneratorInterface::ABSOLUTE_URL),
-                                       'relative' => $router->generate($name, ['_locale' => $locale]+$route),
-                                       'title' => implode('/', $titles),
-                                       'translated' => $translator->trans($this->config['languages'][$locale], [], null, $locale)
-                               ];
-                       }
-
-                       //Add shorter locale
-                       if (empty($this->context['alternates'][$shortLocale = substr($locale, 0, 2)])) {
-                               //Set locale locales context
-                               $this->context['alternates'][$shortLocale] = [
-                                       'absolute' => $router->generate($name, ['_locale' => $locale]+$route, UrlGeneratorInterface::ABSOLUTE_URL),
-                                       'relative' => $router->generate($name, ['_locale' => $locale]+$route),
-                                       'title' => implode('/', $titles),
-                                       'translated' => $translator->trans($this->config['languages'][$locale], [], null, $locale)
-                               ];
-                       }
-               }
-       }
+use Rapsys\AirBundle\Token\AnonymousToken;
 
 
+/**
+ * {@inheritdoc}
+ */
+class DefaultController extends AbstractController {
        /**
         * The about page
         *
         * @desc Display the about informations
         *
        /**
         * The about page
         *
         * @desc Display the about informations
         *
+        * @param Request $request The request instance
         * @return Response The rendered view
         */
         * @return Response The rendered view
         */
-       public function about(): Response {
+       public function about(Request $request): Response {
                //Set page
                //Set page
-               $this->context['page']['title'] = $this->translator->trans('About');
+               $this->context['title']['page'] = $this->translator->trans('About');
 
                //Set description
 
                //Set description
-               $this->context['page']['description'] = $this->translator->trans('Libre Air about');
+               $this->context['description'] = $this->translator->trans('Libre Air about');
 
                //Set keywords
                $this->context['keywords'] = [
 
                //Set keywords
                $this->context['keywords'] = [
@@ -219,7 +58,7 @@ class DefaultController {
                $response = $this->render('@RapsysAir/default/about.html.twig', $this->context);
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
                $response = $this->render('@RapsysAir/default/about.html.twig', $this->context);
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
-               $response->isNotModified($this->request);
+               $response->isNotModified($request);
 
                //Return response
                return $response;
 
                //Return response
                return $response;
@@ -231,16 +70,15 @@ class DefaultController {
         * @desc Send a contact mail to configured contact
         *
         * @param Request $request The request instance
         * @desc Send a contact mail to configured contact
         *
         * @param Request $request The request instance
-        * @param MailerInterface $mailer The mailer instance
         *
         * @return Response The rendered view or redirection
         */
         *
         * @return Response The rendered view or redirection
         */
-       public function contact(Request $request, MailerInterface $mailer): Response {
+       public function contact(Request $request): Response {
                //Set page
                //Set page
-               $this->context['page']['title'] = $this->translator->trans('Contact');
+               $this->context['title']['page'] = $this->translator->trans('Contact');
 
                //Set description
 
                //Set description
-               $this->context['page']['description'] = $this->translator->trans('Contact Libre Air');
+               $this->context['description'] = $this->translator->trans('Contact Libre Air');
 
                //Set keywords
                $this->context['keywords'] = [
 
                //Set keywords
                $this->context['keywords'] = [
@@ -251,10 +89,22 @@ class DefaultController {
                        $this->translator->trans('calendar')
                ];
 
                        $this->translator->trans('calendar')
                ];
 
+               //Set data
+               $data = [];
+
+               //With user
+               if ($user = $this->security->getUser()) {
+                       //Set data
+                       $data = [
+                               'name' => $user->getRecipientName(),
+                               'mail' => $user->getMail()
+                       ];
+               }
+
                //Create the form according to the FormType created previously.
                //And give the proper parameters
                //Create the form according to the FormType created previously.
                //And give the proper parameters
-               $form = $this->createForm('Rapsys\AirBundle\Form\ContactType', null, [
-                       'action' => $this->generateUrl('rapsys_air_contact'),
+               $form = $this->factory->create('Rapsys\AirBundle\Form\ContactType', $data, [
+                       'action' => $this->generateUrl('rapsysair_contact'),
                        'method' => 'POST'
                ]);
 
                        'method' => 'POST'
                ]);
 
@@ -262,7 +112,7 @@ class DefaultController {
                        // Refill the fields in case the form is not valid.
                        $form->handleRequest($request);
 
                        // Refill the fields in case the form is not valid.
                        $form->handleRequest($request);
 
-                       if ($form->isValid()) {
+                       if ($form->isSubmitted() && $form->isValid()) {
                                //Get data
                                $data = $form->getData();
 
                                //Get data
                                $data = $form->getData();
 
@@ -271,7 +121,7 @@ class DefaultController {
                                        //Set sender
                                        ->from(new Address($data['mail'], $data['name']))
                                        //Set recipient
                                        //Set sender
                                        ->from(new Address($data['mail'], $data['name']))
                                        //Set recipient
-                                       ->to(new Address($this->context['contact']['mail'], $this->context['contact']['title']))
+                                       ->to(new Address($this->context['contact']['address'], $this->context['contact']['name']))
                                        //Set subject
                                        ->subject($data['subject'])
 
                                        //Set subject
                                        ->subject($data['subject'])
 
@@ -291,7 +141,7 @@ class DefaultController {
                                //XXX: mail delivery may silently fail
                                try {
                                        //Send message
                                //XXX: mail delivery may silently fail
                                try {
                                        //Send message
-                                       $mailer->send($message);
+                                       $this->mailer->send($message);
 
                                        //Redirect on the same route with sent=1 to cleanup form
                                        return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+$request->get('_route_params'));
 
                                        //Redirect on the same route with sent=1 to cleanup form
                                        return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+$request->get('_route_params'));
@@ -299,10 +149,10 @@ class DefaultController {
                                } catch(TransportExceptionInterface $e) {
                                        if ($message = $e->getMessage()) {
                                                //Add error message mail unreachable
                                } catch(TransportExceptionInterface $e) {
                                        if ($message = $e->getMessage()) {
                                                //Add error message mail unreachable
-                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%: %message%', ['%mail%' => $this->context['contact']['mail'], '%message%' => $this->translator->trans($message)])));
+                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%: %message%', ['%mail%' => $this->context['contact']['address'], '%message%' => $this->translator->trans($message)])));
                                        } else {
                                                //Add error message mail unreachable
                                        } else {
                                                //Add error message mail unreachable
-                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%', ['%mail%' => $this->context['contact']['mail']])));
+                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%', ['%mail%' => $this->context['contact']['address']])));
                                        }
                                }
                        }
                                        }
                                }
                        }
@@ -313,190 +163,120 @@ class DefaultController {
        }
 
        /**
        }
 
        /**
-        * The dispute page
+        * The index page
         *
         *
-        * @desc Generate a dispute document
+        * Display session calendar
         *
         * @param Request $request The request instance
         *
         * @param Request $request The request instance
-        * @param MailerInterface $mailer The mailer instance
-        *
-        * @return Response The rendered view or redirection
+        * @return Response The rendered view
         */
         */
-       public function dispute(Request $request, MailerInterface $mailer): Response {
-               //Prevent non-guest to access here
-               $this->denyAccessUnlessGranted('ROLE_USER', null, $this->translator->trans('Unable to access this page without role %role%!', ['%role%' => $this->translator->trans('User')]));
+       public function index(Request $request): Response {
+               //Add cities
+               $this->context['cities'] = $this->doctrine->getRepository(Location::class)->findCitiesAsArray($this->period);
 
 
-               //Set page
-               $this->context['page']['title'] = $this->translator->trans('Dispute');
+               //Add calendar
+               $this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'));
 
 
-               //Set description
-               $this->context['page']['description'] = $this->translator->trans('Libre Air dispute');
+               //Add dances
+               $this->context['dances'] = $this->doctrine->getRepository(Dance::class)->findNamesAsArray();
 
 
-               //Set keywords
-               $this->context['keywords'] = [
-                       $this->translator->trans('dispute'),
-                       $this->translator->trans('Libre Air'),
-                       $this->translator->trans('outdoor'),
-                       $this->translator->trans('Argentine Tango'),
-                       $this->translator->trans('calendar')
-               ];
+               //Set modified
+               $this->modified = max(array_map(function ($v) { return $v['modified']; }, array_merge($this->context['calendar'], $this->context['cities'], $this->context['dances'])));
 
 
-               //Create the form according to the FormType created previously.
-               //And give the proper parameters
-               $form = $this->createForm('Rapsys\AirBundle\Form\DisputeType', ['court' => 'Paris', 'abstract' => 'Pour constater cette prétendue infraction, les agents verbalisateurs ont pénétré dans un jardin privatif, sans visibilité depuis la voie publique, situé derrière un batiment privé, pour ce faire ils ont franchi au moins un grillage de chantier ou des potteaux métalliques séparant le terrain privé de la voie publique de l\'autre côté du batiment.'], [
-                       'action' => $this->generateUrl('rapsys_air_dispute'),
-                       'method' => 'POST'
-               ]);
+               //Create response
+               $response = new Response();
 
 
-               if ($request->isMethod('POST')) {
-                       // Refill the fields in case the form is not valid.
-                       $form->handleRequest($request);
+               //With logged user
+               if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
+                       //Set last modified
+                       $response->setLastModified(new \DateTime('-1 year'));
 
 
-                       if ($form->isValid()) {
-                               //Get data
-                               $data = $form->getData();
+                       //Set as private
+                       $response->setPrivate();
+               //Without logged user
+               } else {
+                       //Set etag
+                       //XXX: only for public to force revalidation by last modified
+                       $response->setEtag(md5(serialize(array_merge($this->context['calendar'], $this->context['cities'], $this->context['dances']))));
 
 
-                               //Gathering offense
-                               if (!empty($data['offense']) && $data['offense'] == 'gathering') {
-                                       //Add gathering
-                                       $output = DisputePdf::genGathering($data['court'], $data['notice'], $data['agent'], $data['service'], $data['abstract'], $this->translator->trans($this->getUser()->getCivility()->getTitle()), $this->getUser()->getForename(), $this->getUser()->getSurname());
-                               //Traffic offense
-                               } elseif (!empty($data['offense'] && $data['offense'] == 'traffic')) {
-                                       //Add traffic
-                                       $output = DisputePdf::genTraffic($data['court'], $data['notice'], $data['agent'], $data['service'], $data['abstract'], $this->translator->trans($this->getUser()->getCivility()->getTitle()), $this->getUser()->getForename(), $this->getUser()->getSurname());
-                               //Unsupported offense
-                               } else {
-                                       header('Content-Type: text/plain');
-                                       die('TODO');
-                                       exit;
-                               }
+                       //Set last modified
+                       $response->setLastModified($this->modified);
+
+                       //Set as public
+                       $response->setPublic();
 
 
-                               //Send common headers
-                               header('Content-Type: application/pdf');
-
-                               //Send remaining headers
-                               header('Cache-Control: private, max-age=0, must-revalidate');
-                               header('Pragma: public');
-
-                               //Send content-length
-                               header('Content-Length: '.strlen($output));
-
-                               //Display the pdf
-                               echo $output;
-
-                               //Die for now
-                               exit;
-
-#                              //Create message
-#                              $message = (new TemplatedEmail())
-#                                      //Set sender
-#                                      ->from(new Address($data['mail'], $data['name']))
-#                                      //Set recipient
-#                                      //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
-#                                      ->to(new Address($this->config['contact']['mail'], $this->config['contact']['title']))
-#                                      //Set subject
-#                                      ->subject($data['subject'])
-#
-#                                      //Set path to twig templates
-#                                      ->htmlTemplate('@RapsysAir/mail/contact.html.twig')
-#                                      ->textTemplate('@RapsysAir/mail/contact.text.twig')
-#
-#                                      //Set context
-#                                      ->context(
-#                                              [
-#                                                      'subject' => $data['subject'],
-#                                                      'message' => strip_tags($data['message']),
-#                                              ]+$this->context
-#                                      );
-#
-#                              //Try sending message
-#                              //XXX: mail delivery may silently fail
-#                              try {
-#                                      //Send message
-#                                      $mailer->send($message);
-#
-#                                      //Redirect on the same route with sent=1 to cleanup form
-#                                      return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+$request->get('_route_params'));
-#                              //Catch obvious transport exception
-#                              } catch(TransportExceptionInterface $e) {
-#                                      if ($message = $e->getMessage()) {
-#                                              //Add error message mail unreachable
-#                                              $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%: %message%', ['%mail%' => $this->config['contact']['mail'], '%message%' => $this->translator->trans($message)])));
-#                                      } else {
-#                                              //Add error message mail unreachable
-#                                              $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%', ['%mail%' => $this->config['contact']['mail']])));
-#                                      }
-#                              }
+                       //Without role and modification
+                       if ($response->isNotModified($request)) {
+                               //Return 304 response
+                               return $response;
                        }
                }
 
                        }
                }
 
-               //Render template
-               return $this->render('@RapsysAir/default/dispute.html.twig', ['form' => $form->createView(), 'sent' => $request->query->get('sent', 0)]+$this->context);
-       }
+               //With cities
+               if (!empty($this->context['cities'])) {
+                       //Set locations
+                       $locations = [];
+
+                       //Iterate on each cities
+                       foreach($this->context['cities'] as $city) {
+                               //Iterate on each locations
+                               foreach($city['locations'] as $location) {
+                                       //Add location
+                                       $locations[$location['id']] = $location;
+                               }
+                       }
 
 
-       /**
-        * The index page
-        *
-        * @desc Display all granted sessions with an application or login form
-        *
-        * @param Request $request The request instance
-        *
-        * @return Response The rendered view
-        */
-       public function index(Request $request): Response {
-               //Fetch doctrine
-               $doctrine = $this->getDoctrine();
+                       //Add multi
+                       $this->context['multimap'] = $this->map->getMultiMap($this->translator->trans('Libre Air cities sector map'), $this->modified->getTimestamp(), $locations);
 
 
-               //Set page
-               $this->context['page']['title'] = $this->translator->trans('Argentine Tango in Paris');
+                       //Set cities
+                       $cities = array_map(function ($v) { return $v['in']; }, $this->context['cities']);
 
 
-               //Set description
-               $this->context['page']['description'] = $this->translator->trans('Outdoor Argentine Tango session calendar in Paris');
+                       //Set dances
+                       $dances = array_map(function ($v) { return $v['name']; }, $this->context['dances']);
+               } else {
+                       //Set cities
+                       $cities = [];
 
 
-               //Set keywords
-               $this->context['keywords'] = [
-                       $this->translator->trans('Argentine Tango'),
-                       $this->translator->trans('Paris'),
-                       $this->translator->trans('outdoor'),
-                       $this->translator->trans('calendar'),
-                       $this->translator->trans('Libre Air')
-               ];
+                       //Set dances
+                       $dances = [];
+               }
 
 
-               //Set type
-               //XXX: only valid for home page
-               $this->context['ogps']['type'] = 'website';
-
-               //Compute period
-               $period = new \DatePeriod(
-                       //Start from first monday of week
-                       new \DateTime('Monday this week'),
-                       //Iterate on each day
-                       new \DateInterval('P1D'),
-                       //End with next sunday and 4 weeks
-                       new \DateTime(
-                               $this->isGranted('IS_AUTHENTICATED_REMEMBERED')?'Monday this week + 3 week':'Monday this week + 2 week'
+               //Set keywords
+               //TODO: use splice instead of that shit !!!
+               //TODO: handle smartly indoor and outdoor !!!
+               $this->context['keywords'] = array_values(
+                       array_merge(
+                               $dances,
+                               $cities,
+                               [
+                                       $this->translator->trans('indoor'),
+                                       $this->translator->trans('outdoor'),
+                                       $this->translator->trans('calendar'),
+                                       $this->translator->trans('Libre Air')
+                               ]
                        )
                );
 
                        )
                );
 
-               //Fetch calendar
-               $calendar = $doctrine->getRepository(Session::class)->fetchCalendarByDatePeriod($this->translator, $period, null, $request->get('session'), !$this->isGranted('IS_AUTHENTICATED_REMEMBERED'));
+               //Get textual cities
+               $cities = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($cities, 0, -1))], array_slice($cities, -1)), 'strlen'));
 
 
-               //Fetch locations
-               //XXX: we want to display all active locations anyway
-               $locations = $doctrine->getRepository(Location::class)->findTranslatedSortedByPeriod($this->translator, $period);
+               //Get textual dances
+               $dances = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($dances, 0, -1))], array_slice($dances, -1)), 'strlen'));
 
 
-               //Render the view
-               return $this->render('@RapsysAir/default/index.html.twig', ['calendar' => $calendar, 'locations' => $locations]+$this->context);
+               //Set title
+               $this->context['title']['page'] = $this->translator->trans('%dances% %cities%', ['%dances%' => $dances, '%cities%' => $cities]);
+
+               //Set description
+               //TODO: handle french translation when city start with a A, change à in en !
+               $this->context['description'] = $this->translator->trans('%dances% indoor and outdoor calendar %cities%', ['%dances%' => $dances, '%cities%' => $cities]);
 
 
-               //Set Cache-Control must-revalidate directive
-               //TODO: add a javascript forced refresh after 1h ? or header refresh ?
-               #$response->setPublic(true);
-               #$response->setMaxAge(300);
-               #$response->mustRevalidate();
-               ##$response->setCache(['public' => true, 'max_age' => 300]);
+               //Set facebook type
+               //XXX: only valid for home page
+               $this->context['facebook']['metas']['og:type'] = 'website';
 
 
-               //Return the response
-               #return $response;
+               //Render the view
+               return $this->render('@RapsysAir/default/index.html.twig', $this->context, $response);
        }
 
        /**
        }
 
        /**
@@ -504,14 +284,15 @@ class DefaultController {
         *
         * @desc Display the organizer regulation policy
         *
         *
         * @desc Display the organizer regulation policy
         *
+        * @param Request $request The request instance
         * @return Response The rendered view
         */
         * @return Response The rendered view
         */
-       public function organizerRegulation(): Response {
+       public function organizerRegulation(Request $request): Response {
                //Set page
                //Set page
-               $this->context['page']['title'] = $this->translator->trans('Organizer regulation');
+               $this->context['title']['page'] = $this->translator->trans('Organizer regulation');
 
                //Set description
 
                //Set description
-               $this->context['page']['description'] = $this->translator->trans('Libre Air organizer regulation');
+               $this->context['description'] = $this->translator->trans('Libre Air organizer regulation');
 
                //Set keywords
                $this->context['keywords'] = [
 
                //Set keywords
                $this->context['keywords'] = [
@@ -525,7 +306,7 @@ class DefaultController {
                //Set as cachable
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
                //Set as cachable
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
-               $response->isNotModified($this->request);
+               $response->isNotModified($request);
 
                //Return response
                return $response;
 
                //Return response
                return $response;
@@ -536,14 +317,15 @@ class DefaultController {
         *
         * @desc Display the terms of service policy
         *
         *
         * @desc Display the terms of service policy
         *
+        * @param Request $request The request instance
         * @return Response The rendered view
         */
         * @return Response The rendered view
         */
-       public function termsOfService(): Response {
+       public function termsOfService(Request $request): Response {
                //Set page
                //Set page
-               $this->context['page']['title'] = $this->translator->trans('Terms of service');
+               $this->context['title']['page'] = $this->translator->trans('Terms of service');
 
                //Set description
 
                //Set description
-               $this->context['page']['description'] = $this->translator->trans('Libre Air terms of service');
+               $this->context['description'] = $this->translator->trans('Libre Air terms of service');
 
                //Set keywords
                $this->context['keywords'] = [
 
                //Set keywords
                $this->context['keywords'] = [
@@ -557,7 +339,7 @@ class DefaultController {
                //Set as cachable
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
                //Set as cachable
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
-               $response->isNotModified($this->request);
+               $response->isNotModified($request);
 
                //Return response
                return $response;
 
                //Return response
                return $response;
@@ -568,14 +350,15 @@ class DefaultController {
         *
         * @desc Display the frequently asked questions
         *
         *
         * @desc Display the frequently asked questions
         *
+        * @param Request $request The request instance
         * @return Response The rendered view
         */
         * @return Response The rendered view
         */
-       public function frequentlyAskedQuestions(): Response {
+       public function frequentlyAskedQuestions(Request $request): Response {
                //Set page
                //Set page
-               $this->context['page']['title'] = $this->translator->trans('Frequently asked questions');
+               $this->context['title']['page'] = $this->translator->trans('Frequently asked questions');
 
                //Set description
 
                //Set description
-               $this->context['page']['description'] = $this->translator->trans('Libre Air frequently asked questions');
+               $this->context['description'] = $this->translator->trans('Libre Air frequently asked questions');
 
                //Set keywords
                $this->context['keywords'] = [
 
                //Set keywords
                $this->context['keywords'] = [
@@ -590,441 +373,401 @@ class DefaultController {
                //Set as cachable
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
                //Set as cachable
                $response->setEtag(md5($response->getContent()));
                $response->setPublic();
-               $response->isNotModified($this->request);
+               $response->isNotModified($request);
 
                //Return response
                return $response;
        }
 
        /**
 
                //Return response
                return $response;
        }
 
        /**
-        * Return the bundle alias
+        * List all users
         *
         *
-        * {@inheritdoc}
-        */
-       public function getAlias(): string {
-               return 'rapsys_air';
-       }
-
-       /**
-        * Return the facebook image
+        * @desc Display all user with a group listed as users
         *
         *
-        * @desc Generate image in jpeg format or load it from cache
+        * @param Request $request The request instance
         *
         *
-        * @return array The image array
+        * @return Response The rendered view
         */
         */
-       protected function getFacebookImage(): array {
-               //Set texts
-               $texts = $this->facebookImage['texts'] ?? [];
-
-               //Set default source
-               $source = $this->facebookImage['source'] ?? 'png/facebook.png';
-
-               //Set default source
-               $updated = $this->facebookImage['updated'] ?? strtotime('last week');
-
-               //Set default destination
-               //XXX: format facebook<pathinfo>.jpeg
-               //XXX: was facebook/<controller>/<action>.<locale>.jpeg
-               $destination = $this->facebookImage['destination'] ?? 'facebook'.$this->request->getPathInfo().'.jpeg';
-
-               //Set source path
-               $src = $this->config['path']['public'].'/'.$source;
-
-               //Set cache path
-               //XXX: remove extension and store as png anyway
-               $cache = $this->config['path']['cache'].'/facebook/'.substr($source, 0, strrpos($source, '.')).'.'.$this->config['facebook']['width'].'x'.$this->config['facebook']['height'].'.png';
-
-               //Set destination path
-               $dest = $this->config['path']['public'].'/'.$destination;
-
-               //Set asset
-               $asset = '@RapsysAir/'.$destination;
-
-               //With up to date generated image
-               if (
-                       is_file($dest) &&
-                       ($stat = stat($dest)) &&
-                       $stat['mtime'] >= $updated
-               ) {
-                       //Get image size
-                       list ($width, $height) = getimagesize($dest);
-
-                       //With canonical in texts
-                       if (!empty($texts[$this->context['canonical']])) {
-                               //Prevent canonical to finish in alt
-                               unset($texts[$this->context['canonical']]);
-                       }
+       public function userIndex(Request $request): Response {
+               //With admin role
+               if ($this->checker->isGranted('ROLE_ADMIN')) {
+                       //Set title
+                       $this->context['title']['page'] = $this->translator->trans('Libre Air user list');
+
+                       //Set section
+                       $this->context['title']['section'] = $this->translator->trans('User');
+
+                       //Set description
+                       $this->context['description'] = $this->translator->trans('Lists Libre air users');
+               //Without admin role
+               } else {
+                       //Set title
+                       $this->context['title']['page'] = $this->translator->trans('Libre Air organizer list');
+
+                       //Set section
+                       $this->context['title']['section'] = $this->translator->trans('Organizer');
+
+                       //Set description
+                       $this->context['description'] = $this->translator->trans('Lists Libre air organizers');
+               }
 
 
-                       //Return image data
-                       return [
-                               #'image' => $this->stack->getCurrentRequest()->getUriForPath($this->asset->getUrl($asset), true),#.'?fbrefresh='.$stat['mtime'],
-                               'image:url' => $this->stack->getCurrentRequest()->getUriForPath($this->asset->getUrl($asset), true),#.'?fbrefresh='.$stat['mtime'],
-                               #'image:secure_url' => $this->stack->getCurrentRequest()->getUriForPath($this->asset->getUrl($asset), true),#.'?fbrefresh='.$stat['mtime'],
-                               'image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
-                               'image:height' => $height,
-                               'image:width' => $width
-                       ];
-               //With image candidate
-               } elseif (is_file($src)) {
-                       //Create image object
-                       $image = new \Imagick();
-
-                       //With cache image
-                       if (is_file($cache)) {
-                               //Read image
-                               $image->readImage($cache);
-                       //Without we generate it
-                       } else {
-                               //Check target directory
-                               if (!is_dir($dir = dirname($cache))) {
-                                       //Create filesystem object
-                                       $filesystem = new Filesystem();
-
-                                       try {
-                                               //Create dir
-                                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
-                                               $filesystem->mkdir($dir, 0775);
-                                       } catch (IOExceptionInterface $e) {
-                                               //Throw error
-                                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
-                                       }
-                               }
+               //Set keywords
+               $this->context['keywords'] = [
+                       $this->translator->trans('users'),
+                       $this->translator->trans('user list'),
+                       $this->translator->trans('listing'),
+                       $this->translator->trans('Libre Air')
+               ];
 
 
-                               //Read image
-                               $image->readImage($src);
+               //Fetch users
+               $users = $this->doctrine->getRepository(User::class)->findIndexByGroupId();
+
+               //With admin role
+               if ($this->checker->isGranted('ROLE_ADMIN')) {
+                       //Display all users
+                       $this->context['groups'] = $users;
+               //Without admin role
+               } else {
+                       //Only display senior organizers
+                       $this->context['users'] = $users[$this->translator->trans('Senior')];
+               }
 
 
-                               //Crop using aspect ratio
-                               //XXX: for better result upload image directly in aspect ratio :)
-                               $image->cropThumbnailImage($this->config['facebook']['width'], $this->config['facebook']['height']);
+               //Render the view
+               return $this->render('@RapsysAir/user/index.html.twig', $this->context);
+       }
 
 
-                               //Strip image exif data and properties
-                               $image->stripImage();
+       /**
+        * List all sessions for the user
+        *
+        * @desc Display all sessions for the user with an application or login form
+        *
+        * @param Request $request The request instance
+        * @param int $id The user id
+        *
+        * @return Response The rendered view
+        */
+       public function userView(Request $request, int $id, ?string $user): Response {
+               //Get user
+               if (empty($this->context['user'] = $this->doctrine->getRepository(User::class)->findOneByIdAsArray($id, $this->locale))) {
+                       //Throw not found
+                       throw new NotFoundHttpException($this->translator->trans('Unable to find user: %id%', ['%id%' => $id]));
+               }
 
 
-                               //Save cache image
-                               if (!$image->writeImage($cache)) {
-                                       //Throw error
-                                       throw new \Exception(sprintf('Unable to write image "%s"', $cache));
-                               }
-                       }
-                       //Check target directory
-                       if (!is_dir($dir = dirname($dest))) {
-                               //Create filesystem object
-                               $filesystem = new Filesystem();
+               //Create token
+               $token = new AnonymousToken($this->context['user']['roles']);
 
 
-                               try {
-                                       //Create dir
-                                       //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
-                                       $filesystem->mkdir($dir, 0775);
-                               } catch (IOExceptionInterface $e) {
-                                       //Throw error
-                                       throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
-                               }
-                       }
+               //Prevent access when not admin, user is not guest and not currently logged user
+               if (!($isAdmin = $this->checker->isGranted('ROLE_ADMIN')) && !($isGuest = $this->decision->decide($token, ['ROLE_GUEST']))) {
+                       //Throw access denied
+                       throw new AccessDeniedException($this->translator->trans('Unable to access user: %id%', ['%id%' => $id]));
+               }
 
 
-                       //Get image width
-                       $width = $image->getImageWidth();
+               //With invalid user slug
+               if ($this->context['user']['slug'] !== $user) {
+                       //Redirect to cleaned url
+                       return $this->redirectToRoute('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+               }
 
 
-                       //Get image height
-                       $height = $image->getImageHeight();
+               //Fetch calendar
+               $this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'), null, null, $id);
 
 
-                       //Create draw
-                       $draw = new \ImagickDraw();
+               //Get locations at less than 2 km
+               $this->context['locations'] = $this->doctrine->getRepository(Location::class)->findAllByUserIdAsArray($id, $this->period, 2);
 
 
-                       //Set stroke antialias
-                       $draw->setStrokeAntialias(true);
+               //Set ats
+               $ats = [];
 
 
-                       //Set text antialias
-                       $draw->setTextAntialias(true);
+               //Set dances
+               $dances = [];
 
 
-                       //Set stroke width
-                       $draw->setStrokeWidth($this->facebookImage['stroke']??15);
+               //Set indoors
+               $indoors = [];
 
 
-                       //Set font aliases
-                       $fonts = [
-                               'irishgrover' => $this->config['path']['public'].'/ttf/irishgrover.v10.ttf',
-                               'droidsans' => $this->config['path']['public'].'/ttf/droidsans.regular.ttf',
-                               'dejavusans' => $this->config['path']['public'].'/ttf/dejavusans.2.37.ttf',
-                               'labelleaurore' => $this->config['path']['public'].'/ttf/labelleaurore.v10.ttf'
-                       ];
+               //Set ins
+               $ins = [];
 
 
-                       //Set align aliases
-                       $aligns = [
-                               'left' => \Imagick::ALIGN_LEFT,
-                               'center' => \Imagick::ALIGN_CENTER,
-                               'right' => \Imagick::ALIGN_RIGHT
-                       ];
+               //Set insides
+               $insides = [];
 
 
-                       //Set default font
-                       $defaultFont = 'dejavusans';
+               //Set locations
+               $locations = [];
 
 
-                       //Set default align
-                       $defaultAlign = 'center';
+               //Set types
+               $types = [];
 
 
-                       //Set default size
-                       $defaultSize = 60;
+               //Iterate on each calendar
+               foreach($this->context['calendar'] as $date => $calendar) {
+                       //Iterate on each session
+                       foreach($calendar['sessions'] as $sessionId => $session) {
+                               //Add dance
+                               $dances[$session['application']['dance']['name']] = $session['application']['dance']['name'];
 
 
-                       //Set default stroke
-                       $defaultStroke = '#00c3f9';
+                               //Add types
+                               $types[$session['application']['dance']['type']] = lcfirst($session['application']['dance']['type']);
 
 
-                       //Set default fill
-                       $defaultFill = 'white';
+                               //Add indoors
+                               $indoors[$session['location']['indoor']?'indoor':'outdoor'] = $this->translator->trans($session['location']['indoor']?'indoor':'outdoor');
 
 
-                       //Init counter
-                       $i = 1;
+                               //Add insides
+                               $insides[$session['location']['indoor']?'inside':'outside'] = $this->translator->trans($session['location']['indoor']?'inside':'outside');
 
 
-                       //Set text count
-                       $count = count($texts);
+                               //Add ats
+                               $ats[$session['location']['id']] = $session['location']['at'];
 
 
-                       //Draw each text stroke
-                       foreach($texts as $text => $data) {
-                               //Set font
-                               $draw->setFont($fonts[$data['font']??$defaultFont]);
+                               //Add ins
+                               $ins[$session['location']['id']] = $session['location']['in'];
 
 
-                               //Set font size
-                               $draw->setFontSize($data['size']??$defaultSize);
+                               //Session with application user id
+                               if (!empty($session['application']['user']['id']) && $session['application']['user']['id'] == $id) {
+                                       //Add location
+                                       $locations[$session['location']['id']] = $session['location'];
+                               }
+                       }
+               }
 
 
-                               //Set text alignment
-                               $draw->setTextAlignment($align = ($aligns[$data['align']??$defaultAlign]));
+               //Set modified
+               //XXX: dance modified is already computed inside calendar modified
+               $this->modified = max(array_merge([$this->context['user']['modified']], array_map(function ($v) { return $v['modified']; }, array_merge($this->context['calendar'], $this->context['locations']))));
 
 
-                               //Get font metrics
-                               $metrics = $image->queryFontMetrics($draw, $text);
+               //Create response
+               $response = new Response();
 
 
-                               //Without y
-                               if (empty($data['y'])) {
-                                       //Position verticaly each text evenly
-                                       $texts[$text]['y'] = $data['y'] = (($height + 100) / (count($texts) + 1) * $i) - 50;
-                               }
+               //With logged user
+               if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
+                       //Set last modified
+                       $response->setLastModified(new \DateTime('-1 year'));
 
 
-                               //Without x
-                               if (empty($data['x'])) {
-                                       if ($align == \Imagick::ALIGN_CENTER) {
-                                               $texts[$text]['x'] = $data['x'] = $width/2;
-                                       } elseif ($align == \Imagick::ALIGN_LEFT) {
-                                               $texts[$text]['x'] = $data['x'] = 50;
-                                       } elseif ($align == \Imagick::ALIGN_RIGHT) {
-                                               $texts[$text]['x'] = $data['x'] = $width - 50;
-                                       }
-                               }
+                       //Set as private
+                       $response->setPrivate();
+               //Without logged user
+               } else {
+                       //Set etag
+                       //XXX: only for public to force revalidation by last modified
+                       $response->setEtag(md5(serialize(array_merge($this->context['user'], $this->context['calendar'], $this->context['locations']))));
 
 
-                               //Center verticaly
-                               //XXX: add ascender part then center it back by half of textHeight
-                               //TODO: maybe add a boundingbox ???
-                               $texts[$text]['y'] = $data['y'] += $metrics['ascender'] - $metrics['textHeight']/2;
+                       //Set last modified
+                       $response->setLastModified($this->modified);
 
 
-                               //Set stroke color
-                               $draw->setStrokeColor(new \ImagickPixel($data['stroke']??$defaultStroke));
+                       //Set as public
+                       $response->setPublic();
 
 
-                               //Set fill color
-                               $draw->setFillColor(new \ImagickPixel($data['stroke']??$defaultStroke));
+                       //Without role and modification
+                       if ($response->isNotModified($request)) {
+                               //Return 304 response
+                               return $response;
+                       }
+               }
 
 
-                               //Add annotation
-                               $draw->annotation($data['x'], $data['y'], $text);
+               //Add multi map
+               $this->context['multimap'] = $this->map->getMultiMap($this->context['user']['multimap'], $this->modified->getTimestamp(), $this->context['locations']);
 
 
-                               //Increase counter
-                               $i++;
-                       }
+               //Set keywords
+               $this->context['keywords'] = [
+                       $this->context['user']['pseudonym'],
+                       $this->translator->trans('calendar'),
+                       $this->translator->trans('Libre Air')
+               ];
 
 
-                       //Create stroke object
-                       $stroke = new \Imagick();
+               //Set cities
+               $cities = array_unique(array_map(function ($v) { return $v['city']; }, $locations));
 
 
-                       //Add new image
-                       $stroke->newImage($width, $height, new \ImagickPixel('transparent'));
+               //Set titles
+               $titles = array_map(function ($v) { return $v['title']; }, $locations);
 
 
-                       //Draw on image
-                       $stroke->drawImage($draw);
+               //Insert dances in keywords
+               array_splice($this->context['keywords'], 1, 0, array_merge($types, $dances, $indoors, $insides, $titles, $cities));
 
 
-                       //Blur image
-                       //XXX: blur the stroke canvas only
-                       $stroke->blurImage(5,3);
+               //Deduplicate ins
+               $ins = array_unique($ins);
 
 
-                       //Set opacity to 0.5
-                       //XXX: see https://www.php.net/manual/en/image.evaluateimage.php
-                       $stroke->evaluateImage(\Imagick::EVALUATE_DIVIDE, 1.5, \Imagick::CHANNEL_ALPHA);
+               //Get textual dances
+               $dances = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($dances, 0, -1))], array_slice($dances, -1)), 'strlen'));
 
 
-                       //Compose image
-                       $image->compositeImage($stroke, \Imagick::COMPOSITE_OVER, 0, 0);
+               //Get textual types
+               $types = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($types, 0, -1))], array_slice($types, -1)), 'strlen'));
 
 
-                       //Clear stroke
-                       $stroke->clear();
+               //Get textual indoors
+               $indoors = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($indoors, 0, -1))], array_slice($indoors, -1)), 'strlen'));
 
 
-                       //Destroy stroke
-                       unset($stroke);
+               //Get textual ats
+               $ats = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($ats, 0, -1))], array_slice($ats, -1)), 'strlen'));
 
 
-                       //Clear draw
-                       $draw->clear();
+               //Get textual ins
+               $ins = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($ins, 0, -1))], array_slice($ins, -1)), 'strlen'));
 
 
-                       //Set text antialias
-                       $draw->setTextAntialias(true);
+               //Set title
+               $this->context['title']['page'] = $this->translator->trans('%pseudonym% organizer', ['%pseudonym%' => $this->context['user']['pseudonym']]);
 
 
-                       //Draw each text
-                       foreach($texts as $text => $data) {
-                               //Set font
-                               $draw->setFont($fonts[$data['font']??$defaultFont]);
+               //Set section
+               $this->context['title']['section'] = $this->translator->trans('User');
 
 
-                               //Set font size
-                               $draw->setFontSize($data['size']??$defaultSize);
+               //With locations
+               if (!empty($locations)) {
+                       //Set description
+                       $this->context['description'] = ucfirst($this->translator->trans('%dances% %types% %indoors% calendar %ats% %ins% %pseudonym%', ['%dances%' => $dances, '%types%' => $types, '%indoors%' => $indoors, '%ats%' => $ats, '%ins%' => $ins, '%pseudonym%' => $this->translator->trans('by %pseudonym%', ['%pseudonym%' => $this->context['user']['pseudonym']])]));
+               //Without locations
+               } else {
+                       //Set description
+                       $this->context['description'] = $this->translator->trans('%pseudonym% calendar', ['%pseudonym%' => $this->context['user']['pseudonym']]);
+               }
 
 
-                               //Set text alignment
-                               $draw->setTextAlignment($aligns[$data['align']??$defaultAlign]);
+               //Set user description
+               $this->context['locations_description'] = $this->translator->trans('Libre Air %pseudonym% location list', ['%pseudonym%' => $this->translator->trans('by %pseudonym%', ['%pseudonym%' => $this->context['user']['pseudonym']])]);
 
 
-                               //Set fill color
-                               $draw->setFillColor(new \ImagickPixel($data['fill']??$defaultFill));
+               //Set alternates
+               $this->context['alternates'] += $this->context['user']['alternates'];
 
 
-                               //Add annotation
-                               $draw->annotation($data['x'], $data['y'], $text);
-                       }
+               //Create snippet forms for role_guest
+               //TODO: optimize this call
+               if ($isAdmin || $isGuest && $this->security->getUser() && $this->context['user']['id'] == $this->security->getUser()->getId()) {
+                       //Fetch all user snippet
+                       $snippets = $this->doctrine->getRepository(Snippet::class)->findByUserIdLocaleIndexByLocationId($id, $this->locale);
 
 
-                       //Draw on image
-                       $image->drawImage($draw);
+                       //Get user
+                       $user = $this->doctrine->getRepository(User::class)->findOneById($id);
 
 
-                       //Strip image exif data and properties
-                       $image->stripImage();
+                       //Iterate on locations
+                       foreach($this->context['locations'] as $locationId => $location) {
+                               //With existing snippet
+                               if (isset($snippets[$location['id']])) {
+                                       //Set existing in current
+                                       $current = $snippets[$location['id']];
+                               //Without existing snippet
+                               } else {
+                                       //Init snippet
+                                       $current = new Snippet($this->locale, $this->doctrine->getRepository(Location::class)->findOneById($location['id']), $user);
+                               }
 
 
-                       //Set image format
-                       $image->setImageFormat('jpeg');
+                               //Create SnippetType form
+                               $form = $this->factory->createNamed(
+                                       //Set form id
+                                       'snippet_'.$locationId.'_'.$id.'_'.$this->locale,
+                                       //Set form type
+                                       'Rapsys\AirBundle\Form\SnippetType',
+                                       //Set form data
+                                       $current
+                               );
 
 
-                       //Save image
-                       if (!$image->writeImage($dest)) {
-                               //Throw error
-                               throw new \Exception(sprintf('Unable to write image "%s"', $dest));
-                       }
+                               //Refill the fields in case of invalid form
+                               $form->handleRequest($request);
 
 
-                       //Get dest stat
-                       //TODO: see if it works every time
-                       $stat = stat($dest);
+                               //Handle submitted and valid form
+                               //TODO: add a delete snippet ?
+                               if ($form->isSubmitted() && $form->isValid()) {
+                                       //Get snippet
+                                       $snippet = $form->getData();
 
 
-                       //With canonical in texts
-                       if (!empty($texts[$this->context['canonical']])) {
-                               //Prevent canonical to finish in alt
-                               unset($texts[$this->context['canonical']]);
-                       }
+                                       //Queue snippet save
+                                       $this->manager->persist($snippet);
 
 
-                       //Return image data
-                       return [
-                               //TODO: see if it works every time
-                               #'image' => $this->stack->getCurrentRequest()->getUriForPath($this->asset->getUrl($asset), true),#.'?fbrefresh='.$stat['mtime'],
-                               'image:url' => $this->stack->getCurrentRequest()->getUriForPath($this->asset->getUrl($asset), true),#.'?fbrefresh='.$stat['mtime'],
-                               #'image:secure_url' => $this->stack->getCurrentRequest()->getUriForPath($this->asset->getUrl($asset), true),#.'?fbrefresh='.$stat['mtime'],
-                               'image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
-                               'image:height' => $height,
-                               'image:width' => $width
-                       ];
-               }
+                                       //Flush to get the ids
+                                       $this->manager->flush();
 
 
-               //Return empty array without image
-               return [];
-       }
+                                       //Add notice
+                                       $this->addFlash('notice', $this->translator->trans('Snippet for %user% %location% updated', ['%location%' => $location['at'], '%user%' => $this->context['user']['pseudonym']]));
 
 
-       /**
-        * Renders a view
-        *
-        * {@inheritdoc}
-        */
-       protected function render(string $view, array $parameters = [], Response $response = null): Response {
-               //Create application form for role_guest
-               if ($this->isGranted('ROLE_GUEST')) {
-                       //Without application form
-                       if (empty($parameters['forms']['application'])) {
-                               //Fetch doctrine
-                               $doctrine = $this->getDoctrine();
-
-                               //Create ApplicationType form
-                               $application = $this->createForm('Rapsys\AirBundle\Form\ApplicationType', null, [
-                                       //Set the action
-                                       'action' => $this->generateUrl('rapsys_air_application_add'),
-                                       //Set the form attribute
-                                       'attr' => [ 'class' => 'col' ],
-                                       //Set admin
-                                       'admin' => $this->isGranted('ROLE_ADMIN'),
-                                       //Set default user to current
-                                       'user' => $this->getUser()->getId(),
-                                       //Set default slot to evening
-                                       //XXX: default to Evening (3)
-                                       'slot' => $doctrine->getRepository(Slot::class)->findOneById(3)
-                               ]);
+                                       //Redirect to cleaned url
+                                       return $this->redirectToRoute('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+                               }
 
                                //Add form to context
 
                                //Add form to context
-                               $parameters['forms']['application'] = $application->createView();
-                       }
-               //Create login form for anonymous
-               } elseif (!$this->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
-                       //Create ApplicationType form
-                       $login = $this->createForm('Rapsys\UserBundle\Form\LoginType', null, [
-                               //Set the action
-                               'action' => $this->generateUrl('rapsys_user_login'),
-                               //Set the form attribute
-                               'attr' => [ 'class' => 'col' ]
-                       ]);
-
-                       //Add form to context
-                       $parameters['forms']['login'] = $login->createView();
-               }
+                               $this->context['forms']['snippets'][$locationId] = $form->createView();
 
 
-               //With page infos and without facebook image
-               if (empty($this->facebookImage) && !empty($parameters['site']['title']) && !empty($parameters['page']['title']) && !empty($parameters['canonical'])) {
-                       //Set facebook image
-                       $this->facebookImage = [
-                               'texts' => [
-                                       $parameters['site']['title'] => [
-                                               'font' => 'irishgrover',
-                                               'size' => 110
-                                       ],
-                                       $parameters['page']['title'] => [
-                                               'align' => 'left'
+                               //With location user source image
+                               if (($isFile = is_file($source = $this->config['path'].'/location/'.$location['id'].'/'.$id.'.png')) && ($mtime = stat($source)['mtime'])) {
+                                       //Set location image
+                                       $this->context['locations'][$locationId]['image'] = $this->image->getThumb($location['miniature'], $mtime, $source);
+                               }
+
+                               //Create ImageType form
+                               $form = $this->factory->createNamed(
+                                       //Set form id
+                                       'image_'.$locationId.'_'.$id,
+                                       //Set form type
+                                       'Rapsys\AirBundle\Form\ImageType',
+                                       //Set form data
+                                       [
+                                               //Set location
+                                               'location' => $location['id'],
+                                               //Set user
+                                               'user' => $id
                                        ],
                                        ],
-                                       $parameters['canonical'] => [
-                                               'align' => 'right',
-                                               'font' => 'labelleaurore',
-                                               'size' => 50
+                                       //Set form attributes
+                                       [
+                                               //Enable delete with image
+                                               'delete' => isset($this->context['locations'][$locationId]['image'])
                                        ]
                                        ]
-                               ]
-                       ];
-               }
+                               );
 
 
-               //With canonical
-               if (!empty($parameters['canonical'])) {
-                       //Set facebook url
-                       $parameters['ogps']['url'] = $parameters['canonical'];
-               }
+                               //Refill the fields in case of invalid form
+                               $form->handleRequest($request);
 
 
-               //With page title
-               if (!empty($parameters['page']['title'])) {
-                       //Set facebook title
-                       $parameters['ogps']['title'] = $parameters['page']['title'];
-               }
+                               //Handle submitted and valid form
+                               if ($form->isSubmitted() && $form->isValid()) {
+                                       //With delete
+                                       if ($form->has('delete') && $form->get('delete')->isClicked()) {
+                                               //With source and mtime
+                                               if ($isFile && !empty($source) && !empty($mtime)) {
+                                                       //Clear thumb
+                                                       $this->image->remove($mtime, $source);
 
 
-               //With page description
-               if (!empty($parameters['page']['description'])) {
-                       //Set facebook description
-                       $parameters['ogps']['description'] = $parameters['page']['description'];
-               }
+                                                       //Unlink file
+                                                       unlink($this->config['path'].'/location/'.$location['id'].'/'.$id.'.png');
 
 
-               //With locale
-               if (!empty($this->locale)) {
-                       //Set facebook locale
-                       $parameters['ogps']['locale'] = str_replace('-', '_', $this->locale);
-
-                       //With alternates
-                       //XXX: disabled as we don't support fb_locale=xx_xx
-                       //XXX: see https://stackoverflow.com/questions/20827882/in-open-graph-markup-whats-the-use-of-oglocalealternate-without-the-locati
-                       #if (!empty($parameters['alternates'])) {
-                       #       //Iterate on alternates
-                       #       foreach($parameters['alternates'] as $lang => $alternate) {
-                       #               if (strlen($lang) == 5) {
-                       #                       //Set facebook locale alternate
-                       #                       $parameters['ogps']['locale:alternate'] = str_replace('-', '_', $lang);
-                       #               }
-                       #       }
-                       #}
-               }
+                                                       //Add notice
+                                                       $this->addFlash('notice', $this->translator->trans('Image for %user% %location% deleted', ['%location%' => $location['at'], '%user%' => $this->context['user']['pseudonym']]));
 
 
-               //With facebook image defined
-               if (!empty($this->facebookImage)) {
-                       //Get facebook image
-                       $parameters['ogps'] += $this->getFacebookImage();
+                                                       //Redirect to cleaned url
+                                                       return $this->redirectToRoute('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+                                               }
+                                       }
+
+                                       //With image
+                                       if ($image = $form->get('image')->getData()) {
+                                               //Check source path
+                                               if (!is_dir($dir = dirname($source))) {
+                                                       //Create filesystem object
+                                                       $filesystem = new Filesystem();
+
+                                                       try {
+                                                               //Create dir
+                                                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                                                               $filesystem->mkdir($dir, 0775);
+                                                       } catch (IOExceptionInterface $e) {
+                                                               //Throw error
+                                                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                                                       }
+                                               }
+
+                                               //Set source
+                                               $source = realpath($dir).'/'.basename($source);
+
+                                               //Create imagick object
+                                               $imagick = new \Imagick();
+
+                                               //Read image
+                                               $imagick->readImage($image->getRealPath());
+
+                                               //Save image
+                                               if (!$imagick->writeImage($source)) {
+                                                       //Throw error
+                                                       throw new \Exception(sprintf('Unable to write image "%s"', $source));
+                                               }
+
+                                               //Add notice
+                                               $this->addFlash('notice', $this->translator->trans('Image for %user% %location% updated', ['%location%' => $location['at'], '%user%' => $this->context['user']['pseudonym']]));
+
+                                               //Redirect to cleaned url
+                                               return $this->redirectToRoute('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+                                       }
+                               }
+
+                               //Add form to context
+                               $this->context['forms']['images'][$locationId] = $form->createView();
+                       }
                }
 
                }
 
-               //Call parent method
-               return $this->_render($view, $parameters, $response);
+               //Render the view
+               return $this->render('@RapsysAir/user/view.html.twig', ['id' => $id]+$this->context);
        }
 }
        }
 }