Use 'associations' to handle links and external files
authorThierry Florac <thierry.florac@onf.fr>
Mon, 11 Sep 2017 14:54:30 +0200
changeset 140 67bad9f880ee
parent 139 99a481dc4c89
child 141 643417150ee3
Use 'associations' to handle links and external files
src/pyams_content/component/association/__init__.py
src/pyams_content/component/association/container.py
src/pyams_content/component/association/interfaces/__init__.py
src/pyams_content/component/association/paragraph.py
src/pyams_content/component/association/zmi/__init__.py
src/pyams_content/component/association/zmi/interfaces.py
src/pyams_content/component/association/zmi/paragraph.py
src/pyams_content/component/association/zmi/templates/associations-view.pt
src/pyams_content/component/association/zmi/templates/associations.pt
src/pyams_content/component/association/zmi/templates/paragraph-summary.pt
src/pyams_content/component/extfile/__init__.py
src/pyams_content/component/extfile/container.py
src/pyams_content/component/extfile/interfaces/__init__.py
src/pyams_content/component/extfile/zmi/__init__.py
src/pyams_content/component/extfile/zmi/container.py
src/pyams_content/component/extfile/zmi/templates/container.pt
src/pyams_content/component/extfile/zmi/templates/widget-display.pt
src/pyams_content/component/extfile/zmi/templates/widget-input.pt
src/pyams_content/component/extfile/zmi/widget.py
src/pyams_content/component/gallery/__init__.py
src/pyams_content/component/gallery/container.py
src/pyams_content/component/gallery/file.py
src/pyams_content/component/gallery/interfaces/__init__.py
src/pyams_content/component/gallery/paragraph.py
src/pyams_content/component/gallery/zmi/__init__.py
src/pyams_content/component/gallery/zmi/container.py
src/pyams_content/component/gallery/zmi/file.py
src/pyams_content/component/gallery/zmi/gallery.py
src/pyams_content/component/gallery/zmi/interfaces.py
src/pyams_content/component/gallery/zmi/paragraph.py
src/pyams_content/component/gallery/zmi/templates/gallery-images.pt
src/pyams_content/component/gallery/zmi/templates/renderer-default.pt
src/pyams_content/component/gallery/zmi/templates/widget-display.pt
src/pyams_content/component/gallery/zmi/templates/widget-input.pt
src/pyams_content/component/gallery/zmi/widget.py
src/pyams_content/component/illustration/__init__.py
src/pyams_content/component/illustration/interfaces/__init__.py
src/pyams_content/component/illustration/paragraph.py
src/pyams_content/component/illustration/zmi/__init__.py
src/pyams_content/component/illustration/zmi/paragraph.py
src/pyams_content/component/illustration/zmi/templates/renderer-default.pt
src/pyams_content/component/illustration/zmi/templates/renderer-left.pt
src/pyams_content/component/illustration/zmi/templates/renderer-right.pt
src/pyams_content/component/links/__init__.py
src/pyams_content/component/links/container.py
src/pyams_content/component/links/interfaces/__init__.py
src/pyams_content/component/links/zmi/__init__.py
src/pyams_content/component/links/zmi/container.py
src/pyams_content/component/links/zmi/templates/container.pt
src/pyams_content/component/links/zmi/templates/widget-display.pt
src/pyams_content/component/links/zmi/templates/widget-input.pt
src/pyams_content/component/links/zmi/templates/widget-list-display.pt
src/pyams_content/component/links/zmi/templates/widget-list-input.pt
src/pyams_content/component/links/zmi/widget.py
src/pyams_content/component/paragraph/__init__.py
src/pyams_content/component/paragraph/container.py
src/pyams_content/component/paragraph/header.py
src/pyams_content/component/paragraph/html.py
src/pyams_content/component/paragraph/illustration.py
src/pyams_content/component/paragraph/interfaces/__init__.py
src/pyams_content/component/paragraph/interfaces/header.py
src/pyams_content/component/paragraph/interfaces/html.py
src/pyams_content/component/paragraph/zmi/__init__.py
src/pyams_content/component/paragraph/zmi/container.py
src/pyams_content/component/paragraph/zmi/header.py
src/pyams_content/component/paragraph/zmi/html.py
src/pyams_content/component/paragraph/zmi/illustration.py
src/pyams_content/component/paragraph/zmi/templates/associations.pt
src/pyams_content/component/paragraph/zmi/templates/header-summary.pt
src/pyams_content/component/paragraph/zmi/templates/html-summary.pt
src/pyams_content/component/paragraph/zmi/templates/illustration-left.pt
src/pyams_content/component/paragraph/zmi/templates/illustration-right.pt
src/pyams_content/component/paragraph/zmi/templates/illustration.pt
src/pyams_content/component/paragraph/zmi/templates/paragraph-title-icon.pt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,81 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.association.interfaces import IAssociationItem
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_form.interfaces.form import IFormContextPermissionChecker
+from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
+
+# import packages
+from persistent import Persistent
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.traversing import get_parent
+from pyams_utils.url import absolute_url
+from pyramid.events import subscriber
+from pyramid.threadlocal import get_current_registry
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.lifecycleevent import ObjectModifiedEvent
+from zope.schema.fieldproperty import FieldProperty
+
+
+@implementer(IAssociationItem)
+class AssociationItem(Persistent, Contained):
+    """Base association item persistent class"""
+
+    icon_class = ''
+    icon_hint = ''
+
+    visible = FieldProperty(IAssociationItem['visible'])
+
+    def get_url(self, request=None, view_name=None):
+        return absolute_url(self, request, view_name)
+
+
+@adapter_config(context=IAssociationItem, provides=IFormContextPermissionChecker)
+class AssociationItemPermissionChecker(ContextAdapter):
+    """Association item permission checker"""
+
+    @property
+    def edit_permission(self):
+        content = get_parent(self.context, IWfSharedContent)
+        return IFormContextPermissionChecker(content).edit_permission
+
+
+@subscriber(IObjectAddedEvent, context_selector=IAssociationItem)
+def handle_added_association(event):
+    """Handle added association item"""
+    content = get_parent(event.object, IWfSharedContent)
+    if content is not None:
+        get_current_registry().notify(ObjectModifiedEvent(content))
+
+
+@subscriber(IObjectModifiedEvent, context_selector=IAssociationItem)
+def handle_modified_association(event):
+    """Handle modified association item"""
+    content = get_parent(event.object, IWfSharedContent)
+    if content is not None:
+        get_current_registry().notify(ObjectModifiedEvent(content))
+
+
+@subscriber(IObjectRemovedEvent, context_selector=IAssociationItem)
+def handle_removed_association(event):
+    """Handle removed association item"""
+    content = get_parent(event.object, IWfSharedContent)
+    if content is not None:
+        get_current_registry().notify(ObjectModifiedEvent(content))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/container.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,95 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.association.interfaces import IAssociationContainer, IAssociationTarget, \
+    ASSOCIATION_CONTAINER_KEY, IAssociationInfo
+from pyams_i18n.interfaces import II18n
+from zope.annotation.interfaces import IAnnotations
+from zope.location.interfaces import ISublocations
+from zope.traversing.interfaces import ITraversable
+
+# import packages
+from pyams_catalog.utils import index_object
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.traversing import get_parent
+from pyams_utils.vocabulary import vocabulary_config
+from pyramid.threadlocal import get_current_registry
+from zope.container.ordered import OrderedContainer
+from zope.interface import implementer
+from zope.lifecycleevent import ObjectCreatedEvent
+from zope.location import locate
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+
+@implementer(IAssociationContainer)
+class AssociationContainer(OrderedContainer):
+    """Associations container"""
+
+    last_id = 1
+
+    def append(self, value, notify=True):
+        key = str(self.last_id)
+        if not notify:
+            # pre-locate association item to avoid multiple notifications
+            locate(value, self, key)
+        self[key] = value
+        self.last_id += 1
+        if not notify:
+            # make sure that association item is correctly indexed
+            index_object(value)
+
+
+@adapter_config(context=IAssociationTarget, provides=IAssociationContainer)
+def association_container_factory(target):
+    """Associations container factory"""
+    annotations = IAnnotations(target)
+    container = annotations.get(ASSOCIATION_CONTAINER_KEY)
+    if container is None:
+        container = annotations[ASSOCIATION_CONTAINER_KEY] = AssociationContainer()
+        get_current_registry().notify(ObjectCreatedEvent(container))
+        locate(container, target, '++ass++')
+    return container
+
+
+@adapter_config(name='ass', context=IAssociationTarget, provides=ITraversable)
+class AssociationContainerNamespace(ContextAdapter):
+    """Associations container ++association++ namespace"""
+
+    def traverse(self, name, furtherpath=None):
+        return IAssociationContainer(self.context)
+
+
+@adapter_config(name='associations', context=IAssociationTarget, provides=ISublocations)
+class AssociationContainerSublocations(ContextAdapter):
+    """Associations container sub-locations adapter"""
+
+    def sublocations(self):
+        return IAssociationContainer(self.context).values()
+
+
+@vocabulary_config(name='PyAMS content associations')
+class ContentAssociationsVocabulary(SimpleVocabulary):
+    """Content associations vocabulary"""
+
+    def __init__(self, context=None):
+        terms = []
+        target = get_parent(context, IAssociationTarget)
+        if target is not None:
+            terms = [SimpleTerm(link.__name__, title=IAssociationInfo(link).inner_title)
+                     for link in IAssociationContainer(target).values()]
+        super(ContentAssociationsVocabulary, self).__init__(terms)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/interfaces/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,81 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IBaseParagraph
+from zope.annotation.interfaces import IAttributeAnnotatable
+from zope.container.interfaces import IOrderedContainer
+
+# import packages
+from zope.container.constraints import containers, contains
+from zope.interface import Interface, Attribute
+from zope.schema import Bool
+
+from pyams_content import _
+
+
+ASSOCIATION_CONTAINER_KEY = 'pyams_content.associations'
+
+
+class IAssociationItem(IAttributeAnnotatable):
+    """Base association item interface"""
+
+    containers('.IAssociationContainer')
+
+    icon_class = Attribute("Icon class in associations list")
+    icon_hint = Attribute("Icon hint in associations list")
+
+    visible = Bool(title=_("Visible?"),
+                   description=_("Is this item visible in front-office?"),
+                   required=True,
+                   default=True)
+
+    def get_url(self, request=None, view_name=None):
+        """Get link URL"""
+
+
+class IAssociationInfo(Interface):
+    """Association information interface"""
+
+    pictogram = Attribute("Association pictogram")
+
+    user_title = Attribute("Association title proposed on public site")
+
+    inner_title = Attribute("Inner content, if available")
+
+    human_size = Attribute("Content size, if available")
+
+
+class IAssociationContainer(IOrderedContainer):
+    """Associations container interface"""
+
+    contains(IAssociationItem)
+
+    def append(self, value, notify=True):
+        """Append given value to container"""
+
+
+class IAssociationTarget(IAttributeAnnotatable):
+    """Associations container target interface"""
+
+
+class IAssociationRenderer(Interface):
+    """Association renderer adapter interface"""
+
+
+class IAssociationParagraph(IBaseParagraph):
+    """Associations paragraph interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/paragraph.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,45 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.association.interfaces import IAssociationParagraph
+from pyams_content.component.extfile.interfaces import IExtFileContainerTarget
+from pyams_content.component.links.interfaces import ILinkContainerTarget
+from pyams_content.component.paragraph.interfaces import IParagraphFactory
+
+# import packages
+from pyams_content.component.paragraph import BaseParagraph
+from pyams_utils.registry import utility_config
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+@implementer(IAssociationParagraph, IExtFileContainerTarget, ILinkContainerTarget)
+class AssociationParagraph(BaseParagraph):
+    """Associations paragraph"""
+
+    icon_class = 'fa-link'
+    icon_hint = _("Associations paragraph")
+
+
+@utility_config(name='Associations paragraph', provides=IParagraphFactory)
+class AssociationParagraphFactory(object):
+    """Associations paragraph factory"""
+
+    name = _("Associations paragraph")
+    content_type = AssociationParagraph
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/zmi/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,299 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import json
+
+# import interfaces
+from pyams_content.component.association.interfaces import IAssociationTarget, IAssociationContainer, IAssociationInfo
+from pyams_content.component.association.zmi.interfaces import IAssociationsParentForm, IAssociationsView
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_form.interfaces.form import IInnerSubForm
+from pyams_skin.interfaces import IInnerPage
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
+from pyams_zmi.interfaces.menu import IPropertiesMenu
+from z3c.table.interfaces import IValues, IColumn
+
+# import packages
+from pyams_form.form import AJAXAddForm, AJAXEditForm
+from pyams_form.security import ProtectedFormObjectMixin
+from pyams_pagelet.pagelet import pagelet_config, Pagelet
+from pyams_skin.table import BaseTable, SorterColumn, JsActionColumn, NameColumn, ImageColumn, I18nColumn, TrashColumn
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+from pyams_utils.traversing import get_parent
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import InnerAdminDisplayForm
+from pyams_zmi.view import AdminView
+from pyramid.decorator import reify
+from pyramid.exceptions import NotFound
+from pyramid.view import view_config
+from z3c.form import field
+from z3c.table.column import GetAttrColumn
+from zope.interface import implementer, Interface
+
+from pyams_content import _
+
+
+#
+# Association item base forms
+#
+
+class AssociationItemAJAXAddForm(AJAXAddForm):
+    """Association item add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        associations_table = AssociationsTable(self.context, self.request, None)
+        associations_table.update()
+        return {'status': 'success',
+                'message': self.request.localizer.translate(_("Association was correctly added.")),
+                'callback': 'PyAMS_content.associations.afterUpdateCallback',
+                'options': {'parent': associations_table.id,
+                            'table': associations_table.render()}}
+
+
+class AssociationItemAJAXEditForm(AJAXEditForm):
+    """Association item properties edit form, JSON renderer"""
+
+    def get_associations_table(self):
+        target = get_parent(self.context, IAssociationTarget)
+        associations_table = AssociationsTable(target, self.request, None)
+        associations_table.update()
+        return {'status': 'success',
+                'message': self.request.localizer.translate(self.successMessage),
+                'callback': 'PyAMS_content.associations.afterUpdateCallback',
+                'options': {'parent': associations_table.id,
+                            'table': associations_table.render()}}
+
+
+#
+# Content associations view
+#
+
+@viewlet_config(name='associations.menu', context=IAssociationTarget, layer=IPyAMSLayer,
+                manager=IPropertiesMenu, permission=VIEW_SYSTEM_PERMISSION, weight=20)
+class AssociationsMenu(MenuItem):
+    """Associations menu"""
+
+    label = _("Associations...")
+    icon_class = 'fa-link'
+    url = '#associations.html'
+
+
+@pagelet_config(name='associations.html', context=IAssociationTarget, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+@template_config(template='templates/associations-view.pt', layer=IPyAMSLayer)
+@implementer(IInnerPage, IAssociationsView)
+class AssociationsContainerView(AdminView, Pagelet):
+    """Associations container view"""
+
+    title = _("Associations list")
+
+    def __init__(self, context, request):
+        super(AssociationsContainerView, self).__init__(context, request)
+        self.table = AssociationsTable(context, request, self)
+
+    def update(self):
+        super(AssociationsContainerView, self).update()
+        self.table.update()
+
+
+@adapter_config(name='associations', context=(IAssociationTarget, IPyAMSLayer, IAssociationsParentForm),
+                provides=IInnerSubForm)
+@template_config(template='templates/associations.pt', layer=IPyAMSLayer)
+@implementer(IAssociationsView)
+class AssociationsView(InnerAdminDisplayForm):
+    """Associations view"""
+
+    fields = field.Fields(Interface)
+    weight = 90
+
+    def __init__(self, context, request, view):
+        super(AssociationsView, self).__init__(context, request, view)
+        self.table = AssociationsTable(context, request, self)
+
+    def update(self):
+        super(AssociationsView, self).update()
+        self.table.update()
+
+
+class AssociationsTable(ProtectedFormObjectMixin, BaseTable):
+    """Associations view inner table"""
+
+    @property
+    def id(self):
+        return 'associations_{0}_list'.format(self.context.__name__)
+
+    hide_header = True
+    sortOn = None
+
+    def __init__(self, context, request, view):
+        super(AssociationsTable, self).__init__(context, request)
+        self.view = view
+
+    @property
+    def cssClasses(self):
+        classes = ['table', 'table-bordered', 'table-striped', 'table-hover', 'table-tight']
+        permission = self.permission
+        if (not permission) or self.request.has_permission(permission, self.context):
+            classes.append('table-dnd')
+        return {'table': ' '.join(classes)}
+
+    @property
+    def data_attributes(self):
+        attributes = super(AssociationsTable, self).data_attributes
+        attributes['table'] = {'id': self.id,
+                               'data-ams-plugins': 'pyams_content',
+                               'data-ams-plugin-pyams_content-src':
+                                   '/--static--/pyams_content/js/pyams_content{MyAMS.devext}.js',
+                               'data-ams-location': absolute_url(IAssociationContainer(self.context), self.request),
+                               'data-ams-tablednd-drag-handle': 'td.sorter',
+                               'data-ams-tablednd-drop-target': 'set-associations-order.json'}
+        return attributes
+
+    @reify
+    def values(self):
+        return list(super(AssociationsTable, self).values)
+
+
+@adapter_config(context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IValues)
+class AssociationsTableValuesAdapter(ContextRequestViewAdapter):
+    """Associations table values adapter"""
+
+    @property
+    def values(self):
+        return IAssociationContainer(self.context).values()
+
+
+@adapter_config(name='sorter', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IColumn)
+class AssociationsTableSorterColumn(ProtectedFormObjectMixin, SorterColumn):
+    """Associations table sorter column"""
+
+
+@view_config(name='set-associations-order.json', context=IAssociationContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+def set_associations_order(request):
+    """Update asociations order"""
+    order = list(map(str, json.loads(request.params.get('names'))))
+    request.context.updateOrder(order)
+    return {'status': 'success'}
+
+
+@adapter_config(name='show-hide', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable),
+                provides=IColumn)
+class AssociationsTableShowHideColumn(ProtectedFormObjectMixin, JsActionColumn):
+    """Associations container visibility switcher column"""
+
+    cssClasses = {'th': 'action',
+                  'td': 'action switcher'}
+
+    icon_class = 'fa fa-fw fa-eye'
+    icon_hint = _("Switch association visibility")
+
+    url = 'PyAMS_content.associations.switchVisibility'
+
+    weight = 5
+
+    def get_icon(self, item):
+        if item.visible:
+            icon_class = 'fa fa-fw fa-eye'
+        else:
+            icon_class = 'fa fa-fw fa-eye-slash text-danger'
+        return '<i class="{icon_class}"></i>'.format(icon_class=icon_class)
+
+    def renderCell(self, item):
+        if self.permission and not self.request.has_permission(self.permission, context=item):
+            return self.get_icon(item)
+        else:
+            return super(AssociationsTableShowHideColumn, self).renderCell(item)
+
+
+@view_config(name='set-association-visibility.json', context=IAssociationContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+def set_paragraph_visibility(request):
+    """Set paragraph visibility"""
+    container = IAssociationContainer(request.context)
+    association = container.get(str(request.params.get('object_name')))
+    if association is None:
+        raise NotFound()
+    association.visible = not association.visible
+    return {'visible': association.visible}
+
+
+@adapter_config(name='pictogram', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IColumn)
+class AssociationsTablePictogramColumn(ImageColumn):
+    """Associations table pictogram column"""
+
+    weight = 8
+
+    def get_icon_class(self, item):
+        info = IAssociationInfo(item, None)
+        if info is not None:
+            return info.pictogram
+
+    def get_icon_hint(self, item):
+        return self.request.localizer.translate(item.icon_hint)
+
+
+@adapter_config(name='name', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IColumn)
+class AssociationsTablePublicNameColumn(NameColumn):
+    """Associations table name column"""
+
+    _header = _("Public title")
+
+    def getValue(self, obj):
+        info = IAssociationInfo(obj, None)
+        if info is not None:
+            return info.user_title
+        else:
+            return '--'
+
+
+@adapter_config(name='inner_name', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IColumn)
+class AssociationsTableInnerNameColumn(I18nColumn, GetAttrColumn):
+    """Associations table inner name column"""
+
+    _header = _("Inner title")
+    weight = 20
+
+    def getValue(self, obj):
+        info = IAssociationInfo(obj, None)
+        if info is not None:
+            return info.inner_title
+        else:
+            return '--'
+
+
+@adapter_config(name='size', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IColumn)
+class AssociationsTableSizeColumn(I18nColumn, GetAttrColumn):
+    """Associations table size column"""
+
+    _header = _("Size")
+    weight = 30
+
+    def getValue(self, obj):
+        info = IAssociationInfo(obj, None)
+        if info is not None:
+            return info.human_size
+        else:
+            return '--'
+
+
+@adapter_config(name='trash', context=(IAssociationTarget, IPyAMSLayer, AssociationsTable), provides=IColumn)
+class AssociationsTableTrashColumn(ProtectedFormObjectMixin, TrashColumn):
+    """Associations table trash column"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/zmi/interfaces.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,29 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
+from zope.interface import Interface
+
+
+class IAssociationsView(Interface):
+    """Associations view marker interface"""
+
+
+class IAssociationsParentForm(Interface):
+    """Associations view parent form marker interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/zmi/paragraph.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,156 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.association.interfaces import IAssociationParagraph, IAssociationContainer, \
+    IAssociationInfo
+from pyams_content.component.association.zmi.interfaces import IAssociationsParentForm
+from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IParagraphContainer, \
+    IParagraphSummary
+from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_form.interfaces.form import IInnerForm, IEditFormButtons
+from pyams_i18n.interfaces import II18n
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from z3c.form.interfaces import INPUT_MODE
+
+# import packages
+from pyams_content.component.association.paragraph import AssociationParagraph
+from pyams_content.component.paragraph.zmi import BaseParagraphAJAXEditForm
+from pyams_content.component.paragraph.zmi.container import ParagraphContainerView
+from pyams_form.form import AJAXAddForm
+from pyams_form.security import ProtectedFormObjectMixin
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_template.template import template_config, get_view_template
+from pyams_utils.adapter import adapter_config, ContextRequestAdapter
+from pyams_utils.traversing import get_parent
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-association-paragraph.menu', context=IParagraphContainerTarget, view=ParagraphContainerView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=95)
+class AssociationParagraphAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """Associations paragraph add menu"""
+
+    label = _("Add associations paragraph...")
+    label_css_class = 'fa fa-fw fa-link'
+    url = 'add-association-paragraph.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-association-paragraph.html', context=IParagraphContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class AssociationParagraphAddForm(AdminDialogAddForm):
+    """Association paragraph add form"""
+
+    legend = _("Add new association paragraph")
+    icon_css_class = 'fa fa-fw fa-link'
+
+    fields = field.Fields(IAssociationParagraph).select('title')
+    ajax_handler = 'add-association-paragraph.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def create(self, data):
+        return AssociationParagraph()
+
+    def add(self, object):
+        IParagraphContainer(self.context).append(object)
+
+
+@view_config(name='add-association-paragraph.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class AssociationParagraphAJAXAddForm(AJAXAddForm, AssociationParagraphAddForm):
+    """Association paragraph add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'reload',
+                'location': '#paragraphs.html'}
+
+
+@pagelet_config(name='properties.html', context=IAssociationParagraph, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class AssociationParagraphPropertiesEditForm(AdminDialogEditForm):
+    """Association paragraph properties edit form"""
+
+    @property
+    def title(self):
+        content = get_parent(self.context, IWfSharedContent)
+        return II18n(content).query_attribute('title', request=self.request)
+
+    legend = _("Edit association paragraph properties")
+    icon_css_class = 'fa fa-fw fa-link'
+
+    fields = field.Fields(IAssociationParagraph).select('title')
+    ajax_handler = 'properties.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+
+@view_config(name='properties.json', context=IAssociationParagraph, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class AssociationParagraphPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, AssociationParagraphPropertiesEditForm):
+    """Association paragraph properties edit form, JSON renderer"""
+
+
+@adapter_config(context=(IAssociationParagraph, IPyAMSLayer), provides=IParagraphInnerEditor)
+@implementer(IInnerForm, IAssociationsParentForm)
+class AssociationParagraphInnerEditForm(AssociationParagraphPropertiesEditForm):
+    """Association paragraph inner edit form"""
+
+    legend = None
+
+    @property
+    def buttons(self):
+        if self.mode == INPUT_MODE:
+            return button.Buttons(IEditFormButtons)
+        else:
+            return button.Buttons()
+
+
+#
+# Association paragraph summary
+#
+
+@adapter_config(context=(IAssociationParagraph, IPyAMSLayer), provides=IParagraphSummary)
+@template_config(template='templates/paragraph-summary.pt', layer=IPyAMSLayer)
+class AssociationParagraphSummary(ContextRequestAdapter):
+    """Association paragraph renderer"""
+
+    language = None
+    associations = None
+
+    def update(self):
+        i18n = II18n(self.context)
+        if self.language:
+            for attr in ('title', ):
+                setattr(self, attr, i18n.get_attribute(attr, self.language, request=self.request))
+        else:
+            for attr in ('title', ):
+                setattr(self, attr, i18n.query_attribute(attr, request=self.request))
+        self.associations = [{'url': item.get_url(self.request),
+                              'title': IAssociationInfo(item).user_title}
+                             for item in IAssociationContainer(self.context).values() if item.visible]
+
+    render = get_view_template()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/zmi/templates/associations-view.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,13 @@
+<div class="ams-widget">
+	<header>
+		<span tal:condition="view.widget_icon_class | nothing"
+			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+		</span>
+		<h2 tal:content="view.title"></h2>
+		<tal:var content="structure provider:pyams.widget_title" />
+		<tal:var content="structure provider:pyams.toolbar" />
+	</header>
+	<div class="widget-body no-widget-toolbar">
+		<tal:var content="structure view.table.render()" />
+	</div>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/zmi/templates/associations.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,14 @@
+<div class="form-group" i18n:domain="pyams_content">
+	<fieldset class="margin-top-10 padding-top-5 padding-bottom-0">
+		<legend
+			class="inner switcher margin-bottom-5 padding-right-10 no-y-padding pull-left width-auto"
+			tal:attributes="data-ams-switcher-state 'open' if view.table.values else None">
+			<i18n:var translate="">Associations</i18n:var>
+		</legend>
+		<div class="pull-left">
+			<tal:var content="structure provider:pyams.widget_title" />
+		</div>
+		<div class="clearfix"></div>
+		<tal:var content="structure view.table.render()" />
+	</fieldset>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/association/zmi/templates/paragraph-summary.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,9 @@
+<i18n:var domain="pyams_content">
+	<h3 tal:content="i18n:title">§ title</h3>
+	<ul>
+		<li tal:repeat="item view.associations">
+			<a tal:attributes="href item['url']"
+			   tal:content="item['title']" target="_blank">Link</a>
+		</li>
+	</ul>
+</i18n:var>
--- a/src/pyams_content/component/extfile/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/extfile/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,21 +16,27 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.extfile.interfaces import IBaseExtFile, IExtFile, IExtImage, IExtVideo, IExtAudio
+from pyams_content.component.association.interfaces import IAssociationInfo
+from pyams_content.component.extfile.interfaces import IBaseExtFile, IExtFile, IExtImage, IExtVideo, IExtAudio, \
+    IExtMedia
 from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_file.interfaces import IFileInfo, IResponsiveImage, DELETED_FILE
 from pyams_form.interfaces.form import IFormContextPermissionChecker
+from pyams_i18n.interfaces import II18n, INegotiator
 from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
 
 # import packages
-from persistent import Persistent
+from pyams_content.component.association import AssociationItem
 from pyams_i18n.property import I18nFileProperty
 from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.registry import query_utility
+from pyams_utils.request import check_request
+from pyams_utils.size import get_human_size
 from pyams_utils.traversing import get_parent
 from pyams_utils.vocabulary import vocabulary_config
 from pyramid.events import subscriber
 from pyramid.threadlocal import get_current_registry
-from zope.container.contained import Contained
-from zope.interface import implementer
+from zope.interface import implementer, alsoProvides
 from zope.lifecycleevent import ObjectModifiedEvent
 from zope.schema.fieldproperty import FieldProperty
 from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
@@ -58,12 +64,39 @@
 
 
 @implementer(IBaseExtFile)
-class BaseExtFile(Persistent, Contained):
+class BaseExtFile(AssociationItem):
     """External file persistent class"""
 
     title = FieldProperty(IExtFile['title'])
     description = FieldProperty(IExtFile['description'])
     author = FieldProperty(IExtFile['author'])
+    language = FieldProperty(IExtFile['language'])
+    filename = FieldProperty(IExtFile['filename'])
+
+
+@adapter_config(context=IBaseExtFile, provides=IAssociationInfo)
+class BaseExtFileAssociationInfoAdapter(ContextAdapter):
+    """Base external file association info adapter"""
+
+    @property
+    def pictogram(self):
+        return self.context.icon_class
+
+    @property
+    def user_title(self):
+        return II18n(self.context).query_attribute('title') or self.context.filename
+
+    @property
+    def inner_title(self):
+        return self.context.filename or '--'
+
+    @property
+    def human_size(self):
+        data = II18n(self.context).query_attribute('data')
+        if data and data.data:
+            return get_human_size(data.get_size())
+        else:
+            return '--'
 
 
 @adapter_config(context=IBaseExtFile, provides=IFormContextPermissionChecker)
@@ -76,10 +109,34 @@
         return IFormContextPermissionChecker(content).edit_permission
 
 
+def update_properties(extfile):
+    """Update missing file properties"""
+    request = check_request()
+    i18n = query_utility(INegotiator)
+    if i18n is not None:
+        lang = i18n.server_language
+        data = II18n(extfile).get_attribute('data', lang, request)
+        if data:
+            info = IFileInfo(data)
+            info.title = II18n(extfile).get_attribute('title', lang, request)
+            info.description = II18n(extfile).get_attribute('description', lang, request)
+            if not extfile.filename:
+                extfile.filename = info.filename
+            else:
+                info.filename = extfile.filename
+        for lang, data in (extfile.data or {}).items():
+            if data is not None:
+                IFileInfo(data).language = lang
+
+
 @subscriber(IObjectAddedEvent, context_selector=IBaseExtFile)
 def handle_added_extfile(event):
     """Handle added external file"""
-    content = get_parent(event.object, IWfSharedContent)
+    # update inner file properties
+    extfile = event.object
+    update_properties(extfile)
+    # notify content modification
+    content = get_parent(extfile, IWfSharedContent)
     if content is not None:
         get_current_registry().notify(ObjectModifiedEvent(content))
 
@@ -87,7 +144,11 @@
 @subscriber(IObjectModifiedEvent, context_selector=IBaseExtFile)
 def handle_modified_extfile(event):
     """Handle modified external file"""
-    content = get_parent(event.object, IWfSharedContent)
+    # update inner file properties
+    extfile = event.object
+    update_properties(extfile)
+    # notify content modification
+    content = get_parent(extfile, IWfSharedContent)
     if content is not None:
         get_current_registry().notify(ObjectModifiedEvent(content))
 
@@ -104,6 +165,9 @@
 class ExtFile(BaseExtFile):
     """Generic external file persistent class"""
 
+    icon_class = 'fa-file-o'
+    icon_hint = _("Standard file")
+
     data = I18nFileProperty(IExtFile['data'])
 
 register_file_factory('file', ExtFile, _("Standard file"))
@@ -113,7 +177,23 @@
 class ExtImage(BaseExtFile):
     """External image persistent class"""
 
-    data = I18nFileProperty(IExtImage['data'])
+    icon_class = 'fa-file-image-o'
+    icon_hint = _("Image")
+
+    title = FieldProperty(IExtMedia['title'])
+    alt_title = FieldProperty(IExtImage['alt_title'])
+    _data = I18nFileProperty(IExtImage['data'])
+
+    @property
+    def data(self):
+        return self._data
+
+    @data.setter
+    def data(self, value):
+        self._data = value
+        for data in value.values():
+            if (data is not None) and (data is not DELETED_FILE):
+                alsoProvides(data, IResponsiveImage)
 
 register_file_factory('image', ExtImage, _("Image"))
 
@@ -122,6 +202,10 @@
 class ExtVideo(BaseExtFile):
     """External video file persistent class"""
 
+    icon_class = 'fa-file-video-o'
+    icon_hint = _("Video")
+
+    title = FieldProperty(IExtMedia['title'])
     data = I18nFileProperty(IExtVideo['data'])
 
 register_file_factory('video', ExtVideo, _("Video"))
@@ -131,6 +215,10 @@
 class ExtAudio(BaseExtFile):
     """External audio file persistent class"""
 
+    icon_class = 'fa-file-audio-o'
+    icon_hint = _("Audio file")
+
+    title = FieldProperty(IExtMedia['title'])
     data = I18nFileProperty(IExtAudio['data'])
 
 register_file_factory('audio', ExtAudio, _("Audio file"))
--- a/src/pyams_content/component/extfile/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,146 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
-# import interfaces
-from pyams_content.component.extfile.interfaces import IExtFileContainer, IExtFileContainerTarget, \
-    EXTFILE_CONTAINER_KEY, IExtFileLinksContainer, IExtFileLinksContainerTarget, EXTFILE_LINKS_CONTAINER_KEY
-from pyams_file.interfaces import IMediaFile, IImage, IVideo, IAudio
-from pyams_i18n.interfaces import II18n
-from zope.annotation.interfaces import IAnnotations
-from zope.location.interfaces import ISublocations
-from zope.traversing.interfaces import ITraversable
-
-# import packages
-from persistent import Persistent
-from persistent.list import PersistentList
-from pyams_utils.adapter import adapter_config, ContextAdapter
-from pyams_utils.traversing import get_parent
-from pyams_utils.vocabulary import vocabulary_config
-from pyramid.threadlocal import get_current_registry
-from zope.container.contained import Contained
-from zope.container.folder import Folder
-from zope.interface import implementer
-from zope.lifecycleevent import ObjectCreatedEvent
-from zope.location import locate
-from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
-
-
-#
-# External files container
-#
-
-@implementer(IExtFileContainer)
-class ExtFileContainer(Folder):
-    """External files container"""
-
-    last_id = 1
-
-    def __setitem__(self, key, value):
-        key = str(self.last_id)
-        super(ExtFileContainer, self).__setitem__(key, value)
-        self.last_id += 1
-
-    @property
-    def files(self):
-        return (file for file in self.values() if not IMediaFile.providedBy(II18n(file).query_attribute('data')))
-
-    @property
-    def medias(self):
-        return (file for file in self.values() if IMediaFile.providedBy(II18n(file).query_attribute('data')))
-
-    @property
-    def images(self):
-        return (file for file in self.values() if IImage.providedBy(II18n(file).query_attribute('data')))
-
-    @property
-    def videos(self):
-        return (file for file in self.values() if IVideo.providedBy(II18n(file).query_attribute('data')))
-
-    @property
-    def audios(self):
-        return (file for file in self.values() if IAudio.providedBy(II18n(file).query_attribute('data')))
-
-
-@adapter_config(context=IExtFileContainerTarget, provides=IExtFileContainer)
-def extfile_container_factory(target):
-    """External files container factory"""
-    annotations = IAnnotations(target)
-    container = annotations.get(EXTFILE_CONTAINER_KEY)
-    if container is None:
-        container = annotations[EXTFILE_CONTAINER_KEY] = ExtFileContainer()
-        get_current_registry().notify(ObjectCreatedEvent(container))
-        locate(container, target, '++files++')
-    return container
-
-
-@adapter_config(name='files', context=IExtFileContainerTarget, provides=ITraversable)
-class ExtFileContainerNamespace(ContextAdapter):
-    """++files++ namespace adapter"""
-
-    def traverse(self, name, furtherpath=None):
-        return IExtFileContainer(self.context)
-
-
-@adapter_config(name='extfile', context=IExtFileContainerTarget, provides=ISublocations)
-class ExtFileContainerSublocations(ContextAdapter):
-    """External files container sublocations"""
-
-    def sublocations(self):
-        return IExtFileContainer(self.context).values()
-
-
-@vocabulary_config(name='PyAMS content external files')
-class ExtFileContainerFilesVocabulary(SimpleVocabulary):
-    """External files container files vocabulary"""
-
-    def __init__(self, context):
-        target = get_parent(context, IExtFileContainerTarget)
-        terms = [SimpleTerm(file.__name__, title=II18n(file).query_attribute('title'))
-                 for file in IExtFileContainer(target).values()]
-        super(ExtFileContainerFilesVocabulary, self).__init__(terms)
-
-
-#
-# External file links container
-#
-
-@implementer(IExtFileLinksContainer)
-class ExtFileLinksContainer(Persistent, Contained):
-    """External files links container"""
-
-    def __init__(self):
-        self.files = PersistentList()
-
-
-@adapter_config(context=IExtFileLinksContainerTarget, provides=IExtFileLinksContainer)
-def extfile_links_container_factory(target):
-    """External files links container factory"""
-    annotations = IAnnotations(target)
-    container = annotations.get(EXTFILE_LINKS_CONTAINER_KEY)
-    if container is None:
-        container = annotations[EXTFILE_LINKS_CONTAINER_KEY] = ExtFileLinksContainer()
-        get_current_registry().notify(ObjectCreatedEvent(container))
-        locate(container, target, '++files-links++')
-    return container
-
-
-@adapter_config(name='files-links', context=IExtFileLinksContainerTarget, provides=ITraversable)
-class ExtFileLinksContainerNamespace(ContextAdapter):
-    """++files-links++ namespace adapter"""
-
-    def traverse(self, name, furtherpath=None):
-        return IExtFileLinksContainer(self.context)
--- a/src/pyams_content/component/extfile/interfaces/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/extfile/interfaces/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,14 +16,10 @@
 # import standard library
 
 # import interfaces
-from zope.annotation.interfaces import IAttributeAnnotatable
-from zope.container.interfaces import IContainer
+from pyams_content.component.association.interfaces import IAssociationItem, IAssociationTarget
 
 # import packages
 from pyams_i18n.schema import I18nTextLineField, I18nTextField, I18nFileField, I18nThumbnailImageField
-from pyams_utils.schema import PersistentList
-from zope.container.constraints import containers, contains
-from zope.interface import Interface, Attribute
 from zope.schema import TextLine, Choice
 
 from pyams_content import _
@@ -33,14 +29,12 @@
 EXTFILE_LINKS_CONTAINER_KEY = 'pyams_content.extfile.links'
 
 
-class IBaseExtFile(IAttributeAnnotatable):
+class IBaseExtFile(IAssociationItem):
     """Base external file interface"""
 
-    containers('.IExtFileContainer')
-
     title = I18nTextLineField(title=_("Title"),
                               description=_("File title, as shown in front-office"),
-                              required=True)
+                              required=False)
 
     description = I18nTextField(title=_("Description"),
                                 description=_("File description displayed by front-office template"),
@@ -50,6 +44,15 @@
                       description=_("Name of document's author"),
                       required=False)
 
+    language = Choice(title=_("Language"),
+                      description=_("File's content language"),
+                      vocabulary="PyAMS base languages",
+                      required=False)
+
+    filename = TextLine(title=_("Save file as..."),
+                        description=_("Name under which the file will be saved"),
+                        required=False)
+
 
 class IExtFile(IBaseExtFile):
     """Generic external file interface"""
@@ -62,10 +65,18 @@
 class IExtMedia(IExtFile):
     """External media file interface"""
 
+    title = I18nTextLineField(title=_("Legend"),
+                              description=_("File legend, as shown in front-office"),
+                              required=False)
+
 
 class IExtImage(IExtMedia):
     """External image file interface"""
 
+    alt_title = I18nTextLineField(title=_("Accessibility title"),
+                                  description=_("Alternate title used to describe image content"),
+                                  required=False)
+
     data = I18nThumbnailImageField(title=_("Image data"),
                                    description=_("Image content"),
                                    required=True)
@@ -79,30 +90,5 @@
     """External audio file interface"""
 
 
-class IExtFileContainer(IContainer):
-    """External files container"""
-
-    contains(IBaseExtFile)
-
-    files = Attribute("Files list iterator")
-    medias = Attribute("Medias list iterator")
-    images = Attribute("Images list iterator")
-    videos = Attribute("Videos list iterator")
-    audios = Attribute("Audios list iterator")
-
-
-class IExtFileContainerTarget(Interface):
+class IExtFileContainerTarget(IAssociationTarget):
     """External files container marker interface"""
-
-
-class IExtFileLinksContainer(Interface):
-    """External files links container interface"""
-
-    files = PersistentList(title=_("External files"),
-                           description=_("List of external files linked to this object"),
-                           value_type=Choice(vocabulary="PyAMS content external files"),
-                           required=False)
-
-
-class IExtFileLinksContainerTarget(Interface):
-    """External files links container marker interface"""
--- a/src/pyams_content/component/extfile/zmi/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/extfile/zmi/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,25 +16,21 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.extfile.interfaces import IExtFileContainer, IExtFileContainerTarget, IBaseExtFile, \
-    IExtFile, IExtImage
+from pyams_content.component.association.interfaces import IAssociationContainer
+from pyams_content.component.association.zmi.interfaces import IAssociationsView
+from pyams_content.component.extfile.interfaces import IExtFileContainerTarget, IBaseExtFile, \
+    IExtFile, IExtImage, IExtVideo, IExtAudio
 from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_file.interfaces import IFileInfo
-from pyams_i18n.interfaces import INegotiator, II18n
-from pyams_skin.interfaces.viewlet import IWidgetTitleViewletManager
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
 from pyams_skin.layer import IPyAMSLayer
 from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
-from z3c.form.interfaces import NOT_CHANGED
 
 # import packages
+from pyams_content.component.association.zmi import AssociationItemAJAXAddForm, AssociationItemAJAXEditForm
 from pyams_content.component.extfile import EXTERNAL_FILES_FACTORIES
-from pyams_content.component.extfile.zmi.container import ExtFileContainerView
-from pyams_form.form import AJAXAddForm, AJAXEditForm
 from pyams_form.security import ProtectedFormObjectMixin
 from pyams_pagelet.pagelet import pagelet_config
-from pyams_skin.viewlet.toolbar import ToolbarAction
-from pyams_utils.registry import query_utility
-from pyams_utils.traversing import get_parent
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem, ToolbarMenuDivider
 from pyams_viewlet.viewlet import viewlet_config
 from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
 from pyramid.view import view_config
@@ -58,12 +54,19 @@
                      required=True)
 
 
-@viewlet_config(name='add-extfile.menu', context=IExtFileContainerTarget, view=ExtFileContainerView,
-                layer=IPyAMSLayer, manager=IWidgetTitleViewletManager, weight=50)
-class ExtFileAddMenu(ProtectedFormObjectMixin, ToolbarAction):
+@viewlet_config(name='add-extfile.divider', context=IExtFileContainerTarget, view=IAssociationsView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=59)
+class ExtFileAddMenuDivider(ToolbarMenuDivider):
+    """External file add menu divider"""
+
+
+@viewlet_config(name='add-extfile.menu', context=IExtFileContainerTarget, view=IAssociationsView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=60)
+class ExtFileAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
     """External file add menu"""
 
     label = _("Add external file")
+    label_css_class = 'fa fa-fw fa-file-o'
 
     url = 'add-extfile.html'
     modal_target = True
@@ -75,19 +78,10 @@
     """External file add form"""
 
     legend = _("Add new external file")
-    icon_css_class = 'fa fa-fw fa-file-text-o'
-
-    fields = field.Fields(IExtFileFactoryChooser) + \
-             field.Fields(IExtFile).omit('__parent__', '__name__')
+    icon_css_class = 'fa fa-fw fa-file-o'
 
-    @property
-    def ajax_handler(self):
-        origin = self.request.params.get('origin')
-        if origin == 'link':
-            return 'add-extfile-link.json'
-        else:
-            return 'add-extfile.json'
-
+    fields = field.Fields(IExtFile).select('title', 'description', 'author', 'language', 'data', 'filename')
+    ajax_handler = 'add-extfile.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
     def updateWidgets(self, prefix=None):
@@ -96,66 +90,29 @@
             self.widgets['description'].widget_css_class = 'textarea'
 
     def create(self, data):
-        factory = EXTERNAL_FILES_FACTORIES.get(data.get('factory'))
+        factory = EXTERNAL_FILES_FACTORIES.get('file')
         if factory is not None:
             return factory[0]()
 
-    def update_content(self, content, data):
-        data['factory'] = NOT_CHANGED
-        return super(ExtFileAddForm, self).update_content(content, data)
-
     def add(self, object):
-        IExtFileContainer(self.context)['none'] = object
-        i18n = query_utility(INegotiator)
-        if i18n is not None:
-            lang = i18n.server_language
-            data = II18n(object).get_attribute('data', lang, self.request)
-            if data:
-                info = IFileInfo(data)
-                info.title = II18n(object).get_attribute('title', lang, self.request)
-                info.description = II18n(object).get_attribute('description', lang, self.request)
-            for lang, data in object.data.items():
-                if data is not None:
-                    IFileInfo(data).language = lang
+        IAssociationContainer(self.context).append(object)
 
 
 @view_config(name='add-extfile.json', context=IExtFileContainerTarget, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExtFileAJAXAddForm(AJAXAddForm, ExtFileAddForm):
+class ExtFileAJAXAddForm(AssociationItemAJAXAddForm, ExtFileAddForm):
     """External file add form, JSON renderer"""
 
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': '#external-files.html'}
-
-
-@view_config(name='add-extfile-link.json', context=IExtFileContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExtFileLinkAJAXAddForm(AJAXAddForm, ExtFileAddForm):
-    """External file link add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        target = get_parent(self.context, IExtFileContainerTarget)
-        container = IExtFileContainer(target)
-        files = [{'id': file.__name__,
-                  'text': II18n(file).query_attribute('title', request=self.request)}
-                 for file in container.values()]
-        return {'status': 'callback',
-                'callback': 'PyAMS_content.extfiles.refresh',
-                'options': {'files': files,
-                            'new_file': {'id': changes.__name__,
-                                         'text': II18n(changes).query_attribute('title', request=self.request)}}}
-
 
 @pagelet_config(name='properties.html', context=IExtFile, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
 class ExtFilePropertiesEditForm(AdminDialogEditForm):
     """External file properties edit form"""
 
     legend = _("Update file properties")
-    icon_css_class = 'fa fa-fw fa-file-text-o'
+    icon_css_class = 'fa fa-fw fa-file-o'
     dialog_class = 'modal-large'
 
-    fields = field.Fields(IExtFile).omit('__parent__', '__file__')
+    fields = field.Fields(IExtFile).select('title', 'description', 'author', 'language', 'data', 'filename')
     ajax_handler = 'properties.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
@@ -164,21 +121,59 @@
         if 'description' in self.widgets:
             self.widgets['description'].widget_css_class = 'textarea'
 
-    def update_content(self, content, data):
-        changes = super(ExtFilePropertiesEditForm, self).update_content(content, data)
-        if changes:
-            i18n = query_utility(INegotiator)
-            if i18n is not None:
-                lang = i18n.server_language
-                data = II18n(content).get_attribute('data', lang, self.request)
-                if data:
-                    info = IFileInfo(data)
-                    info.title = II18n(content).get_attribute('title', lang, self.request)
-                    info.description = II18n(content).get_attribute('description', lang, self.request)
-            for lang, data in content.data.items():
-                if data and not IFileInfo(data).language:
-                    IFileInfo(data).language = lang
-        return changes
+
+@view_config(name='properties.json', context=IExtFile, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ExtFilePropertiesAJAXEditForm(AssociationItemAJAXEditForm, ExtFilePropertiesEditForm):
+    """External file properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        if ('title' in changes.get(IBaseExtFile, ())) or \
+           ('filename' in changes.get(IBaseExtFile, ())) or \
+           ('data' in changes.get(IExtFile, ())):
+            return self.get_associations_table()
+        else:
+            return super(ExtFilePropertiesAJAXEditForm, self).get_ajax_output(changes)
+
+
+#
+# Images views
+#
+
+@viewlet_config(name='add-extimage.menu', context=IExtFileContainerTarget, view=IAssociationsView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=61)
+class ExtImageAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """External image add menu"""
+
+    label = _("Add image")
+    label_css_class = 'fa fa-fw fa-file-image-o'
+
+    url = 'add-extimage.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-extimage.html', context=IExtFileContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class ExtImageAddForm(ExtFileAddForm):
+    """External image add form"""
+
+    legend = _("Add new image")
+    icon_css_class = 'fa fa-fw fa-file-image-o'
+
+    fields = field.Fields(IExtImage).select('title', 'alt_title', 'description', 'author',
+                                            'language', 'data', 'filename')
+    ajax_handler = 'add-extimage.json'
+
+    def create(self, data):
+        factory = EXTERNAL_FILES_FACTORIES.get('image')
+        if factory is not None:
+            return factory[0]()
+
+
+@view_config(name='add-extimage.json', context=IExtFileContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ExtImageAJAXAddForm(AssociationItemAJAXAddForm, ExtImageAddForm):
+    """External image add form, JSON renderer"""
 
 
 @pagelet_config(name='properties.html', context=IExtImage, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
@@ -186,20 +181,147 @@
     """External image properties edit form"""
 
     legend = _("Update image properties")
-    icon_css_class = 'fa fa-fw fa-picture-o'
+    icon_css_class = 'fa fa-fw fa-file-image-o'
 
-    fields = field.Fields(IExtImage).omit('__parent__', '__name__')
+    fields = field.Fields(IExtImage).select('title', 'alt_title', 'description', 'author',
+                                            'language', 'data', 'filename')
 
 
-@view_config(name='properties.json', context=IExtFile, request_type=IPyAMSLayer,
+@view_config(name='properties.json', context=IExtImage, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExtFilePropertiesAJAXEditForm(AJAXEditForm, ExtFilePropertiesEditForm):
-    """External file properties edit form, JSON renderer"""
+class ExtImagePropertiesAJAXEditForm(AssociationItemAJAXEditForm, ExtImagePropertiesEditForm):
+    """External image properties edit form, JSON renderer"""
 
     def get_ajax_output(self, changes):
         if ('title' in changes.get(IBaseExtFile, ())) or \
+           ('filename' in changes.get(IBaseExtFile, ())) or \
            ('data' in changes.get(IExtFile, ())):
-            return {'status': 'reload',
-                    'location': '#external-files.html'}
+            return self.get_associations_table()
+        else:
+            return super(ExtImagePropertiesAJAXEditForm, self).get_ajax_output(changes)
+
+
+#
+# Videos views
+#
+
+@viewlet_config(name='add-extvideo.menu', context=IExtFileContainerTarget, view=IAssociationsView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=62)
+class ExtVideoAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """External video add menu"""
+
+    label = _("Add video")
+    label_css_class = 'fa fa-fw fa-file-video-o'
+
+    url = 'add-extvideo.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-extvideo.html', context=IExtFileContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class ExtVideoAddForm(ExtFileAddForm):
+    """External video add form"""
+
+    legend = _("Add new video")
+    icon_css_class = 'fa fa-fw fa-file-video-o'
+
+    fields = field.Fields(IExtVideo).select('title', 'description', 'author', 'language', 'data', 'filename')
+    ajax_handler = 'add-extvideo.json'
+
+    def create(self, data):
+        factory = EXTERNAL_FILES_FACTORIES.get('video')
+        if factory is not None:
+            return factory[0]()
+
+
+@view_config(name='add-extvideo.json', context=IExtFileContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ExtVideoAJAXAddForm(AssociationItemAJAXAddForm, ExtVideoAddForm):
+    """External video add form, JSON renderer"""
+
+
+@pagelet_config(name='properties.html', context=IExtVideo, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
+class ExtVideoPropertiesEditForm(ExtFilePropertiesEditForm):
+    """External video properties edit form"""
+
+    legend = _("Update video properties")
+    icon_css_class = 'fa fa-fw fa-file-video-o'
+
+    fields = field.Fields(IExtVideo).select('title', 'description', 'author', 'language', 'data', 'filename')
+
+
+@view_config(name='properties.json', context=IExtVideo, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ExtVideoPropertiesAJAXEditForm(AssociationItemAJAXEditForm, ExtVideoPropertiesEditForm):
+    """External video properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        if ('title' in changes.get(IBaseExtFile, ())) or \
+           ('filename' in changes.get(IBaseExtFile, ())) or \
+           ('data' in changes.get(IExtFile, ())):
+            return self.get_associations_table()
         else:
-            return super(ExtFilePropertiesAJAXEditForm, self).get_ajax_output(changes)
+            return super(ExtVideoPropertiesAJAXEditForm, self).get_ajax_output(changes)
+
+
+#
+# Audio file views
+#
+
+@viewlet_config(name='add-extaudio.menu', context=IExtFileContainerTarget, view=IAssociationsView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=63)
+class ExtAudioAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """External audio file add menu"""
+
+    label = _("Add audio file")
+    label_css_class = 'fa fa-fw fa-file-audio-o'
+
+    url = 'add-extaudio.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-extaudio.html', context=IExtFileContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class ExtAudioAddForm(ExtFileAddForm):
+    """External audio file add form"""
+
+    legend = _("Add new audio file")
+    icon_css_class = 'fa fa-fw fa-file-audio-o'
+
+    fields = field.Fields(IExtAudio).select('title', 'description', 'author', 'language', 'data', 'filename')
+    ajax_handler = 'add-extaudio.json'
+
+    def create(self, data):
+        factory = EXTERNAL_FILES_FACTORIES.get('audio')
+        if factory is not None:
+            return factory[0]()
+
+
+@view_config(name='add-extaudio.json', context=IExtFileContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ExtAudioAJAXAddForm(AssociationItemAJAXAddForm, ExtAudioAddForm):
+    """External audio file add form, JSON renderer"""
+
+
+@pagelet_config(name='properties.html', context=IExtAudio, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
+class ExtAudioPropertiesEditForm(ExtFilePropertiesEditForm):
+    """External audio file properties edit form"""
+
+    legend = _("Update audio file properties")
+    icon_css_class = 'fa fa-fw fa-file-audio-o'
+
+    fields = field.Fields(IExtVideo).select('title', 'description', 'author', 'language', 'data', 'filename')
+
+
+@view_config(name='properties.json', context=IExtAudio, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ExtAudioPropertiesAJAXEditForm(AssociationItemAJAXEditForm, ExtAudioPropertiesEditForm):
+    """External audio file properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        if ('title' in changes.get(IBaseExtFile, ())) or \
+           ('filename' in changes.get(IBaseExtFile, ())) or \
+           ('data' in changes.get(IExtFile, ())):
+            return self.get_associations_table()
+        else:
+            return super(ExtAudioPropertiesAJAXEditForm, self).get_ajax_output(changes)
--- a/src/pyams_content/component/extfile/zmi/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/extfile/zmi/container.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,53 +16,16 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.extfile.interfaces import IExtFileContainerTarget, IExtFileContainer, \
-    IExtFileLinksContainerTarget, IExtFileLinksContainer
-from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_file.interfaces import IFileInfo, IFile, IImage
+from pyams_content.component.association.interfaces import IAssociationTarget, IAssociationContainer, IAssociationInfo
+from pyams_content.component.extfile.interfaces import IExtFile, IExtImage
 from pyams_i18n.interfaces import II18n
-from pyams_skin.interfaces import IInnerPage, IPageHeader
 from pyams_skin.layer import IPyAMSLayer
-from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
-from pyams_utils.interfaces.data import IObjectData
-from pyams_zmi.interfaces.menu import IPropertiesMenu
-from pyams_zmi.layer import IAdminLayer
-from z3c.table.interfaces import IValues, IColumn
 
 # import packages
-from pyams_content.component.extfile.zmi.widget import ExtFileLinkSelectFieldWidget
-from pyams_content.shared.common.zmi import WfModifiedContentColumnMixin
-from pyams_form.form import AJAXEditForm
-from pyams_form.security import ProtectedFormObjectMixin
-from pyams_pagelet.pagelet import pagelet_config
-from pyams_skin.page import DefaultPageHeaderAdapter
-from pyams_skin.table import BaseTable, I18nColumn, TrashColumn
-from pyams_skin.viewlet.menu import MenuItem, MenuDivider
-from pyams_template.template import template_config
-from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
-from pyams_utils.size import get_human_size
 from pyams_utils.traversing import get_parent
 from pyams_utils.url import absolute_url
-from pyams_viewlet.viewlet import viewlet_config
-from pyams_zmi.form import AdminDialogEditForm
-from pyams_zmi.view import AdminView
-from pyramid.decorator import reify
 from pyramid.view import view_config
-from z3c.form import field
-from z3c.table.column import GetAttrColumn
-from zope.interface import implementer, alsoProvides, Interface
-
-from pyams_content import _
-
-
-@viewlet_config(name='external-files.menu', context=IExtFileContainerTarget, layer=IAdminLayer,
-                manager=IPropertiesMenu, permission=VIEW_SYSTEM_PERMISSION, weight=200)
-class ExtFileContainerMenu(MenuItem):
-    """External files container menu"""
-
-    label = _("External files...")
-    icon_class = 'fa-file-text-o'
-    url = '#external-files.html'
+from zope.interface import Interface
 
 
 #
@@ -73,191 +36,27 @@
              renderer='json', xhr=True)
 def get_files_list(request):
     """Get container files in JSON format for TinyMCE editor"""
-    target = get_parent(request.context, IExtFileContainerTarget)
-    if target is None:
-        return []
-    container = IExtFileContainer(target)
-    return sorted([{'title': II18n(file).query_attribute('title', request=request),
-                    'value': absolute_url(II18n(file).query_attribute('data', request=request), request)}
-                   for file in container.values()],
-                  key=lambda x: x['title'])
+    result = []
+    target = get_parent(request.context, IAssociationTarget)
+    if target is not None:
+        container = IAssociationContainer(target)
+        result.extend([{'title': IAssociationInfo(item).user_title,
+                        'value': absolute_url(II18n(item).query_attribute('data', request=request),
+                                              request=request)}
+                       for item in container.values() if IExtFile.providedBy(item)])
+    return sorted(result, key=lambda x: x['title'])
 
 
 @view_config(name='get-images-list.json', context=Interface, request_type=IPyAMSLayer,
              renderer='json', xhr=True)
 def get_images_list(request):
     """Get container images in JSON format for TinyMCE editor"""
-    target = get_parent(request.context, IExtFileContainerTarget)
-    if target is None:
-        return []
-    container = IExtFileContainer(target)
-    return sorted([{'title': II18n(img).query_attribute('title', request=request),
-                    'value': absolute_url(II18n(img).query_attribute('data', request=request), request)}
-                   for img in container.images],
-                  key=lambda x: x['title'])
-
-
-@pagelet_config(name='external-files.html', context=IExtFileContainerTarget, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-@template_config(template='templates/container.pt', layer=IPyAMSLayer)
-@implementer(IInnerPage)
-class ExtFileContainerView(AdminView):
-    """External files container view"""
-
-    title = _("External files list")
-
-    def __init__(self, context, request):
-        super(ExtFileContainerView, self).__init__(context, request)
-        self.files_table = ExtFileContainerTable(context, request, self, _("External files"), 'files')
-        self.images_table = ExtFileContainerTable(context, request, self, _("Images"), 'images')
-        self.videos_table = ExtFileContainerTable(context, request, self, _("Videos"), 'videos')
-        self.audios_table = ExtFileContainerTable(context, request, self, _("Sounds"), 'audios')
-
-    def update(self):
-        super(ExtFileContainerView, self).update()
-        self.files_table.update()
-        self.images_table.update()
-        self.videos_table.update()
-        self.audios_table.update()
-
-
-class ExtFileContainerTable(BaseTable):
-    """External files container table"""
-
-    hide_toolbar = True
-    cssClasses = {'table': 'table table-bordered table-striped table-hover table-tight'}
-
-    def __init__(self, context, request, view, title, property):
-        super(ExtFileContainerTable, self).__init__(context, request)
-        self.view = view
-        self.title = title
-        self.files_property = property
-        self.object_data = {'ams-widget-toggle-button': 'false'}
-        alsoProvides(self, IObjectData)
-
-    @property
-    def data_attributes(self):
-        attributes = super(ExtFileContainerTable, self).data_attributes
-        attributes['table'] = {'data-ams-location': absolute_url(IExtFileContainer(self.context), self.request),
-                               'data-ams-datatable-sort': 'false',
-                               'data-ams-datatable-pagination': 'false'}
-        return attributes
-
-    @reify
-    def values(self):
-        return list(super(ExtFileContainerTable, self).values)
-
-    def render(self):
-        if not self.values:
-            if self.files_property == 'files':
-                for table in (self.view.images_table, self.view.videos_table, self.view.audios_table):
-                    if table.values:
-                        return ''
-                translate = self.request.localizer.translate
-                return translate(_("No currently stored external file."))
-            else:
-                return ''
-        return super(ExtFileContainerTable, self).render()
-
-
-@adapter_config(name='name', context=(IExtFileContainerTarget, IPyAMSLayer, ExtFileContainerTable), provides=IColumn)
-class ExtFileContainerNameColumn(I18nColumn, WfModifiedContentColumnMixin, GetAttrColumn):
-    """External files container name column"""
-
-    _header = _("Title")
-
-    weight = 10
-
-    def getValue(self, obj):
-        return II18n(obj).query_attribute('title', request=self.request)
-
-
-@adapter_config(name='filename', context=(IExtFileContainerTarget, IPyAMSLayer, ExtFileContainerTable), provides=IColumn)
-class ExtFileContainerFilenameColumn(I18nColumn, GetAttrColumn):
-    """External file container filename column"""
-
-    _header = _("Filename")
-
-    weight = 15
-
-    def getValue(self, obj):
-        data = II18n(obj).query_attribute('data', request=self.request)
-        if data is not None:
-            return IFileInfo(data).filename
-        else:
-            return '--'
-
-
-@adapter_config(name='filesize', context=(IExtFileContainerTarget, IPyAMSLayer, ExtFileContainerTable), provides=IColumn)
-class ExtFileContainerFileSizeColumn(I18nColumn, GetAttrColumn):
-    """External file container file size column"""
-
-    _header = _("Size")
-
-    weight = 20
-
-    def getValue(self, obj):
-        data = II18n(obj).query_attribute('data', request=self.request)
-        if data is not None:
-            result = get_human_size(IFile(data).get_size())
-            if IImage.providedBy(data):
-                result = '{0} ({1[0]}x{1[1]})'.format(result, IImage(data).get_image_size())
-            return result
-        else:
-            return 'N/A'
-
-
-@adapter_config(name='trash', context=(IExtFileContainerTarget, IPyAMSLayer, ExtFileContainerTable), provides=IColumn)
-class ExtFileContainerTrashColumn(ProtectedFormObjectMixin, TrashColumn):
-    """External files container trash column"""
-
-
-@adapter_config(context=(IExtFileContainerTarget, IPyAMSLayer, ExtFileContainerTable), provides=IValues)
-class ExtFileContainerValues(ContextRequestViewAdapter):
-    """External files container values"""
-
-    @property
-    def values(self):
-        return getattr(IExtFileContainer(self.context), self.view.files_property)
-
-
-@adapter_config(context=(IExtFileContainerTarget, IPyAMSLayer, ExtFileContainerView), provides=IPageHeader)
-class ExtFileHeaderAdapter(DefaultPageHeaderAdapter):
-    """External files container header adapter"""
-
-    back_url = '#properties.html'
-    icon_class = 'fa fa-fw fa-file-text-o'
-
-
-#
-# External files links edit form
-#
-
-@pagelet_config(name='extfile-links.html', context=IExtFileLinksContainerTarget, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-class ExtFileLinksContainerLinksEditForm(AdminDialogEditForm):
-    """External file links container edit form"""
-
-    legend = _("Edit external files links")
-
-    fields = field.Fields(IExtFileLinksContainer)
-    fields['files'].widgetFactory = ExtFileLinkSelectFieldWidget
-
-    ajax_handler = 'extfile-links.json'
-    edit_permission = MANAGE_CONTENT_PERMISSION
-
-
-@view_config(name='extfile-links.json', context=IExtFileLinksContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExtFileLinksContainerAJAXEditForm(AJAXEditForm, ExtFileLinksContainerLinksEditForm):
-    """External file links container edit form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        if 'files' in changes.get(IExtFileLinksContainer, ()):
-            return {'status': 'success',
-                    'event': 'PyAMS_content.changed_item',
-                    'event_options': {'object_type': 'extfiles_container',
-                                      'object_name': self.context.__name__,
-                                      'nb_files': len(IExtFileLinksContainer(self.context).files or ())}}
-        else:
-            return super(ExtFileLinksContainerAJAXEditForm, self).get_ajax_output(changes)
+    result = []
+    target = get_parent(request.context, IAssociationTarget)
+    if target is not None:
+        container = IAssociationContainer(target)
+        result.extend([{'title': IAssociationInfo(item).user_title,
+                        'value': absolute_url(II18n(item).query_attribute('data', request=request),
+                                              request=request)}
+                       for item in container.values() if IExtImage.providedBy(item)])
+    return sorted(result, key=lambda x: x['title'])
--- a/src/pyams_content/component/extfile/zmi/templates/container.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-<div class="ams-widget">
-	<header>
-		<span tal:condition="view.widget_icon_class | nothing"
-			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
-		</span>
-		<h2 tal:content="view.title"></h2>
-		<tal:var content="structure provider:pyams.widget_title" />
-		<tal:var content="structure provider:pyams.toolbar" />
-	</header>
-	<div class="widget-body no-widget-toolbar">
-		<tal:var content="structure view.files_table.render()" />
-		<tal:var content="structure view.images_table.render()" />
-		<tal:var content="structure view.videos_table.render()" />
-		<tal:var content="structure view.audios_table.render()" />
-	</div>
-</div>
--- a/src/pyams_content/component/extfile/zmi/templates/widget-display.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-<input type="hidden" autocomplete="off" readonly
-	data-ams-select2-multiple="true"
-	tal:attributes="id view/id;
-					name view/name;
-					class string:select2 ${view/klass} ordered;
-					style view/style;
-					title view/title;
-					value python:','.join(view.value);
-					lang view/lang;
-					onclick view/onclick;
-					ondblclick view/ondblclick;
-					onmousedown view/onmousedown;
-					onmouseup view/onmouseup;
-					onmouseover view/onmouseover;
-					onmousemove view/onmousemove;
-					onmouseout view/onmouseout;
-					onkeypress view/onkeypress;
-					onkeydown view/onkeydown;
-					onkeyup view/onkeyup;
-					disabled view/disabled;
-					tabindex view/tabindex;
-					data-ams-select2-values view/values_map;" />
--- a/src/pyams_content/component/extfile/zmi/templates/widget-input.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,44 +0,0 @@
-<label class="input bordered with-icon" i18n:domain="pyams_content"
-	   data-ams-plugins="pyams_content"
-	   tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content')">
-	<i class="icon-append fa fa-plus-square txt-color-green hint opaque"
-		title="Add external file" i18n:attributes="title"
-		data-ams-url="add-extfile.html?origin=link" data-toggle="modal"
-		tal:attributes="data-ams-select2-target string:${view/name}:list"></i>
-	<div class="select2-parent">
-		<select class="select2 ordered"
-				data-ams-select2-allow-clear="true"
-				tal:attributes="id view/id;
-								name string:${view/name}:list;
-								class string:${view/klass} select2 ordered;
-								style view/style;
-								title view/title;
-								lang view/lang;
-								onclick view/onclick;
-								ondblclick view/ondblclick;
-								onmousedown view/onmousedown;
-								onmouseup view/onmouseup;
-								onmouseover view/onmouseover;
-								onmousemove view/onmousemove;
-								onmouseout view/onmouseout;
-								onkeypress view/onkeypress;
-								onkeydown view/onkeydown;
-								onkeyup view/onkeyup;
-								disabled view/disabled;
-								tabindex view/tabindex;
-								onfocus view/onfocus;
-								onblur view/onblur;
-								onchange view/onchange;
-								multiple view/multiple;
-								size view/size">
-			<option tal:repeat="entry view/selectedItems"
-					tal:attributes="value entry/value;
-									selected python:entry['value'] in view.value;"
-					tal:content="entry/content"></option>
-			<option tal:repeat="entry view/notselectedItems"
-					tal:attributes="value entry/value;
-									selected python:entry['value'] in view.value;"
-					tal:content="entry/content"></option>
-		</select>
-	</div>
-</label>
--- a/src/pyams_content/component/extfile/zmi/widget.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,42 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-import json
-
-# import interfaces
-from pyams_skin.layer import IPyAMSLayer
-
-# import packages
-from pyams_form.widget import widgettemplate_config
-from z3c.form.browser.orderedselect import OrderedSelectWidget
-from z3c.form.widget import FieldWidget
-
-
-@widgettemplate_config(mode='input', template='templates/widget-input.pt', layer=IPyAMSLayer)
-@widgettemplate_config(mode='display', template='templates/widget-display.pt', layer=IPyAMSLayer)
-class ExtFileLinksSelectWidget(OrderedSelectWidget):
-    """External files links select widget"""
-
-    @property
-    def values_map(self):
-        result = {}
-        [result.update({entry['value']: entry['content']}) for entry in self.selectedItems]
-        return json.dumps(result)
-
-
-def ExtFileLinkSelectFieldWidget(field, request):
-    """External files links select widget factory"""
-    return FieldWidget(field, ExtFileLinksSelectWidget(request))
--- a/src/pyams_content/component/gallery/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/gallery/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,93 +16,33 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.gallery.interfaces import IGalleryFileInfo, GALLERY_FILE_INFO_KEY, IGallery, IGalleryFile
+from pyams_content.component.gallery.interfaces import IGallery, IGalleryTarget, \
+    GALLERY_CONTAINER_KEY, IGalleryRenderer
 from pyams_content.shared.common.interfaces import IWfSharedContent
 from pyams_form.interfaces.form import IFormContextPermissionChecker
-from pyams_i18n.interfaces import II18n
 from zope.annotation.interfaces import IAnnotations
 from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
+from zope.location.interfaces import ISublocations
 from zope.traversing.interfaces import ITraversable
 
 # import packages
-from persistent import Persistent
-from pyams_file.property import FileProperty
+from pyams_catalog.utils import index_object
 from pyams_utils.adapter import adapter_config, ContextAdapter
 from pyams_utils.container import BTreeOrderedContainer
+from pyams_utils.request import check_request
 from pyams_utils.traversing import get_parent
+from pyams_utils.vocabulary import vocabulary_config
 from pyramid.events import subscriber
 from pyramid.threadlocal import get_current_registry
+from zope.interface import implementer
 from zope.lifecycleevent import ObjectCreatedEvent, ObjectModifiedEvent
-from zope.container.contained import Contained
-from zope.interface import implementer
 from zope.location import locate
 from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
 
 
 #
-# Gallery file
-#
-
-@implementer(IGalleryFileInfo)
-class GalleryFileInfo(Persistent, Contained):
-    """Gallery file info"""
-
-    title = FieldProperty(IGalleryFileInfo['title'])
-    description = FieldProperty(IGalleryFileInfo['description'])
-    author = FieldProperty(IGalleryFileInfo['author'])
-    author_comments = FieldProperty(IGalleryFileInfo['author_comments'])
-    sound = FileProperty(IGalleryFileInfo['sound'])
-    sound_title = FieldProperty(IGalleryFileInfo['sound_title'])
-    sound_description = FieldProperty(IGalleryFileInfo['sound_description'])
-    pif_number = FieldProperty(IGalleryFileInfo['pif_number'])
-    visible = FieldProperty(IGalleryFileInfo['visible'])
-
-    def get_title(self, request=None):
-        return II18n(self).query_attribute('title', request=request)
-
-
-@adapter_config(context=IGalleryFile, provides=IGalleryFileInfo)
-def media_gallery_info_factory(file):
-    """Gallery file gallery info factory"""
-    annotations = IAnnotations(file)
-    info = annotations.get(GALLERY_FILE_INFO_KEY)
-    if info is None:
-        info = annotations[GALLERY_FILE_INFO_KEY] = GalleryFileInfo()
-        get_current_registry().notify(ObjectCreatedEvent(info))
-        locate(info, file, '++gallery-info++')
-    return info
-
-
-@adapter_config(name='gallery-info', context=IGalleryFile, provides=ITraversable)
-class MediaGalleryInfoTraverser(ContextAdapter):
-    """Gallery file gallery info adapter"""
-
-    def traverse(self, name, furtherpath=None):
-        return IGalleryFileInfo(self.context)
-
-
-@adapter_config(context=IGalleryFile, provides=IFormContextPermissionChecker)
-class GalleryFilePermissionChecker(ContextAdapter):
-    """Gallery file permission checker"""
-
-    @property
-    def edit_permission(self):
-        content = get_parent(self.context, IWfSharedContent)
-        return IFormContextPermissionChecker(content).edit_permission
-
-
-@adapter_config(context=IGalleryFileInfo, provides=IFormContextPermissionChecker)
-class GalleryFileInfoPermissionChecker(ContextAdapter):
-    """Gallery file info permission checker"""
-
-    @property
-    def edit_permission(self):
-        content = get_parent(self.context, IWfSharedContent)
-        return IFormContextPermissionChecker(content).edit_permission
-
-
-#
-# Gallery
+# Galleries container
 #
 
 @implementer(IGallery)
@@ -111,19 +51,53 @@
 
     title = FieldProperty(IGallery['title'])
     description = FieldProperty(IGallery['description'])
-    visible = FieldProperty(IGallery['visible'])
+    renderer = FieldProperty(IGallery['renderer'])
 
     last_id = 1
 
-    def __setitem__(self, key, value):
+    def append(self, value, notify=True):
         key = str(self.last_id)
-        super(Gallery, self).__setitem__(key, value)
+        if not notify:
+            # pre-locate gallery item to avoid multiple notifications
+            locate(value, self.key)
+        self[key] = value
         self.last_id += 1
+        if not notify:
+            # make sure that gallery item is correctly indexed
+            index_object(value)
 
     def get_visible_images(self):
         return [image for image in self.values() if image.visible]
 
 
+@adapter_config(context=IGalleryTarget, provides=IGallery)
+def gallery_factory(target):
+    """Galleries container factory"""
+    annotations = IAnnotations(target)
+    gallery = annotations.get(GALLERY_CONTAINER_KEY)
+    if gallery is None:
+        gallery = annotations[GALLERY_CONTAINER_KEY] = Gallery()
+        get_current_registry().notify(ObjectCreatedEvent(gallery))
+        locate(gallery, target, '++gallery++')
+    return gallery
+
+
+@adapter_config(name='gallery', context=IGalleryTarget, provides=ITraversable)
+class GalleryContainerNamespace(ContextAdapter):
+    """++gallery++ namespace traverser"""
+
+    def traverse(self, name, furtherpath=None):
+        return IGallery(self.context)
+
+
+@adapter_config(name='gallery', context=IGalleryTarget, provides=ISublocations)
+class GalleryContainerSublocations(ContextAdapter):
+    """Galleries container sublocations"""
+
+    def sublocations(self):
+        return IGallery(self.context).values()
+
+
 @adapter_config(context=IGallery, provides=IFormContextPermissionChecker)
 class GalleryPermissionChecker(ContextAdapter):
     """Gallery permission checker"""
@@ -156,3 +130,18 @@
     content = get_parent(event.object, IWfSharedContent)
     if content is not None:
         get_current_registry().notify(ObjectModifiedEvent(content))
+
+
+@vocabulary_config(name='PyAMS gallery renderers')
+class GalleryRendererVocabulary(SimpleVocabulary):
+    """Gallery renderer utilities vocabulary"""
+
+    def __init__(self, context=None):
+        request = check_request()
+        translate = request.localizer.translate
+        registry = request.registry
+        context = Gallery()
+        terms = [SimpleTerm(name, title=translate(adapter.label))
+                 for name, adapter in sorted(registry.getAdapters((context, request), IGalleryRenderer),
+                                             key=lambda x: x[1].weight)]
+        super(GalleryRendererVocabulary, self).__init__(terms)
--- a/src/pyams_content/component/gallery/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,125 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
-# import interfaces
-from pyams_content.component.gallery.interfaces import IGalleryContainer, IGalleryContainerTarget, \
-    GALLERY_CONTAINER_KEY, IGalleryLinksContainer, IGalleryLinksContainerTarget, GALLERY_LINKS_CONTAINER_KEY
-from zope.annotation.interfaces import IAnnotations
-from zope.location.interfaces import ISublocations
-from zope.traversing.interfaces import ITraversable
-
-# import packages
-from persistent import Persistent
-from persistent.list import PersistentList
-from pyams_i18n.interfaces import II18n
-from pyams_utils.adapter import adapter_config, ContextAdapter
-from pyams_utils.traversing import get_parent
-from pyams_utils.vocabulary import vocabulary_config
-from pyramid.threadlocal import get_current_registry
-from zope.container.contained import Contained
-from zope.container.folder import Folder
-from zope.interface import implementer
-from zope.lifecycleevent import ObjectCreatedEvent
-from zope.location import locate
-from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
-
-
-#
-# Galleries container
-#
-
-@implementer(IGalleryContainer)
-class GalleryContainer(Folder):
-    """Galleries container"""
-
-    last_id = 1
-
-    def __setitem__(self, key, value):
-        key = str(self.last_id)
-        super(GalleryContainer, self).__setitem__(key, value)
-        self.last_id += 1
-
-
-@adapter_config(context=IGalleryContainerTarget, provides=IGalleryContainer)
-def gallery_container_factory(target):
-    """Galleries container factory"""
-    annotations = IAnnotations(target)
-    container = annotations.get(GALLERY_CONTAINER_KEY)
-    if container is None:
-        container = annotations[GALLERY_CONTAINER_KEY] = GalleryContainer()
-        get_current_registry().notify(ObjectCreatedEvent(container))
-        locate(container, target, '++gallery++')
-    return container
-
-
-@adapter_config(name='gallery', context=IGalleryContainerTarget, provides=ITraversable)
-class GalleryContainerNamespace(ContextAdapter):
-    """++gallery++ namespace traverser"""
-
-    def traverse(self, name, furtherpath=None):
-        return IGalleryContainer(self.context)
-
-
-@adapter_config(name='gallery', context=IGalleryContainerTarget, provides=ISublocations)
-class GalleryContainerSublocations(ContextAdapter):
-    """Galleries container sublocations"""
-
-    def sublocations(self):
-        return IGalleryContainer(self.context).values()
-
-
-@vocabulary_config(name='PyAMS content galleries')
-class GalleryContainerGalleriesVocabulary(SimpleVocabulary):
-    """Galleries container galleries vocabulary"""
-
-    def __init__(self, context):
-        target = get_parent(context, IGalleryContainerTarget)
-        terms = [SimpleTerm(gallery.__name__, title=II18n(gallery).query_attribute('title'))
-                 for gallery in IGalleryContainer(target).values()]
-        super(GalleryContainerGalleriesVocabulary, self).__init__(terms)
-
-
-#
-# Galleries links container
-#
-
-@implementer(IGalleryLinksContainer)
-class GalleryLinksContainer(Persistent, Contained):
-    """Galleries links container"""
-
-    def __init__(self):
-        self.galleries = PersistentList()
-
-
-@adapter_config(context=IGalleryLinksContainerTarget, provides=IGalleryLinksContainer)
-def gallery_links_container_factory(target):
-    """Galleries links container factory"""
-    annotations = IAnnotations(target)
-    container = annotations.get(GALLERY_LINKS_CONTAINER_KEY)
-    if container is None:
-        container = annotations[GALLERY_LINKS_CONTAINER_KEY] = GalleryLinksContainer()
-        get_current_registry().notify(ObjectCreatedEvent(container))
-        locate(container, target, '++gallery-links++')
-    return container
-
-
-@adapter_config(name='gallery-links', context=IGalleryLinksContainerTarget, provides=ITraversable)
-class GalleryLinksContainerNamespace(ContextAdapter):
-    """++gallery-links++ namespace adapter"""
-
-    def traverse(self, name, furtherpath=None):
-        return IGalleryLinksContainer(self.context)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/gallery/file.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,100 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_file.interfaces import DELETED_FILE, IResponsiveImage
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.gallery.interfaces import IGalleryFile
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_form.interfaces.form import IFormContextPermissionChecker
+from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
+
+# import packages
+from persistent import Persistent
+from pyams_file.property import FileProperty
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.traversing import get_parent
+from pyramid.events import subscriber
+from pyramid.threadlocal import get_current_registry
+from zope.lifecycleevent import ObjectModifiedEvent
+from zope.container.contained import Contained
+from zope.interface import implementer, alsoProvides
+from zope.schema.fieldproperty import FieldProperty
+
+
+#
+# Gallery file
+#
+
+@implementer(IGalleryFile)
+class GalleryFile(Persistent, Contained):
+    """Gallery file info"""
+
+    title = FieldProperty(IGalleryFile['title'])
+    alt_title = FieldProperty(IGalleryFile['alt_title'])
+    _data = FileProperty(IGalleryFile['data'])
+    description = FieldProperty(IGalleryFile['description'])
+    author = FieldProperty(IGalleryFile['author'])
+    author_comments = FieldProperty(IGalleryFile['author_comments'])
+    sound = FileProperty(IGalleryFile['sound'])
+    sound_title = FieldProperty(IGalleryFile['sound_title'])
+    sound_description = FieldProperty(IGalleryFile['sound_description'])
+    pif_number = FieldProperty(IGalleryFile['pif_number'])
+    visible = FieldProperty(IGalleryFile['visible'])
+
+    @property
+    def data(self):
+        return self._data
+
+    @data.setter
+    def data(self, value):
+        self._data = value
+        if (value is not None) and (value is not DELETED_FILE):
+            alsoProvides(self._data, IResponsiveImage)
+
+
+@adapter_config(context=IGalleryFile, provides=IFormContextPermissionChecker)
+class GalleryFilePermissionChecker(ContextAdapter):
+    """Gallery file permission checker"""
+
+    @property
+    def edit_permission(self):
+        content = get_parent(self.context, IWfSharedContent)
+        return IFormContextPermissionChecker(content).edit_permission
+
+
+@subscriber(IObjectAddedEvent, context_selector=IGalleryFile)
+def handle_added_gallery_file(event):
+    """Handle added gallery file"""
+    content = get_parent(event.object, IWfSharedContent)
+    if content is not None:
+        get_current_registry().notify(ObjectModifiedEvent(content))
+
+
+@subscriber(IObjectModifiedEvent, context_selector=IGalleryFile)
+def handle_modified_gallery_file(event):
+    """Handle modified gallery file"""
+    content = get_parent(event.object, IWfSharedContent)
+    if content is not None:
+        get_current_registry().notify(ObjectModifiedEvent(content))
+
+
+@subscriber(IObjectRemovedEvent, context_selector=IGalleryFile)
+def handle_removed_gallery_file(event):
+    """Handle removed gallery file"""
+    content = get_parent(event.object, IWfSharedContent)
+    if content is not None:
+        get_current_registry().notify(ObjectModifiedEvent(content))
--- a/src/pyams_content/component/gallery/interfaces/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/gallery/interfaces/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,35 +16,40 @@
 # import standard library
 
 # import interfaces
-from pyams_file.interfaces import IMediaFile
-from zope.container.interfaces import IContainer, IOrderedContainer
+from pyams_content.component.paragraph.interfaces import IBaseParagraph
+from zope.container.interfaces import IOrderedContainer
+from zope.contentprovider.interfaces import IContentProvider
 
 # import packages
-from pyams_file.schema import FileField
+from pyams_file.schema import FileField, ImageField
 from pyams_i18n.schema import I18nTextLineField, I18nTextField
-from pyams_utils.schema import PersistentList
 from zope.annotation.interfaces import IAttributeAnnotatable
-from zope.container.constraints import containers, contains
-from zope.interface import Interface
-from zope.schema import Choice, Bool, TextLine
+from zope.container.constraints import contains, containers
+from zope.interface import Interface, Attribute
+from zope.schema import Bool, TextLine, Choice
 
 from pyams_content import _
 
 
 GALLERY_CONTAINER_KEY = 'pyams_content.gallery'
-GALLERY_FILE_INFO_KEY = 'pyams_content.gallery.info'
-GALLERY_LINKS_CONTAINER_KEY = 'pyams_content.gallery.links'
 
 
 class IGalleryFile(Interface):
     """Gallery file marker interface"""
 
+    containers('.IGallery')
 
-class IGalleryFileInfo(Interface):
-    """Gallery file info"""
+    title = I18nTextLineField(title=_("Legend"),
+                              description=_("Image title"),
+                              required=False)
 
-    title = I18nTextLineField(title=_("Title"),
-                              required=False)
+    alt_title = I18nTextLineField(title=_("Accessibility title"),
+                                  description=_("Alternate title used to describe image content"),
+                                  required=False)
+
+    data = ImageField(title=_("Image data"),
+                      description=_("Image content"),
+                      required=True)
 
     description = I18nTextField(title=_("Description"),
                                 required=False)
@@ -81,8 +86,6 @@
 class IBaseGallery(IOrderedContainer, IAttributeAnnotatable):
     """Base gallery interface"""
 
-    containers('.IGalleryContainer')
-
     title = I18nTextLineField(title=_("Title"),
                               description=_("Gallery title, as shown in front-office"),
                               required=True)
@@ -91,10 +94,11 @@
                                 description=_("Gallery description displayed by front-office template"),
                                 required=False)
 
-    visible = Bool(title=_("Visible gallery?"),
-                   description=_("If 'no', this gallery won't be displayed in front office"),
-                   required=True,
-                   default=True)
+    renderer = Choice(title=_("Gallery style"),
+                      vocabulary='PyAMS gallery renderers')
+
+    def append(self, value, notify=True):
+        """Append new file to gallery"""
 
     def get_visible_images(self):
         """Get iterator over visible images"""
@@ -103,27 +107,18 @@
 class IGallery(IBaseGallery):
     """Gallery interface"""
 
-    contains(IMediaFile)
-
-
-class IGalleryContainer(IContainer):
-    """Galleries container"""
-
-    contains(IBaseGallery)
+    contains(IGalleryFile)
 
 
-class IGalleryContainerTarget(Interface):
-    """Galleries container marker interface"""
+class IGalleryRenderer(IContentProvider):
+    """Gallery renderer utility interface"""
+
+    label = Attribute("Renderer label")
 
 
-class IGalleryLinksContainer(Interface):
-    """Galleries links container interface"""
-
-    galleries = PersistentList(title=_("Contained galleries"),
-                               description=_("List of images galleries linked to this object"),
-                               value_type=Choice(vocabulary="PyAMS content galleries"),
-                               required=False)
+class IGalleryTarget(IAttributeAnnotatable):
+    """Gallery container target marker interface"""
 
 
-class IGalleryLinksContainerTarget(Interface):
-    """Galleries links container marker interface"""
+class IGalleryParagraph(IGallery, IBaseParagraph):
+    """Gallery paragraph"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/gallery/paragraph.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,44 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.gallery.interfaces import IGalleryParagraph
+from pyams_content.component.paragraph.interfaces import IParagraphFactory
+
+# import packages
+from pyams_content.component.gallery import Gallery as BaseGallery
+from pyams_content.component.paragraph import BaseParagraph
+from pyams_utils.registry import utility_config
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+@implementer(IGalleryParagraph)
+class Gallery(BaseGallery, BaseParagraph):
+    """Gallery class"""
+
+    icon_class = 'fa-picture-o'
+    icon_hint = _("Images gallery")
+
+
+@utility_config(name='gallery', provides=IParagraphFactory)
+class GalleryFactory(object):
+    """Gallery paragraph factory"""
+
+    name = _("Images gallery")
+    content_type = Gallery
--- a/src/pyams_content/component/gallery/zmi/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/gallery/zmi/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -14,145 +14,37 @@
 
 
 # import standard library
+import json
 
 # import interfaces
-from pyams_content.component.gallery.interfaces import IGalleryContainerTarget, IGallery, IGalleryContainer, \
-    IGalleryFile, IGalleryFileInfo
-from pyams_content.component.gallery.zmi.interfaces import IGalleryImageAddFields
+from pyams_content.component.gallery.interfaces import IGallery, IGalleryRenderer
+from pyams_content.component.gallery.zmi.interfaces import IGalleryImageAddFields, IGalleryImagesView
 from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_file.interfaces.archive import IArchiveExtractor
+from pyams_form.interfaces.form import IWidgetsPrefixViewletsManager
 from pyams_i18n.interfaces import II18n
-from pyams_skin.interfaces.viewlet import IWidgetTitleViewletManager
 from pyams_skin.layer import IPyAMSLayer
 from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
-from z3c.form.interfaces import NOT_CHANGED
 
 # import packages
-from pyams_content.component.gallery import Gallery
-from pyams_content.component.gallery.zmi.container import GalleryContainerView
-from pyams_file.file import get_magic_content_type, FileFactory
-from pyams_form.form import AJAXAddForm, AJAXEditForm
-from pyams_form.security import ProtectedFormObjectMixin
+from pyams_content.shared.common.zmi import WfSharedContentPermissionMixin
+from pyams_form.form import AJAXEditForm
 from pyams_pagelet.pagelet import pagelet_config
-from pyams_skin.viewlet.toolbar import ToolbarAction
-from pyams_utils.registry import query_utility
-from pyams_utils.traversing import get_parent
-from pyams_viewlet.viewlet import viewlet_config
-from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyams_template.template import template_config, get_view_template
+from pyams_utils.adapter import adapter_config, ContextRequestAdapter
+from pyams_viewlet.viewlet import viewlet_config, Viewlet
+from pyams_zmi.form import AdminDialogEditForm, AdminDialogDisplayForm
+from pyramid.exceptions import NotFound
+from pyramid.renderers import render_to_response
 from pyramid.view import view_config
 from z3c.form import field
-from zope.interface import alsoProvides
-from zope.lifecycleevent import ObjectCreatedEvent
+from zope.interface import implementer, Interface
 
 from pyams_content import _
 
 
-@viewlet_config(name='add-gallery.menu', context=IGalleryContainerTarget, view=GalleryContainerView,
-                layer=IPyAMSLayer, manager=IWidgetTitleViewletManager, weight=50)
-class GalleryAddMenu(ProtectedFormObjectMixin, ToolbarAction):
-    """Gallery add menu"""
-
-    label = _("Add gallery")
-
-    url = 'add-gallery.html'
-    modal_target = True
-
-
-@pagelet_config(name='add-gallery.html', context=IGalleryContainerTarget, layer=IPyAMSLayer,
-                permission=MANAGE_CONTENT_PERMISSION)
-class GalleryAddForm(AdminDialogAddForm):
-    """Gallery add form"""
-
-    legend = _("Add new images gallery")
-    icon_css_class = 'fa fa-fw fa-picture-o'
-
-    fields = field.Fields(IGallery).omit('__parent__', '__name__') + \
-             field.Fields(IGalleryImageAddFields)
-
-    @property
-    def ajax_handler(self):
-        origin = self.request.params.get('origin')
-        if origin == 'link':
-            return 'add-gallery-link.json'
-        else:
-            return 'add-gallery.json'
-
-    edit_permission = MANAGE_CONTENT_PERMISSION
-
-    def updateWidgets(self, prefix=None):
-        super(GalleryAddForm, self).updateWidgets(prefix)
-        if 'description' in self.widgets:
-            self.widgets['description'].widget_css_class = 'textarea'
-        if 'author_comments' in self.widgets:
-            self.widgets['author_comments'].widget_css_class = 'textarea'
-
-    def create(self, data):
-        gallery = Gallery()
-        images = data['images_data']
-        if images and (images is not NOT_CHANGED):
-            medias = []
-            if isinstance(images, (list, tuple)):
-                images = images[1]
-            if hasattr(images, 'seek'):
-                images.seek(0)
-            registry = self.request.registry
-            content_type = get_magic_content_type(images)
-            if hasattr(images, 'seek'):
-                images.seek(0)
-            extractor = query_utility(IArchiveExtractor, name=content_type.decode())
-            if extractor is not None:
-                extractor.initialize(images)
-                for content, filename in extractor.get_contents():
-                    media = FileFactory(content)
-                    registry.notify(ObjectCreatedEvent(media))
-                    medias.append(media)
-            else:
-                media = FileFactory(images)
-                registry.notify(ObjectCreatedEvent(media))
-                medias.append(media)
-            for media in medias:
-                alsoProvides(media, IGalleryFile)
-                IGalleryFileInfo(media).author = data.get('author')
-                IGalleryFileInfo(media).author_comments = data.get('author_comments')
-                gallery['none'] = media
-        return gallery
-
-    def update_content(self, content, data):
-        content.title = data.get('title')
-        content.description = data.get('description')
-        content.visible = data.get('visible')
-
-    def add(self, object):
-        IGalleryContainer(self.context)['none'] = object
-
-
-@view_config(name='add-gallery.json', context=IGalleryContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class GalleryAJAXAddForm(AJAXAddForm, GalleryAddForm):
-    """Gallery add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': '#galleries.html'}
-
-
-@view_config(name='add-gallery-link.json', context=IGalleryContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class GalleryLinkAJAXAddForm(AJAXAddForm, GalleryAddForm):
-    """Gallery link add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        target = get_parent(self.context, IGalleryContainerTarget)
-        container = IGalleryContainer(target)
-        galleries = [{'id': gallery.__name__,
-                      'text': II18n(gallery).query_attribute('title', request=self.request)}
-                     for gallery in container.values()]
-        return {'status': 'callback',
-                'callback': 'PyAMS_content.galleries.refresh',
-                'options': {'galleries': galleries,
-                            'new_gallery': {'id': changes.__name__,
-                                            'text': II18n(changes).query_attribute('title', request=self.request)}}}
-
+#
+# Gallery properties
+#
 
 @pagelet_config(name='properties.html', context=IGallery, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
 class GalleryPropertiesEditForm(AdminDialogEditForm):
@@ -167,7 +59,7 @@
 
     def updateWidgets(self, prefix=None):
         super(GalleryPropertiesEditForm, self).updateWidgets(prefix)
-        if 'description' in self.comments:
+        if 'description' in self.widgets:
             self.widgets['description'].widget_css_class = 'textarea'
 
 
@@ -182,3 +74,89 @@
                     'location': '#external-files.html'}
         else:
             return super(GalleryPropertiesAJAXEditForm, self).get_ajax_output(changes)
+
+
+#
+# Gallery contents
+#
+
+@pagelet_config(name='contents.html', context=IGallery, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
+@implementer(IGalleryImagesView)
+class GalleryContentForm(AdminDialogDisplayForm):
+    """Gallery contents form"""
+
+    legend = _("Update gallery contents")
+    dialog_class = 'modal-max'
+
+    fields = field.Fields(Interface)
+    show_widget_title = True
+
+
+@viewlet_config(name='gallery-images', context=IGallery, view=IGalleryImagesView, manager=IWidgetsPrefixViewletsManager)
+@template_config(template='templates/gallery-images.pt', layer=IPyAMSLayer)
+@implementer(IGalleryImagesView)
+class GalleryImagesViewlet(Viewlet):
+    """Gallery images viewlet"""
+
+    def get_title(self, image):
+        return II18n(image).query_attribute('title', request=self.request)
+
+
+@view_config(name='get-gallery-images.html', context=IGallery, request_type=IPyAMSLayer,
+             permission=VIEW_SYSTEM_PERMISSION)
+@implementer(IGalleryImagesView)
+class GalleryImagesView(WfSharedContentPermissionMixin):
+    """Gallery images view"""
+
+    def __init__(self, context, request):
+        self.context = context
+        self.request = request
+
+    def __call__(self):
+        return render_to_response('templates/gallery-images.pt', {'view': self}, request=self.request)
+
+    def get_title(self, image):
+        return II18n(image).query_attribute('title', request=self.request)
+
+
+@view_config(name='set-images-order.json', context=IGallery, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+def set_images_order(request):
+    """Set gallery images order"""
+    images_names = json.loads(request.params.get('images'))
+    request.context.updateOrder(images_names)
+    return {'status': 'success'}
+
+
+@view_config(name='set-image-visibility.json', context=IGallery, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+def set_image_visibility(request):
+    """Set gallery image visibility"""
+    gallery = IGallery(request.context)
+    image = gallery.get(str(request.params.get('object_name')))
+    if image is None:
+        raise NotFound()
+    image.visible = not image.visible
+    return {'visible': image.visible}
+
+
+#
+# Gallery renderers
+#
+
+class BaseGalleryRenderer(ContextRequestAdapter):
+    """Base gallery renderer"""
+
+    def update(self):
+        pass
+
+    render = get_view_template()
+
+
+@adapter_config(name='default', context=(IGallery, IPyAMSLayer), provides=IGalleryRenderer)
+@template_config(template='templates/renderer-default.pt', layer=IPyAMSLayer)
+class DefaultGalleryRenderer(BaseGalleryRenderer):
+    """Default gallery renderer"""
+
+    label = _("Default gallery renderer")
+    weight = 1
--- a/src/pyams_content/component/gallery/zmi/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,208 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
-# import interfaces
-from pyams_content.component.gallery.interfaces import IGalleryContainerTarget, IGalleryContainer, \
-    IGalleryLinksContainerTarget, IGalleryLinksContainer
-from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_i18n.interfaces import II18n
-from pyams_skin.interfaces import IInnerPage, IPageHeader
-from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
-from pyams_utils.interfaces.data import IObjectData
-from pyams_zmi.interfaces.menu import IPropertiesMenu
-from pyams_zmi.layer import IAdminLayer
-from z3c.table.interfaces import IColumn, IValues
-
-# import packages
-from pyams_content.component.gallery.zmi.widget import GalleryLinkSelectFieldWidget
-from pyams_content.shared.common.zmi import WfModifiedContentColumnMixin
-from pyams_form.form import AJAXEditForm
-from pyams_form.security import ProtectedFormObjectMixin
-from pyams_pagelet.pagelet import pagelet_config
-from pyams_skin.layer import IPyAMSLayer
-from pyams_skin.page import DefaultPageHeaderAdapter
-from pyams_skin.table import BaseTable, I18nColumn, TrashColumn, ActionColumn
-from pyams_skin.viewlet.menu import MenuItem
-from pyams_template.template import template_config
-from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
-from pyams_utils.url import absolute_url
-from pyams_viewlet.viewlet import viewlet_config
-from pyams_zmi.form import AdminDialogEditForm
-from pyams_zmi.view import AdminView
-from pyramid.decorator import reify
-from pyramid.view import view_config
-from z3c.table.column import GetAttrColumn
-from z3c.form import field
-from zope.interface import implementer, alsoProvides
-
-from pyams_content import _
-
-
-@viewlet_config(name='galleries.menu', context=IGalleryContainerTarget, layer=IAdminLayer,
-                manager=IPropertiesMenu, permission=VIEW_SYSTEM_PERMISSION, weight=220)
-class GalleryContainerMenu(MenuItem):
-    """Galleries container menu"""
-
-    label = _("Images galleries...")
-    icon_class = 'fa-picture-o'
-    url = '#galleries.html'
-
-
-#
-# Galleries container views
-#
-
-@pagelet_config(name='galleries.html', context=IGalleryContainerTarget, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-@template_config(template='templates/container.pt', layer=IPyAMSLayer)
-@implementer(IInnerPage)
-class GalleryContainerView(AdminView):
-    """Galleries container view"""
-
-    title = _("Galleries list")
-
-    def __init__(self, context, request):
-        super(GalleryContainerView, self).__init__(context, request)
-        self.galleries_table = GalleryContainerTable(context, request)
-
-    def update(self):
-        super(GalleryContainerView, self).update()
-        self.galleries_table.update()
-
-
-class GalleryContainerTable(BaseTable):
-    """Galleries container table"""
-
-    hide_header = True
-    cssClasses = {'table': 'table table-bordered table-striped table-hover table-tight'}
-
-    def __init__(self, context, request):
-        super(GalleryContainerTable, self).__init__(context, request)
-        self.object_data = {'ams-widget-toggle-button': 'false'}
-        alsoProvides(self, IObjectData)
-
-    @property
-    def data_attributes(self):
-        attributes = super(GalleryContainerTable, self).data_attributes
-        attributes['table'] = {'data-ams-location': absolute_url(IGalleryContainer(self.context), self.request),
-                               'data-ams-datatable-sort': 'false',
-                               'data-ams-datatable-pagination': 'false'}
-        return attributes
-
-    @reify
-    def values(self):
-        return list(super(GalleryContainerTable, self).values)
-
-    def render(self):
-        if not self.values:
-            translate = self.request.localizer.translate
-            return translate(_("No currently defined gallery."))
-        return super(GalleryContainerTable, self).render()
-
-
-@adapter_config(name='manage', context=(IGalleryContainerTarget, IPyAMSLayer, GalleryContainerTable), provides=IColumn)
-class GalleryContainerManageColumn(ActionColumn):
-    """Gallery container manage column"""
-
-    icon_class = 'fa fa-fw fa-camera'
-    icon_hint = _("Display gallery contents")
-
-    url = 'contents.html'
-    target = None
-    modal_target = True
-
-    weight = 5
-
-
-@adapter_config(name='name', context=(IGalleryContainerTarget, IPyAMSLayer, GalleryContainerTable), provides=IColumn)
-class GalleryContainerNameColumn(I18nColumn, WfModifiedContentColumnMixin, GetAttrColumn):
-    """Galleries container name column"""
-
-    _header = _("Title")
-
-    weight = 10
-
-    def getValue(self, obj):
-        return II18n(obj).query_attribute('title', request=self.request)
-
-
-@adapter_config(name='count', context=(IGalleryContainerTarget, IPyAMSLayer, GalleryContainerTable), provides=IColumn)
-class GalleryContainerCountColumn(I18nColumn, GetAttrColumn):
-    """Gallery container images counter column"""
-
-    _header = _("Images")
-
-    weight = 20
-
-    def getValue(self, obj):
-        return len(obj)
-
-
-@adapter_config(name='trash', context=(IGalleryContainerTarget, IPyAMSLayer, GalleryContainerTable), provides=IColumn)
-class GalleryContainerTrashColumn(ProtectedFormObjectMixin, TrashColumn):
-    """Galleries container trash column"""
-
-
-@adapter_config(context=(IGalleryContainerTarget, IPyAMSLayer, GalleryContainerTable), provides=IValues)
-class GalleryContainerValues(ContextRequestViewAdapter):
-    """Galleries container values"""
-
-    @property
-    def values(self):
-        return IGalleryContainer(self.context).values()
-
-
-@adapter_config(context=(IGalleryContainerTarget, IPyAMSLayer, GalleryContainerView), provides=IPageHeader)
-class GalleryHeaderAdapter(DefaultPageHeaderAdapter):
-    """Galleries container header adapter"""
-
-    back_url = '#properties.html'
-    icon_class = 'fa fa-fw fa-picture-o'
-
-
-#
-# Galleries links edit form
-#
-
-@pagelet_config(name='gallery-links.html', context=IGalleryLinksContainerTarget, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-class GalleryLinksContainerLinksEditForm(AdminDialogEditForm):
-    """Galleries links container edit form"""
-
-    legend = _("Edit galleries links")
-
-    fields = field.Fields(IGalleryLinksContainer)
-    fields['galleries'].widgetFactory = GalleryLinkSelectFieldWidget
-
-    ajax_handler = 'gallery-links.json'
-    edit_permission = MANAGE_CONTENT_PERMISSION
-
-
-@view_config(name='gallery-links.json', context=IGalleryLinksContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class GalleryLinksContainerAJAXEditForm(AJAXEditForm, GalleryLinksContainerLinksEditForm):
-    """Galleries links container edit form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        if 'galleries' in changes.get(IGalleryLinksContainer, ()):
-            return {'status': 'success',
-                    'event': 'PyAMS_content.changed_item',
-                    'event_options': {'object_type': 'galleries_container',
-                                      'object_name': self.context.__name__,
-                                      'nb_galleries': len(IGalleryLinksContainer(self.context).galleries or ())}}
-        else:
-            return super(GalleryLinksContainerAJAXEditForm, self).get_ajax_output(changes)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/gallery/zmi/file.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,231 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_utils.traversing import get_parent
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.gallery.interfaces import IGallery, IGalleryFile
+from pyams_content.component.gallery.zmi.interfaces import IGalleryImageAddFields, IGalleryImagesView
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_file.interfaces.archive import IArchiveExtractor
+from pyams_i18n.interfaces import II18n
+from pyams_skin.interfaces.viewlet import IWidgetTitleViewletManager, IContextActions
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION, VIEW_PERMISSION
+from z3c.form.interfaces import NOT_CHANGED
+from zope.schema.interfaces import WrongType
+
+# import packages
+from pyams_content.component.gallery.file import GalleryFile
+from pyams_content.shared.common.zmi import WfSharedContentPermissionMixin
+from pyams_file.file import get_magic_content_type
+from pyams_file.zmi.file import FilePropertiesAction
+from pyams_form.form import AJAXAddForm, AJAXEditForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarAction, JsToolbarActionItem
+from pyams_utils.registry import query_utility
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogEditForm, AdminDialogAddForm
+from pyramid.view import view_config
+from z3c.form import field
+from zope.lifecycleevent import ObjectCreatedEvent
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-image.menu', context=IGallery, view=IGalleryImagesView, manager=IWidgetTitleViewletManager)
+class GalleryImageAddMenu(WfSharedContentPermissionMixin, ToolbarAction):
+    """Gallery image add menu"""
+
+    label = _("Add image(s)")
+
+    url = 'add-image.html'
+    modal_target = True
+    stop_propagation = True
+
+
+@pagelet_config(name='add-image.html', context=IGallery, layer=IPyAMSLayer, permission=MANAGE_CONTENT_PERMISSION)
+class GalleryImageAddForm(AdminDialogAddForm):
+    """Gallery image add form"""
+
+    legend = _("Add image(s)")
+    icon_css_class = 'fa -fa-fw fa-picture-o'
+
+    fields = field.Fields(IGalleryImageAddFields)
+    ajax_handler = 'add-image.json'
+
+    def updateWidgets(self, prefix=None):
+        super(GalleryImageAddForm, self).updateWidgets(prefix)
+        if 'author_comments' in self.widgets:
+            self.widgets['author_comments'].widget_css_class = 'textarea'
+
+    def create(self, data):
+        medias = []
+        images = data['images_data']
+        if images and (images is not NOT_CHANGED):
+            filename = None
+            if isinstance(images, (list, tuple)):
+                filename, images = images
+            if hasattr(images, 'seek'):
+                images.seek(0)
+            registry = self.request.registry
+            content_type = get_magic_content_type(images)
+            if isinstance(content_type, bytes):
+                content_type = content_type.decode()
+            if hasattr(images, 'seek'):
+                images.seek(0)
+            extractor = query_utility(IArchiveExtractor, name=content_type)
+            if extractor is not None:
+                extractor.initialize(images)
+                for content, filename in extractor.get_contents():
+                    try:
+                        media = GalleryFile()
+                        media.data = filename, content
+                    except WrongType:
+                        continue
+                    else:
+                        registry.notify(ObjectCreatedEvent(media))
+                        medias.append(media)
+            else:
+                try:
+                    media = GalleryFile()
+                    media.data = filename, images if filename else images
+                except WrongType:
+                    pass
+                else:
+                    registry.notify(ObjectCreatedEvent(media))
+                    medias.append(media)
+            for media in medias:
+                media.author = data.get('author')
+                media.author_comments = data.get('author_comments')
+                self.context.append(media)
+        return None
+
+
+@view_config(name='add-image.json', context=IGallery, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class GalleryImageAJAXAddForm(AJAXAddForm, GalleryImageAddForm):
+    """Gallery image add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'reload',
+                'location': absolute_url(self.context, self.request, 'get-gallery-images.html'),
+                'target': '#gallery_images_{0}'.format(self.context.__name__)}
+
+
+@viewlet_config(name='file.showhide.action', context=IGalleryFile, layer=IPyAMSLayer, view=IGalleryImagesView,
+                manager=IContextActions, permission=VIEW_SYSTEM_PERMISSION, weight=1)
+class GalleryFileShowAddAction(JsToolbarActionItem):
+    """Gallery file show/hide action"""
+
+    label = _("Show/hide image")
+
+    @property
+    def label_css_class(self):
+        if self.context.visible:
+            return 'fa fa-fw fa-eye'
+        else:
+            return 'fa fa-fw fa-eye-slash text-danger'
+
+    hint_gravity = 'nw'
+
+    url = 'PyAMS_content.galleries.switchImageVisibility'
+
+
+@viewlet_config(name='file.properties.action', context=IGalleryFile, layer=IPyAMSLayer, view=IGalleryImagesView,
+                manager=IContextActions, permission=VIEW_SYSTEM_PERMISSION, weight=5)
+class GalleryFilePropertiesAction(FilePropertiesAction):
+    """Media properties action"""
+
+    url = 'gallery-file-properties.html'
+
+
+@pagelet_config(name='gallery-file-properties.html', context=IGalleryFile, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+class GalleryFilePropertiesEditForm(AdminDialogEditForm):
+    """Gallery file properties edit form"""
+
+    legend = _("Update image properties")
+    icon_css_class = 'fa fa-fw fa-picture-o'
+    dialog_class = 'modal-large'
+
+    fields = field.Fields(IGalleryFile).omit('__parent__', '__name__', 'visible')
+    ajax_handler = 'gallery-file-properties.json'
+
+    @property
+    def title(self):
+        gallery = get_parent(self.context, IGallery)
+        return II18n(gallery).query_attribute('title', request=self.request)
+
+    def updateWidgets(self, prefix=None):
+        super(GalleryFilePropertiesEditForm, self).updateWidgets(prefix)
+        if 'description' in self.widgets:
+            self.widgets['description'].widget_css_class = 'textarea'
+        if 'author_comments' in self.widgets:
+            self.widgets['author_comments'].widget_css_class = 'textarea'
+        if 'sound_description' in self.widgets:
+            self.widgets['sound_description'].widget_css_class = 'textarea'
+
+
+@view_config(name='gallery-file-properties.json', context=IGalleryFile, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class GalleryFileInfoPropertiesAJAXEditForm(AJAXEditForm, GalleryFilePropertiesEditForm):
+    """Gallery file properties edit form, JSON renderer"""
+
+
+@viewlet_config(name='gallery-file-download.action', context=IGalleryFile, layer=IPyAMSLayer, view=IGalleryImagesView,
+                manager=IContextActions, permission=VIEW_PERMISSION, weight=89)
+class GalleryFileDownloaderAction(JsToolbarActionItem):
+    """Gallery file downloader action"""
+
+    label = _("Download image...")
+    label_css_class = 'fa fa-fw fa-download'
+    hint_gravity = 'nw'
+
+    @property
+    def url(self):
+        return absolute_url(self.context.data, self.request, query={'download': '1'})
+
+
+@viewlet_config(name='gallery-file-remover.action', context=IGalleryFile, layer=IPyAMSLayer, view=IGalleryImagesView,
+                manager=IContextActions, weight=90)
+class GalleryFileRemoverAction(WfSharedContentPermissionMixin, JsToolbarActionItem):
+    """Gallery file remover action"""
+
+    label = _("Remove image...")
+    label_css_class = 'fa fa-fw fa-trash'
+    hint_gravity = 'nw'
+
+    url = 'PyAMS_content.galleries.removeFile'
+
+
+@view_config(name='delete-element.json', context=IGallery, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+def delete_gallery_element(request):
+    """Delete gallery element"""
+    translate = request.localizer.translate
+    name = request.params.get('object_name')
+    if not name:
+        return {'status': 'message',
+                'messagebox': {'status': 'error',
+                               'content': translate(_("No provided object_name argument!"))}}
+    if name not in request.context:
+        return {'status': 'message',
+                'messagebox': {'status': 'error',
+                               'content': translate(_("Given image name doesn't exist!"))}}
+    del request.context[name]
+    return {'status': 'success'}
--- a/src/pyams_content/component/gallery/zmi/gallery.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,245 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-import json
-
-# import interfaces
-from pyams_content.component.gallery.interfaces import IGallery, IGalleryFileInfo, IGalleryFile
-from pyams_content.component.gallery.zmi.interfaces import IGalleryImageAddFields
-from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_file.interfaces.archive import IArchiveExtractor
-from pyams_form.interfaces.form import IWidgetsPrefixViewletsManager
-from pyams_i18n.interfaces import II18n
-from pyams_skin.interfaces.viewlet import IWidgetTitleViewletManager, IContextActions
-from pyams_skin.layer import IPyAMSLayer
-from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
-from z3c.form.interfaces import NOT_CHANGED
-
-# import packages
-from pyams_content.shared.common.zmi import WfSharedContentPermissionMixin
-from pyams_file.file import get_magic_content_type, FileFactory
-from pyams_file.zmi.file import FilePropertiesAction
-from pyams_form.form import AJAXAddForm, AJAXEditForm
-from pyams_pagelet.pagelet import pagelet_config
-from pyams_skin.viewlet.toolbar import ToolbarAction, ToolbarMenuDivider, JsToolbarMenuItem
-from pyams_template.template import template_config
-from pyams_utils.registry import query_utility
-from pyams_utils.url import absolute_url
-from pyams_viewlet.viewlet import viewlet_config, Viewlet
-from pyams_zmi.form import AdminDialogEditForm, AdminDialogAddForm, AdminDialogDisplayForm
-from pyramid.renderers import render_to_response
-from pyramid.view import view_config
-from z3c.form import field
-from zope.interface import alsoProvides, Interface
-from zope.lifecycleevent import ObjectCreatedEvent
-
-from pyams_content import _
-
-
-@pagelet_config(name='contents.html', context=IGallery, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
-class GalleryContentForm(AdminDialogDisplayForm):
-    """Gallery contents form"""
-
-    legend = _("Update gallery contents")
-    dialog_class = 'modal-max'
-
-    fields = field.Fields(Interface)
-    show_widget_title = True
-
-
-@viewlet_config(name='add-image.menu', context=IGallery, view=GalleryContentForm, manager=IWidgetTitleViewletManager)
-class GalleryImageAddMenu(WfSharedContentPermissionMixin, ToolbarAction):
-    """Gallery image add menu"""
-
-    label = _("Add image(s)")
-
-    url = 'add-image.html'
-    modal_target = True
-    stop_propagation = True
-
-
-@pagelet_config(name='add-image.html', context=IGallery, layer=IPyAMSLayer, permission=MANAGE_CONTENT_PERMISSION)
-class GalleryImageAddForm(AdminDialogAddForm):
-    """Gallery image add form"""
-
-    legend = _("Add image(s)")
-    icon_css_class = 'fa -fa-fw fa-picture-o'
-
-    fields = field.Fields(IGalleryImageAddFields)
-    ajax_handler = 'add-image.json'
-
-    def updateWidgets(self, prefix=None):
-        super(GalleryImageAddForm, self).updateWidgets(prefix)
-        if 'author_comments' in self.widgets:
-            self.widgets['author_comments'].widget_css_class = 'textarea'
-
-    def create(self, data):
-        medias = []
-        images = data['images_data']
-        if images and (images is not NOT_CHANGED):
-            if isinstance(images, (list, tuple)):
-                images = images[1]
-            if hasattr(images, 'seek'):
-                images.seek(0)
-            registry = self.request.registry
-            content_type = get_magic_content_type(images)
-            if hasattr(images, 'seek'):
-                images.seek(0)
-            extractor = query_utility(IArchiveExtractor, name=content_type.decode())
-            if extractor is not None:
-                extractor.initialize(images)
-                for content, filename in extractor.get_contents():
-                    media = FileFactory(content)
-                    registry.notify(ObjectCreatedEvent(media))
-                    medias.append(media)
-            else:
-                media = FileFactory(images)
-                registry.notify(ObjectCreatedEvent(media))
-                medias.append(media)
-            for media in medias:
-                alsoProvides(media, IGalleryFile)
-                IGalleryFileInfo(media).author = data.get('author')
-                IGalleryFileInfo(media).author_comments = data.get('author_comments')
-                self.context['none'] = media
-        return None
-
-
-@view_config(name='add-image.json', context=IGallery, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class GalleryImageAJAXAddForm(AJAXAddForm, GalleryImageAddForm):
-    """Gallery image add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': absolute_url(self.context, self.request, 'get-gallery-images.html'),
-                'target': '#gallery-images'}
-
-
-@viewlet_config(name='gallery-images', context=IGallery, view=GalleryContentForm, manager=IWidgetsPrefixViewletsManager)
-@template_config(template='templates/gallery-images.pt', layer=IPyAMSLayer)
-class GalleryImagesViewlet(Viewlet):
-    """Gallery images viewlet"""
-
-    def get_info(self, image):
-        return IGalleryFileInfo(image)
-
-    def get_title(self, image):
-        return II18n(IGalleryFileInfo(image)).query_attribute('title', request=self.request)
-
-
-@view_config(name='get-gallery-images.html', context=IGallery, request_type=IPyAMSLayer,
-             permission=VIEW_SYSTEM_PERMISSION)
-class GalleryImagesView(WfSharedContentPermissionMixin):
-    """Gallery images view"""
-
-    def __init__(self, context, request):
-        self.context = context
-        self.request = request
-
-    def __call__(self):
-        return render_to_response('templates/gallery-images.pt', {'view': self}, request=self.request)
-
-    def get_info(self, image):
-        return IGalleryFileInfo(image)
-
-    def get_title(self, image):
-        return II18n(IGalleryFileInfo(image)).query_attribute('title', request=self.request)
-
-
-@view_config(name='set-images-order.json', context=IGallery, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-def set_images_order(request):
-    """Set gallery images order"""
-    images_names = json.loads(request.params.get('images'))
-    request.context.updateOrder(images_names)
-    return {'status': 'success'}
-
-
-@viewlet_config(name='file.properties.action', context=IGalleryFile, layer=IPyAMSLayer, view=Interface,
-                manager=IContextActions, permission=VIEW_SYSTEM_PERMISSION, weight=1)
-class GalleryFilePropertiesAction(FilePropertiesAction):
-    """Media properties action"""
-
-    url = 'gallery-file-properties.html'
-
-
-@pagelet_config(name='gallery-file-properties.html', context=IGalleryFile, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-class GalleryFilePropertiesEditForm(AdminDialogEditForm):
-    """Gallery file properties edit form"""
-
-    legend = _("Update image properties")
-    icon_css_class = 'fa fa-fw fa-edit'
-
-    fields = field.Fields(IGalleryFileInfo)
-    ajax_handler = 'gallery-file-properties.json'
-
-    def getContent(self):
-        return IGalleryFileInfo(self.context)
-
-    @property
-    def title(self):
-        return II18n(self.getContent()).query_attribute('title', request=self.request)
-
-    def updateWidgets(self, prefix=None):
-        super(GalleryFilePropertiesEditForm, self).updateWidgets(prefix)
-        if 'description' in self.widgets:
-            self.widgets['description'].widget_css_class = 'textarea'
-        if 'author_comments' in self.widgets:
-            self.widgets['author_comments'].widget_css_class = 'textarea'
-        if 'sound_description' in self.widgets:
-            self.widgets['sound_description'].widget_css_class = 'textarea'
-
-
-@view_config(name='gallery-file-properties.json', context=IGalleryFile, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class GalleryFileInfoPropertiesAJAXEditForm(AJAXEditForm, GalleryFilePropertiesEditForm):
-    """Gallery file properties edit form, JSON renderer"""
-
-
-@viewlet_config(name='gallery-file-remover.divider', context=IGalleryFile, layer=IPyAMSLayer, view=Interface,
-                manager=IContextActions, weight=89)
-class GalleryFileRemoverDivider(WfSharedContentPermissionMixin, ToolbarMenuDivider):
-    """Gallery file remover divider"""
-
-
-@viewlet_config(name='gallery-file-remover.action', context=IGalleryFile, layer=IPyAMSLayer, view=Interface,
-                manager=IContextActions, weight=90)
-class GalleryFileRemoverAction(WfSharedContentPermissionMixin, JsToolbarMenuItem):
-    """Gallery file remover action"""
-
-    label = _("Remove image...")
-    label_css_class = 'fa fa-fw fa-trash'
-
-    url = 'PyAMS_content.galleries.removeFile'
-
-
-@view_config(name='delete-element.json', context=IGallery, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-def delete_gallery_element(request):
-    """Delete gallery element"""
-    translate = request.localizer.translate
-    name = request.params.get('object_name')
-    if not name:
-        return {'status': 'message',
-                'messagebox': {'status': 'error',
-                               'content': translate(_("No provided object_name argument!"))}}
-    if name not in request.context:
-        return {'status': 'message',
-                'messagebox': {'status': 'error',
-                               'content': translate(_("Given image name doesn't exist!"))}}
-    del request.context[name]
-    return {'status': 'success'}
--- a/src/pyams_content/component/gallery/zmi/interfaces.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/gallery/zmi/interfaces.py	Mon Sep 11 14:54:30 2017 +0200
@@ -18,7 +18,7 @@
 # import interfaces
 
 # import packages
-from pyams_file.schema import FileField
+from pyams_file.schema import ImageField
 from pyams_i18n.schema import I18nTextField
 from zope.interface import Interface
 from zope.schema import TextLine
@@ -26,6 +26,10 @@
 from pyams_content import _
 
 
+class IGalleryImagesView(Interface):
+    """Marker interface for gallery contents view"""
+
+
 class IGalleryImageAddFields(Interface):
     """Gallery image add fields"""
 
@@ -36,6 +40,6 @@
                                     description=_("Comments relatives to author's rights management"),
                                     required=False)
 
-    images_data = FileField(title=_("Images data"),
-                            description=_("You can upload a single file or choose to upload a whole ZIP archive"),
-                            required=False)
+    images_data = ImageField(title=_("Images data"),
+                             description=_("You can upload a single file or choose to upload a whole ZIP archive"),
+                             required=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/gallery/zmi/paragraph.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,185 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.gallery.interfaces import IGalleryParagraph, IBaseGallery
+from pyams_content.component.gallery.zmi.interfaces import IGalleryImagesView
+from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IParagraphContainer
+from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_form.interfaces.form import IInnerForm, IEditFormButtons, IInnerSubForm
+from pyams_i18n.interfaces import II18n
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu, IWidgetTitleViewletManager
+from pyams_skin.layer import IPyAMSLayer
+from z3c.form.interfaces import INPUT_MODE
+
+# import packages
+from pyams_content.component.gallery.paragraph import Gallery
+from pyams_content.component.paragraph.zmi import BaseParagraphAJAXEditForm
+from pyams_content.component.paragraph.zmi.container import ParagraphContainerView
+from pyams_content.shared.common.zmi import WfSharedContentPermissionMixin
+from pyams_form.form import AJAXAddForm
+from pyams_form.security import ProtectedFormObjectMixin
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem, ToolbarAction
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config
+from pyams_utils.traversing import get_parent
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm, InnerAdminDisplayForm
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import implementer, Interface
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-gallery.menu', context=IParagraphContainerTarget, view=ParagraphContainerView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=65)
+class GalleryAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """Gallery add menu"""
+
+    label = _("Add images gallery...")
+    label_css_class = 'fa fa-fw fa-picture-o'
+    url = 'add-gallery.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-gallery.html', context=IParagraphContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class GalleryAddForm(AdminDialogAddForm):
+    """Gallery add form"""
+
+    legend = _("Add new gallery")
+    icon_css_class = 'fa fa-fw fa-picture-o'
+
+    fields = field.Fields(IGalleryParagraph).omit('__parent__', '__name__', 'visible')
+    ajax_handler = 'add-gallery.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(GalleryAddForm, self).updateWidgets(prefix)
+        if 'description' in self.widgets:
+            self.widgets['description'].widget_css_class = 'textarea'
+
+    def create(self, data):
+        return Gallery()
+
+    def add(self, object):
+        IParagraphContainer(self.context).append(object)
+
+
+@view_config(name='add-gallery.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class GalleryAJAXAddForm(AJAXAddForm, GalleryAddForm):
+    """Gallery paragraph add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'reload',
+                'location': '#paragraphs.html'}
+
+
+@pagelet_config(name='properties.html', context=IGalleryParagraph, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class GalleryPropertiesEditForm(AdminDialogEditForm):
+    """Gallery properties edit form"""
+
+    @property
+    def title(self):
+        content = get_parent(self.context, IWfSharedContent)
+        return II18n(content).query_attribute('title', request=self.request)
+
+    legend = _("Edit gallery properties")
+    icon_css_class = 'fa fa-fw fa-picture-o'
+
+    fields = field.Fields(IGalleryParagraph).omit('__parent__', '__name__', 'visible')
+    ajax_handler = 'properties.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(GalleryPropertiesEditForm, self).updateWidgets(prefix)
+        if 'description' in self.widgets:
+            self.widgets['description'].widget_css_class = 'textarea'
+
+
+@view_config(name='properties.json', context=IGalleryParagraph, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class GalleryPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, GalleryPropertiesEditForm):
+    """Gallery paragraph properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        updated = changes.get(IBaseGallery, ())
+        if 'title' in updated:
+            return {'status': 'success',
+                    'event': 'PyAMS_content.changed_item',
+                    'event_options': {'object_type': 'paragraph',
+                                      'object_name': self.context.__name__,
+                                      'title': II18n(self.context).query_attribute('title', request=self.request),
+                                      'visible': self.context.visible}}
+        else:
+            return super(GalleryPropertiesAJAXEditForm, self).get_ajax_output(changes)
+
+
+@adapter_config(context=(IGalleryParagraph, IPyAMSLayer), provides=IParagraphInnerEditor)
+@implementer(IInnerForm)
+class GalleryInnerEditForm(GalleryPropertiesEditForm):
+    """Gallery properties inner edit form"""
+
+    legend = None
+
+    @property
+    def buttons(self):
+        if self.mode == INPUT_MODE:
+            return button.Buttons(IEditFormButtons)
+        else:
+            return button.Buttons()
+
+
+#
+# Gallery contents view
+#
+
+@adapter_config(name='gallery-images', context=(IGalleryParagraph, IPyAMSLayer, GalleryInnerEditForm),
+                provides=IInnerSubForm)
+@template_config(template='templates/gallery-images.pt', layer=IPyAMSLayer)
+@implementer(IGalleryImagesView)
+class GalleryContentsView(WfSharedContentPermissionMixin, InnerAdminDisplayForm):
+    """Gallery contents edit form"""
+
+    fields = field.Fields(Interface)
+    weight = 10
+
+    def get_title(self, image):
+        return II18n(image).query_attribute('title', request=self.request)
+
+
+@viewlet_config(name='add-image.menu', context=IGalleryParagraph, view=GalleryContentsView,
+                manager=IWidgetTitleViewletManager)
+class GalleryImageAddMenu(WfSharedContentPermissionMixin, ToolbarAction):
+    """Gallery image add menu"""
+
+    label = _("Add image(s)")
+
+    url = 'add-image.html'
+    modal_target = True
+    stop_propagation = True
+
+
+#
+# Gallery paragraph summary
+#
--- a/src/pyams_content/component/gallery/zmi/templates/gallery-images.pt	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/gallery/zmi/templates/gallery-images.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -1,51 +1,56 @@
-<div id="gallery-images" class="sortable gallery" i18n:domain="pyams_content"
+<div class="form-group" i18n:domain="pyams_content"
 	 data-ams-plugins="pyams_content"
 	 tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content');
-					 data-ams-location extension:absolute_url(context);
-					 class '{0} gallery'.format('sortable' if request.has_permission(view.permission) else '');"
-	 data-ams-plugin-pyams_content-async="false"
-	 data-ams-sortable-stop="PyAMS_content.galleries.setOrder">
-	<div tal:repeat="image context.values()"
-		 class="image margin-5 margin-bottom-10 radius-4 padding-5 pull-left text-center"
-		 style="position: relative;"
-		 tal:attributes="data-ams-element-name image.__name__">
-		<a class="fancybox" data-toggle
-		   data-ams-fancybox-type="image"
-		   tal:define="thumbnails extension:thumbnails(image);
-					   target thumbnails.get_thumbnail('800x600', 'jpeg');
-					   info view.get_info(image);"
-		   tal:attributes="href extension:absolute_url(target);">
-			<i class="fa fa-fw fa-eye-slash txt-color-red pull-right opaque hint"
-			   style="position: absolute; right: 8px; top: 8px;"
-			   title="Hidden image" i18n:attributes="title"
-			   tal:condition="not:info.visible"></i>
-			<img class="thumbnail hint"
-				 data-ams-hint-gravity="s"
-				 tal:define="thumbnail thumbnails.get_thumbnail('128x128', 'jpeg');
-							 image_size thumbnail.get_image_size();
-							 margin_left 64 - image_size[0] / 2;
-							 margin_top 64 - image_size[1] / 2;"
-				 tal:attributes="src extension:absolute_url(thumbnail);
-								 title info.get_title(request);
-								 style string:margin-left: ${margin_left}px;; margin-right: ${margin_left}px;; margin-top: ${margin_top}px;; margin-bottom: ${margin_top}px;;" />
-		</a>
-		<div class="btn-group dropup margin-top-10"
-			 tal:define="actions extension:context_actions(image);"
-			 tal:omit-tag="not:actions">
-			<a class="btn btn-xs btn-default" target="download_window"
-			   tal:attributes="href extension:absolute_url(image)" i18n:translate="">
-				Download
-			</a>
-			<tal:if condition="actions">
-				<button class="btn btn-xs btn-primary dropdown-toggle" data-toggle="dropdown">
-					<i class="fa fa-caret-up"></i>
-				</button>
-				<ul class="dropdown-menu">
+					 id string:gallery_images_${context.__name__};"
+	 data-ams-plugin-pyams_content-async="false">
+	<fieldset class="margin-top-10 padding-top-5 padding-bottom-0">
+		<legend
+			class="inner switcher margin-bottom-5 padding-right-10 no-y-padding pull-left width-auto"
+			tal:attributes="data-ams-switcher-state 'open' if context.values() else None">
+			<i18n:var translate="">Gallery images</i18n:var>
+		</legend>
+		<div class="pull-left">
+			<tal:var content="structure provider:pyams.widget_title" />
+		</div>
+		<div class="clearfix"></div>
+		<div class="sortable gallery"
+			 tal:attributes="data-ams-location extension:absolute_url(context);
+							 class '{0} gallery'.format('sortable' if request.has_permission(view.permission) else '');"
+			 data-ams-sortable-stop="PyAMS_content.galleries.setOrder">
+			<div tal:repeat="image context.values()"
+				 class="image margin-5 margin-bottom-10 radius-4 padding-5 pull-left text-center"
+				 style="position: relative;"
+				 tal:attributes="data-ams-element-name image.__name__">
+				<tal:var define="thumbnails extension:thumbnails(image.data);">
+					<tal:if condition="thumbnails">
+						<a class="fancybox" data-toggle
+						   data-ams-fancybox-type="image"
+						   tal:define="target thumbnails.get_thumbnail('800x600', 'jpeg')"
+						   tal:attributes="href extension:absolute_url(target);">
+							<img class="thumbnail hint"
+								 data-ams-hint-gravity="s"
+								 tal:define="thumbnail thumbnails.get_thumbnail('128x128', 'jpeg');
+											 image_size thumbnail.get_image_size();
+											 margin_left 64 - image_size[0] / 2;
+											 margin_top 64 - image_size[1] / 2;"
+								 tal:attributes="src extension:absolute_url(thumbnail);
+												 title i18n:image.title;
+												 style string:margin-left: ${margin_left}px;; margin-right: ${margin_left}px;; margin-top: ${margin_top}px;; margin-bottom: ${margin_top}px;;" />
+						</a>
+					</tal:if>
+					<tal:if condition="not:thumbnails">
+						<img class="thumbnail hint" src="/--static--/pyams_skin/img/mimetypes/unknown.png"
+							 tal:attributes="title i18n:image.title"
+							 style="padding: 48px;" />
+					</tal:if>
+				</tal:var>
+				<div class="btn-group margin-top-10"
+					 tal:define="actions extension:context_actions(image);">
 					<tal:loop repeat="viewlet actions.viewlets"
 							  content="structure viewlet.render()" />
-				</ul>
-			</tal:if>
+				</div>
+				<span class="clearfix"></span>
+			</div>
 		</div>
-		<span class="clearfix"></span>
-	</div>
+	</fieldset>
 </div>
--- a/src/pyams_content/component/gallery/zmi/templates/widget-display.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-<input type="hidden" autocomplete="off" readonly
-	data-ams-select2-multiple="true"
-	tal:attributes="id view/id;
-					name view/name;
-					class string:select2 ${view/klass} ordered;
-					style view/style;
-					title view/title;
-					value python:','.join(view.value);
-					lang view/lang;
-					onclick view/onclick;
-					ondblclick view/ondblclick;
-					onmousedown view/onmousedown;
-					onmouseup view/onmouseup;
-					onmouseover view/onmouseover;
-					onmousemove view/onmousemove;
-					onmouseout view/onmouseout;
-					onkeypress view/onkeypress;
-					onkeydown view/onkeydown;
-					onkeyup view/onkeyup;
-					disabled view/disabled;
-					tabindex view/tabindex;
-					data-ams-select2-values view/values_map;" />
--- a/src/pyams_content/component/gallery/zmi/templates/widget-input.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,44 +0,0 @@
-<label class="input bordered with-icon" i18n:domain="pyams_content"
-	   data-ams-plugins="pyams_content"
-	   tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content')">
-	<i class="icon-append fa fa-plus-square txt-color-green hint opaque"
-		title="Add gallery" i18n:attributes="title"
-		data-ams-url="add-gallery.html?origin=link" data-toggle="modal"
-		tal:attributes="data-ams-select2-target string:${view/name}:list"></i>
-	<div class="select2-parent">
-		<select class="select2 ordered"
-				data-ams-select2-allow-clear="true"
-				tal:attributes="id view/id;
-								name string:${view/name}:list;
-								class string:${view/klass} select2 ordered;
-								style view/style;
-								title view/title;
-								lang view/lang;
-								onclick view/onclick;
-								ondblclick view/ondblclick;
-								onmousedown view/onmousedown;
-								onmouseup view/onmouseup;
-								onmouseover view/onmouseover;
-								onmousemove view/onmousemove;
-								onmouseout view/onmouseout;
-								onkeypress view/onkeypress;
-								onkeydown view/onkeydown;
-								onkeyup view/onkeyup;
-								disabled view/disabled;
-								tabindex view/tabindex;
-								onfocus view/onfocus;
-								onblur view/onblur;
-								onchange view/onchange;
-								multiple view/multiple;
-								size view/size">
-			<option tal:repeat="entry view/selectedItems"
-					tal:attributes="value entry/value;
-									selected python:entry['value'] in view.value;"
-					tal:content="entry/content"></option>
-			<option tal:repeat="entry view/notselectedItems"
-					tal:attributes="value entry/value;
-									selected python:entry['value'] in view.value;"
-					tal:content="entry/content"></option>
-		</select>
-	</div>
-</label>
--- a/src/pyams_content/component/gallery/zmi/widget.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,42 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-import json
-
-# import interfaces
-from pyams_skin.layer import IPyAMSLayer
-
-# import packages
-from pyams_form.widget import widgettemplate_config
-from z3c.form.browser.orderedselect import OrderedSelectWidget
-from z3c.form.widget import FieldWidget
-
-
-@widgettemplate_config(mode='input', template='templates/widget-input.pt', layer=IPyAMSLayer)
-@widgettemplate_config(mode='display', template='templates/widget-display.pt', layer=IPyAMSLayer)
-class GalleryLinksSelectWidget(OrderedSelectWidget):
-    """Galleries links select widget"""
-
-    @property
-    def values_map(self):
-        result = {}
-        [result.update({entry['value']: entry['content']}) for entry in self.selectedItems]
-        return json.dumps(result)
-
-
-def GalleryLinkSelectFieldWidget(field, request):
-    """Galleries links select widget factory"""
-    return FieldWidget(field, GalleryLinksSelectWidget(request))
--- a/src/pyams_content/component/illustration/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/illustration/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,5 +16,125 @@
 # import standard library
 
 # import interfaces
+from pyams_content.component.illustration.interfaces import IIllustrationRenderer, IIllustration, IIllustrationTarget, \
+    ILLUSTRATION_KEY
+from pyams_file.interfaces import DELETED_FILE, IResponsiveImage, IFileInfo
+from pyams_i18n.interfaces import INegotiator, II18n
+from zope.annotation.interfaces import IAnnotations
+from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent
+from zope.location.interfaces import ISublocations
+from zope.traversing.interfaces import ITraversable
 
 # import packages
+from persistent import Persistent
+from pyams_i18n.property import I18nFileProperty
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.registry import query_utility
+from pyams_utils.request import check_request
+from pyams_utils.vocabulary import vocabulary_config
+from pyramid.events import subscriber
+from pyramid.threadlocal import get_current_registry
+from zope.container.contained import Contained
+from zope.interface import implementer, alsoProvides
+from zope.lifecycleevent import ObjectCreatedEvent, ObjectAddedEvent
+from zope.location import locate
+from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+
+@implementer(IIllustration)
+class Illustration(Persistent, Contained):
+    """Illustration persistent class"""
+
+    title = FieldProperty(IIllustration['title'])
+    alt_title = FieldProperty(IIllustration['alt_title'])
+    description = FieldProperty(IIllustration['description'])
+    author = FieldProperty(IIllustration['author'])
+    _data = I18nFileProperty(IIllustration['data'])
+    renderer = FieldProperty(IIllustration['renderer'])
+
+    @property
+    def data(self):
+        return self._data
+
+    @data.setter
+    def data(self, value):
+        self._data = value
+        for data in value.values():
+            if (data is not None) and (data is not DELETED_FILE):
+                alsoProvides(data, IResponsiveImage)
+
+
+@adapter_config(context=IIllustrationTarget, provides=IIllustration)
+def illustration_factory(context):
+    """Illustration factory"""
+    annotations = IAnnotations(context)
+    illustration = annotations.get(ILLUSTRATION_KEY)
+    if illustration is None:
+        illustration = annotations[ILLUSTRATION_KEY] = Illustration()
+        registry = get_current_registry()
+        registry.notify(ObjectCreatedEvent(illustration))
+        locate(illustration, context, '++illustration++')
+        registry.notify(ObjectAddedEvent(illustration, context, '++illustration++'))
+    return illustration
+
+
+def update_illustration_properties(illustration):
+    """Update missing file properties"""
+    request = check_request()
+    i18n = query_utility(INegotiator)
+    if i18n is not None:
+        lang = i18n.server_language
+        data = II18n(illustration).get_attribute('data', lang, request)
+        if data:
+            info = IFileInfo(data)
+            info.title = II18n(illustration).get_attribute('title', lang, request)
+            info.description = II18n(illustration).get_attribute('alt_title', lang, request)
+        for lang, data in (illustration.data or {}).items():
+            if data is not None:
+                IFileInfo(data).language = lang
+
+
+@subscriber(IObjectAddedEvent, context_selector=IIllustration)
+def handle_added_illustration(event):
+    """Handle added illustration"""
+    illustration = event.object
+    update_illustration_properties(illustration)
+
+
+@subscriber(IObjectModifiedEvent, context_selector=IIllustration)
+def handle_modified_illustration(event):
+    """Handle modified illustration"""
+    illustration = event.object
+    update_illustration_properties(illustration)
+
+
+@adapter_config(name='illustration', context=IIllustrationTarget, provides=ITraversable)
+class IllustrationNamespace(ContextAdapter):
+    """++illustration++ namespace adapter"""
+
+    def traverse(self, name, furtherpath=None):
+        return IIllustration(self.context)
+
+
+@adapter_config(name='illustration', context=IIllustrationTarget, provides=ISublocations)
+class IllustrationSublocations(ContextAdapter):
+    """Illustration sub-locations adapter"""
+
+    def sublocations(self):
+        return IIllustration(self.context),
+
+
+@vocabulary_config(name='PyAMS illustration renderers')
+class IllustrationRendererVocabulary(SimpleVocabulary):
+    """Illustration renderer utilities vocabulary"""
+
+    def __init__(self, context=None):
+        request = check_request()
+        translate = request.localizer.translate
+        registry = request.registry
+        context = Illustration()
+        terms = [SimpleTerm(name, title=translate(adapter.label))
+                 for name, adapter in sorted(registry.getAdapters((context, request), IIllustrationRenderer),
+                                             key=lambda x: x[1].weight)]
+        super(IllustrationRendererVocabulary, self).__init__(terms)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/interfaces/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,76 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IBaseParagraph
+from pyams_i18n.schema import I18nTextLineField, I18nThumbnailImageField, I18nTextField
+from zope.annotation.interfaces import IAttributeAnnotatable
+from zope.contentprovider.interfaces import IContentProvider
+
+# import packages
+from zope.interface import Interface, Attribute
+from zope.schema import Choice, TextLine
+
+from pyams_content import _
+
+
+#
+# Illustration
+#
+
+ILLUSTRATION_KEY = 'pyams_content.illustration'
+
+
+class IIllustration(Interface):
+    """Illustration paragraph"""
+
+    title = I18nTextLineField(title=_("Legend"),
+                              description=_("Illustration title"),
+                              required=False)
+
+    alt_title = I18nTextLineField(title=_("Accessibility title"),
+                                  description=_("Alternate title used to describe image content"),
+                                  required=False)
+
+    description = I18nTextField(title=_("Description"),
+                                description=_(""),
+                                required=False)
+
+    author = TextLine(title=_("Author"),
+                      description=_("Name of picture's author"),
+                      required=False)
+
+    data = I18nThumbnailImageField(title=_("Image data"),
+                                   description=_("Image content"),
+                                   required=True)
+
+    renderer = Choice(title=_("Image style"),
+                      vocabulary='PyAMS illustration renderers')
+
+
+class IIllustrationTarget(IAttributeAnnotatable):
+    """Illustration target marker interface"""
+
+
+class IIllustrationRenderer(IContentProvider):
+    """Illustration renderer utility interface"""
+
+    label = Attribute("Renderer label")
+
+
+class IIllustrationParagraph(IIllustration, IBaseParagraph):
+    """Illustration paragraph"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/paragraph.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,44 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.illustration.interfaces import IIllustrationParagraph
+from pyams_content.component.paragraph.interfaces import IParagraphFactory
+
+# import packages
+from pyams_content.component.illustration import Illustration as BaseIllustration
+from pyams_content.component.paragraph import BaseParagraph
+from pyams_utils.registry import utility_config
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+@implementer(IIllustrationParagraph)
+class Illustration(BaseIllustration, BaseParagraph):
+    """Illustration class"""
+
+    icon_class = 'fa-file-image-o'
+    icon_hint = _("Illustration")
+
+
+@utility_config(name='Illustration', provides=IParagraphFactory)
+class IllustrationFactory(object):
+    """Illustration paragraph factory"""
+
+    name = _("Illustration")
+    content_type = Illustration
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/zmi/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,131 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.illustration.interfaces import IIllustration, IIllustrationRenderer, IIllustrationTarget
+from pyams_form.interfaces.form import IInnerSubForm
+from pyams_i18n.interfaces import II18n
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.interfaces import IPropertiesEditForm
+from transaction.interfaces import ITransactionManager
+
+# import packages
+from pyams_template.template import get_view_template, template_config
+from pyams_utils.adapter import ContextRequestAdapter, adapter_config
+from pyams_zmi.form import InnerAdminEditForm
+from z3c.form import field
+
+from pyams_content import _
+
+
+#
+# Illustration renderers
+#
+
+class BaseIllustrationRenderer(ContextRequestAdapter):
+    """Base illustration renderer"""
+
+    language = None
+
+    def update(self):
+        i18n = II18n(self.context)
+        if self.language:
+            self.legend = i18n.get_attribute('alt_title', self.language, request=self.request)
+        else:
+            self.legend = i18n.query_attribute('alt_title', request=self.request)
+
+    render = get_view_template()
+
+
+@adapter_config(name='default', context=(IIllustration, IPyAMSLayer), provides=IIllustrationRenderer)
+@template_config(template='templates/renderer-default.pt', layer=IPyAMSLayer)
+class DefaultIllustrationRenderer(BaseIllustrationRenderer):
+    """Default illustration renderer"""
+
+    label = _("Centered illustration")
+    weight = 1
+
+
+@adapter_config(name='left+zoom', context=(IIllustration, IPyAMSLayer), provides=IIllustrationRenderer)
+@template_config(template='templates/renderer-left.pt', layer=IPyAMSLayer)
+class LeftIllustrationWithZoomRenderer(BaseIllustrationRenderer):
+    """Illustrtaion renderer with small image and zoom"""
+
+    label = _("Small illustration on the left with zoom")
+    weight = 2
+
+
+@adapter_config(name='right+zoom', context=(IIllustration, IPyAMSLayer), provides=IIllustrationRenderer)
+@template_config(template='templates/renderer-right.pt', layer=IPyAMSLayer)
+class RightIllustrationWithZoomRenderer(BaseIllustrationRenderer):
+    """Illustrtaion renderer with small image and zoom"""
+
+    label = _("Small illustration on the right with zoom")
+    weight = 3
+
+
+#
+# Illustration properties inner edit form
+#
+
+@adapter_config(name='illustration', context=(IIllustrationTarget, IPyAMSLayer, IPropertiesEditForm),
+                provides=IInnerSubForm)
+class IllustrationPropertiesInnerEditForm(InnerAdminEditForm):
+    """Illustration properties inner edit form"""
+
+    prefix = 'illustration_form.'
+
+    css_class = 'form-group'
+    padding_class = ''
+    fieldset_class = 'margin-top-10 padding-top-5 padding-bottom-5'
+
+    legend = _("Illustration")
+    legend_class = 'inner switcher padding-right-10 no-y-padding pull-left'
+
+    fields = field.Fields(IIllustration).omit('__parent__', '__name__')
+    weight = 10
+
+    def getContent(self):
+        return IIllustration(self.context)
+
+    def updateWidgets(self, prefix=None):
+        super(IllustrationPropertiesInnerEditForm, self).updateWidgets(prefix)
+        if 'description' in self.widgets:
+            self.widgets['description'].widget_css_class = 'textarea'
+
+    @property
+    def switcher_state(self):
+        for lang, data in self.getContent().data:
+            if data:
+                return 'open'
+
+    def get_ajax_output(self, changes):
+        output = super(IllustrationPropertiesInnerEditForm, self).get_ajax_output(changes)
+        if 'data' in changes.get(IIllustration, ()):
+            # we have to commit transaction to be able to handle blobs...
+            ITransactionManager(self.context).get().commit()
+            context = IIllustration(self.context)
+            form = IllustrationPropertiesInnerEditForm(context, self.request)
+            form.update()
+            output.setdefault('callbacks', []).append({
+                'callback': 'PyAMS_content.illustration.afterUpdateCallback',
+                'options': {'parent': '{0}_{1}_{2}'.format(self.context.__class__.__name__,
+                                                           getattr(form.getContent(), '__name__', 'noname').replace('++', ''),
+                                                           form.id),
+                            'form': form.render()}
+            })
+        return output
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/zmi/paragraph.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,215 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, \
+    IParagraphContainer, IParagraphSummary
+from pyams_content.component.illustration.interfaces import IIllustrationRenderer, IIllustration, IIllustrationParagraph
+from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_form.interfaces.form import IInnerForm, IEditFormButtons
+from pyams_i18n.interfaces import II18n
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from transaction.interfaces import ITransactionManager
+from z3c.form.interfaces import INPUT_MODE
+
+# import packages
+from pyams_content.component.illustration.paragraph import Illustration
+from pyams_content.component.paragraph.zmi import BaseParagraphAJAXEditForm
+from pyams_content.component.paragraph.zmi.container import ParagraphContainerView
+from pyams_form.form import AJAXAddForm
+from pyams_form.security import ProtectedFormObjectMixin
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_utils.adapter import ContextRequestAdapter, adapter_config
+from pyams_utils.traversing import get_parent
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+#
+# Illustration
+#
+
+@viewlet_config(name='add-illustration.menu', context=IParagraphContainerTarget, view=ParagraphContainerView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=60)
+class IllustrationAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """Illustration add menu"""
+
+    label = _("Add illustration...")
+    label_css_class = 'fa fa-fw fa-file-image-o'
+    url = 'add-illustration.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-illustration.html', context=IParagraphContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class IllustrationAddForm(AdminDialogAddForm):
+    """Illustration add form"""
+
+    legend = _("Add new illustration")
+    dialog_class = 'modal-large'
+    icon_css_class = 'fa fa-fw fa-file-image-o'
+
+    fields = field.Fields(IIllustrationParagraph).omit('__parent__', '__name__', 'visible')
+    ajax_handler = 'add-illustration.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(IllustrationAddForm, self).updateWidgets(prefix)
+        if 'description' in self.widgets:
+            self.widgets['description'].widget_css_class = 'textarea'
+
+    def create(self, data):
+        return Illustration()
+
+    def add(self, object):
+        IParagraphContainer(self.context).append(object)
+
+
+@view_config(name='add-illustration.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class IllustrationAJAXAddForm(AJAXAddForm, IllustrationAddForm):
+    """HTML paragraph add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'reload',
+                'location': '#paragraphs.html'}
+
+
+@pagelet_config(name='properties.html', context=IIllustrationParagraph, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class IllustrationPropertiesEditForm(AdminDialogEditForm):
+    """Illustration properties edit form"""
+
+    @property
+    def title(self):
+        content = get_parent(self.context, IWfSharedContent)
+        return II18n(content).query_attribute('title', request=self.request)
+
+    legend = _("Edit illustration properties")
+    dialog_class = 'modal-large'
+    icon_css_class = 'fa fa-fw fa-file-image-o'
+
+    fields = field.Fields(IIllustrationParagraph).omit('__parent__', '__name__', 'visible')
+    ajax_handler = 'properties.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(IllustrationPropertiesEditForm, self).updateWidgets(prefix)
+        if 'description' in self.widgets:
+            self.widgets['description'].widget_css_class = 'textarea'
+
+
+@view_config(name='properties.json', context=IIllustrationParagraph, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class IllustrationPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, IllustrationPropertiesEditForm):
+    """Illustration properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(IllustrationPropertiesAJAXEditForm, self).get_ajax_output(changes)
+        if 'title' in changes.get(IIllustration, ()):
+            output.setdefault('events', []).append({
+                'event': 'PyAMS_content.changed_item',
+                'options': {'object_type': 'paragraph',
+                            'object_name': self.context.__name__,
+                            'title': II18n(self.context).query_attribute('title', request=self.request),
+                            'visible': self.context.visible}
+            })
+        return output
+
+
+@adapter_config(context=(IIllustrationParagraph, IPyAMSLayer), provides=IParagraphInnerEditor)
+@implementer(IInnerForm)
+class IllustrationInnerEditForm(IllustrationPropertiesEditForm):
+    """Illustration inner edit form"""
+
+    legend = None
+    ajax_handler = 'inner-properties.json'
+
+    @property
+    def buttons(self):
+        if self.mode == INPUT_MODE:
+            return button.Buttons(IEditFormButtons)
+        else:
+            return button.Buttons()
+
+
+@view_config(name='inner-properties.json', context=IIllustrationParagraph, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class IllustrationInnerAJAXEditForm(BaseParagraphAJAXEditForm, IllustrationInnerEditForm):
+    """Illustration paragraph inner edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(IllustrationInnerAJAXEditForm, self).get_ajax_output(changes)
+        updated = changes.get(IIllustration, ())
+        if 'title' in updated:
+            output.setdefault('events', []).append({
+                'event': 'PyAMS_content.changed_item',
+                'options': {'object_type': 'paragraph',
+                            'object_name': self.context.__name__,
+                            'title': II18n(self.context).query_attribute('title', request=self.request),
+                            'visible': self.context.visible}
+            })
+        if 'data' in updated:
+            # we have to commit transaction to be able to handle blobs...
+            ITransactionManager(self.context).get().commit()
+            context = IIllustrationParagraph(self.context)
+            form = IllustrationInnerEditForm(context, self.request)
+            form.update()
+            output.setdefault('callbacks', []).append({
+                'callback': 'PyAMS_content.illustration.afterUpdateCallback',
+                'options': {'parent': '{0}_{1}_{2}'.format(self.context.__class__.__name__,
+                                                           getattr(form.getContent(), '__name__', 'noname').replace('++', ''),
+                                                           form.id),
+                            'form': form.render()}
+            })
+        return output
+
+
+#
+# Illustration summary
+#
+
+@adapter_config(context=(IIllustrationParagraph, IPyAMSLayer), provides=IParagraphSummary)
+class IllustrationSummary(ContextRequestAdapter):
+    """Illustration renderer"""
+
+    def __init__(self, context, request):
+        super(IllustrationSummary, self).__init__(context, request)
+        self.renderer = request.registry.queryMultiAdapter((context, request), IIllustrationRenderer,
+                                                           name=self.context.renderer)
+
+    language = None
+
+    def update(self):
+        if self.renderer is not None:
+            self.renderer.language = self.language
+            self.renderer.update()
+
+    def render(self):
+        if self.renderer is not None:
+            return self.renderer.render()
+        else:
+            return ''
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/zmi/templates/renderer-default.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,7 @@
+<div class="text-center margin-y-5">
+	<img tal:define="data i18n:context.data;
+					 thumbnails extension:thumbnails(data);
+					 target thumbnails.get_thumbnail('800x600', 'jpeg');"
+		 tal:attributes="src extension:absolute_url(target)" /><br />
+	<span tal:content="view.legend">legend</span>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/zmi/templates/renderer-left.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,11 @@
+<div class="pull-left margin-10">
+	<a class="fancybox" data-toggle
+	   data-ams-fancybox-type="image"
+	   tal:define="thumbnails extension:thumbnails(context.data);
+				   target thumbnails.get_thumbnail('800x600', 'png');
+				   thumb thumbnails.get_thumbnail('300x200', 'png');"
+	   tal:attributes="href extension:absolute_url(target)">
+		<img tal:attributes="src extension:absolute_url(thumb)" /><br />
+		<span tal:content="view.legend">legend</span>
+	</a><br />
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/illustration/zmi/templates/renderer-right.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,11 @@
+<div class="pull-right margin-10">
+	<a class="fancybox" data-toggle
+	   data-ams-fancybox-type="image"
+	   tal:define="thumbnails extension:thumbnails(context.data);
+				   target thumbnails.get_thumbnail('800x600', 'png');
+				   thumb thumbnails.get_thumbnail('300x200', 'png');"
+	   tal:attributes="href extension:absolute_url(target)">
+		<img tal:attributes="src extension:absolute_url(thumb)" /><br />
+		<span tal:content="view.legend">legend</span>
+	</a><br />
+</div>
--- a/src/pyams_content/component/links/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/links/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -9,8 +9,6 @@
 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
 # FOR A PARTICULAR PURPOSE.
 #
-from pyams_i18n.interfaces import II18n
-from pyams_utils.request import check_request
 
 __docformat__ = 'restructuredtext'
 
@@ -19,76 +17,79 @@
 
 # import interfaces
 from hypatia.interfaces import ICatalog
+from pyams_content.component.association.interfaces import IAssociationInfo, IAssociationTarget, IAssociationContainer
 from pyams_content.component.links.interfaces import IBaseLink, IInternalLink, IExternalLink, IMailtoLink
-from pyams_content.shared.common.interfaces import IWfSharedContent
-from pyams_form.interfaces.form import IFormContextPermissionChecker
-from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
+from pyams_i18n.interfaces import II18n
+from pyams_sequence.interfaces import ISequentialIdInfo
 
 # import packages
 from hypatia.catalog import CatalogQuery
 from hypatia.query import Eq, Any
-from persistent import Persistent
 from pyams_catalog.query import CatalogResultSet
+from pyams_content.component.association import AssociationItem
 from pyams_content.workflow import VISIBLE_STATES
 from pyams_sequence.utility import get_last_version
 from pyams_utils.adapter import adapter_config, ContextAdapter
 from pyams_utils.registry import get_utility
+from pyams_utils.request import check_request
 from pyams_utils.traversing import get_parent
 from pyams_utils.url import absolute_url
-from pyramid.events import subscriber
-from pyramid.threadlocal import get_current_registry
-from zope.lifecycleevent import ObjectModifiedEvent
-from zope.container.contained import Contained
+from pyams_utils.vocabulary import vocabulary_config
 from zope.interface import implementer
 from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+from pyams_content import _
 
 
+#
+# Links vocabulary
+#
+
+@vocabulary_config(name='PyAMS content links')
+class ContentLinksVocabulary(SimpleVocabulary):
+    """Content links vocabulary"""
+
+    def __init__(self, context=None):
+        terms = []
+        target = get_parent(context, IAssociationTarget)
+        if target is not None:
+            terms = [SimpleTerm(link.__name__, title=IAssociationInfo(link).inner_title)
+                     for link in IAssociationContainer(target).values() if IBaseLink.providedBy(link)]
+        super(ContentLinksVocabulary, self).__init__(terms)
+
+
+#
+# Base link persistent class
+#
+
 @implementer(IBaseLink)
-class BaseLink(Persistent, Contained):
+class BaseLink(AssociationItem):
     """Base link persistent class"""
 
     title = FieldProperty(IBaseLink['title'])
     description = FieldProperty(IBaseLink['description'])
 
 
-@adapter_config(context=IBaseLink, provides=IFormContextPermissionChecker)
-class BaseLinkPermissionChecker(ContextAdapter):
-    """Base link permission checker"""
+class BaseLinkInfoAdapter(ContextAdapter):
+    """Base link association info adapter"""
 
     @property
-    def edit_permission(self):
-        content = get_parent(self.context, IWfSharedContent)
-        return IFormContextPermissionChecker(content).edit_permission
+    def pictogram(self):
+        return self.context.icon_class
 
 
-@subscriber(IObjectAddedEvent, context_selector=IBaseLink)
-def handle_added_link(event):
-    """Handle added link"""
-    content = get_parent(event.object, IWfSharedContent)
-    if content is not None:
-        get_current_registry().notify(ObjectModifiedEvent(content))
-
-
-@subscriber(IObjectModifiedEvent, context_selector=IBaseLink)
-def handle_modified_link(event):
-    """Handle modified link"""
-    content = get_parent(event.object, IWfSharedContent)
-    if content is not None:
-        get_current_registry().notify(ObjectModifiedEvent(content))
-
-
-@subscriber(IObjectRemovedEvent, context_selector=IBaseLink)
-def handle_removed_link(event):
-    """Handle removed link"""
-    content = get_parent(event.object, IWfSharedContent)
-    if content is not None:
-        get_current_registry().notify(ObjectModifiedEvent(content))
-
+#
+# Internal links
+#
 
 @implementer(IInternalLink)
 class InternalLink(BaseLink):
     """Internal link persistent class"""
 
+    icon_class = 'fa-link'
+    icon_hint = _("Internal link")
+
     reference = FieldProperty(IInternalLink['reference'])
 
     def get_target(self, state=None):
@@ -109,40 +110,121 @@
     def get_editor_url(self):
         return 'oid://{0}'.format(self.reference)
 
-    def get_url(self, request, view_name=None):
+    def get_url(self, request=None, view_name=None):
         target = self.get_target(state=VISIBLE_STATES)
         if target is not None:
+            if request is None:
+                request = check_request()
             return absolute_url(target, request, view_name)
         else:
             return ''
 
 
+@adapter_config(context=IInternalLink, provides=IAssociationInfo)
+class InternalLinkAssociationInfoAdapter(BaseLinkInfoAdapter):
+    """Internal link association info adapter"""
+
+    @property
+    def user_title(self):
+        title = II18n(self.context).query_attribute('title')
+        if not title:
+            target = self.context.get_target()
+            if target is not None:
+                title = II18n(target).query_attribute('title')
+        return title or '--'
+
+    @property
+    def inner_title(self):
+        target = self.context.get_target()
+        if target is not None:
+            sequence = ISequentialIdInfo(target)
+            return '{0} ({1})'.format(II18n(target).query_attribute('title'),
+                                      sequence.get_short_oid())
+        else:
+            return '--'
+
+    @property
+    def human_size(self):
+        return '--'
+
+
+#
+# External links
+#
+
 @implementer(IExternalLink)
 class ExternalLink(BaseLink):
     """External link persistent class"""
 
+    icon_class = 'fa-external-link'
+    icon_hint = _("External link")
+
     url = FieldProperty(IExternalLink['url'])
     language = FieldProperty(IExternalLink['language'])
 
     def get_editor_url(self):
         return self.url
 
-    def get_url(self, request, view_name=None):
+    def get_url(self, request=None, view_name=None):
         return self.url
 
 
+@adapter_config(context=IExternalLink, provides=IAssociationInfo)
+class ExternalLinkAssociationInfoAdapter(BaseLinkInfoAdapter):
+    """External link association info adapter"""
+
+    @property
+    def user_title(self):
+        title = II18n(self.context).query_attribute('title')
+        if not title:
+            title = self.context.url
+        return title or '--'
+
+    @property
+    def inner_title(self):
+        return self.context.url
+
+    @property
+    def human_size(self):
+        return '--'
+
+
+#
+# Mailto links
+#
+
 @implementer(IMailtoLink)
 class MailtoLink(BaseLink):
     """Mailto link persistent class"""
 
+    icon_class = 'fa-envelope-o'
+    icon_hint = _("Mailto link")
+
     address = FieldProperty(IMailtoLink['address'])
+    address_name = FieldProperty(IMailtoLink['address_name'])
 
     def get_editor_url(self):
-        request = check_request()
-        return 'mailto:{0} <{1}>'.format(II18n(self).query_attribute('title', request=request),
-                                         self.address)
+        return 'mailto:{0} <{1}>'.format(self.address_name, self.address)
+
+    def get_url(self, request=None, view_name=None):
+        return 'mailto:{0} &lt;{1}&gt;'.format(self.address_name, self.address)
+
+
+@adapter_config(context=IMailtoLink, provides=IAssociationInfo)
+class MailtoLinkAssociationInfoAdapter(BaseLinkInfoAdapter):
+    """Mailto link association info adapter"""
 
-    def get_url(self, request, view_name=None):
-        request = check_request()
-        return 'mailto:{0} <{1}>'.format(II18n(self).query_attribute('title', request=request),
-                                         self.address)
+    @property
+    def user_title(self):
+        title = II18n(self.context).query_attribute('title')
+        if not title:
+            title = self.context.address_name
+        return title or '--'
+
+    @property
+    def inner_title(self):
+        return self.context.get_url()
+
+    @property
+    def human_size(self):
+        return '--'
--- a/src/pyams_content/component/links/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,121 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
-# import interfaces
-from pyams_content.component.links.interfaces import ILinkContainer, ILinkContainerTarget, LINK_CONTAINER_KEY, \
-    ILinkLinksContainer, LINK_LINKS_CONTAINER_KEY, ILinkLinksContainerTarget
-from pyams_i18n.interfaces import II18n
-from zope.annotation.interfaces import IAnnotations
-from zope.location.interfaces import ISublocations
-from zope.traversing.interfaces import ITraversable
-
-# import packages
-from persistent import Persistent
-from persistent.list import PersistentList
-from pyams_utils.adapter import adapter_config, ContextAdapter
-from pyams_utils.traversing import get_parent
-from pyams_utils.vocabulary import vocabulary_config
-from pyramid.threadlocal import get_current_registry
-from zope.container.contained import Contained
-from zope.container.folder import Folder
-from zope.interface import implementer
-from zope.lifecycleevent import ObjectCreatedEvent
-from zope.location import locate
-from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
-
-
-@implementer(ILinkContainer)
-class LinkContainer(Folder):
-    """Links container"""
-
-    last_id = 1
-
-    def __setitem__(self, key, value):
-        key = str(self.last_id)
-        super(LinkContainer, self).__setitem__(key, value)
-        self.last_id += 1
-
-
-@adapter_config(context=ILinkContainerTarget, provides=ILinkContainer)
-def link_container_factory(target):
-    """Links container factory"""
-    annotations = IAnnotations(target)
-    container = annotations.get(LINK_CONTAINER_KEY)
-    if container is None:
-        container = annotations[LINK_CONTAINER_KEY] = LinkContainer()
-        get_current_registry().notify(ObjectCreatedEvent(container))
-        locate(container, target, '++links++')
-    return container
-
-
-@adapter_config(name='links', context=ILinkContainerTarget, provides=ITraversable)
-class LinkContainerNamespace(ContextAdapter):
-    """++links++ namespace adapter"""
-
-    def traverse(self, name, furtherpath=None):
-        return ILinkContainer(self.context)
-
-
-@adapter_config(name='links', context=ILinkContainerTarget, provides=ISublocations)
-class LinkContainerSublocations(ContextAdapter):
-    """Links container sublocations"""
-
-    def sublocations(self):
-        return ILinkContainer(self.context).values()
-
-
-@vocabulary_config(name='PyAMS content links')
-class LinkContainerLinksVocabulary(SimpleVocabulary):
-    """Links container links vocabulary"""
-
-    def __init__(self, context):
-        target = get_parent(context, ILinkContainerTarget)
-        terms = [SimpleTerm(link.__name__, title=II18n(link).query_attribute('title'))
-                 for link in ILinkContainer(target).values()]
-        super(LinkContainerLinksVocabulary, self).__init__(terms)
-
-
-#
-# Link links container
-#
-
-@implementer(ILinkLinksContainer)
-class LinkLinksContainer(Persistent, Contained):
-    """Links links container"""
-
-    def __init__(self):
-        self.links = PersistentList()
-
-
-@adapter_config(context=ILinkLinksContainerTarget, provides=ILinkLinksContainer)
-def link_links_container_factory(target):
-    """Links links container factory"""
-    annotations = IAnnotations(target)
-    container = annotations.get(LINK_LINKS_CONTAINER_KEY)
-    if container is None:
-        container = annotations[LINK_LINKS_CONTAINER_KEY] = LinkLinksContainer()
-        get_current_registry().notify(ObjectCreatedEvent(container))
-        locate(container, target, '++links-links++')
-    return container
-
-
-@adapter_config(name='links-links', context=ILinkLinksContainerTarget, provides=ITraversable)
-class LinkLinksContainerNamespace(ContextAdapter):
-    """++links-links++ namespace adapter"""
-
-    def traverse(self, name, furtherpath=None):
-        return ILinkLinksContainer(self.context)
--- a/src/pyams_content/component/links/interfaces/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/links/interfaces/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -17,32 +17,23 @@
 import re
 
 # import interfaces
-from zope.annotation.interfaces import IAttributeAnnotatable
-from zope.container.interfaces import IContainer
+from pyams_content.component.association.interfaces import IAssociationTarget, IAssociationItem
 
 # import packages
 from pyams_i18n.schema import I18nTextLineField, I18nTextField
 from pyams_sequence.schema import InternalReference, InternalReferencesList
-from pyams_utils.schema import PersistentList
-from zope.container.constraints import containers, contains
 from zope.interface import Interface
 from zope.schema import Choice, TextLine
 
 from pyams_content import _
 
 
-LINK_CONTAINER_KEY = 'pyams_content.link'
-LINK_LINKS_CONTAINER_KEY = 'pyams_content.link.links'
-
-
-class IBaseLink(IAttributeAnnotatable):
+class IBaseLink(IAssociationItem):
     """Base link interface"""
 
-    containers('.ILinkContainer')
-
-    title = I18nTextLineField(title=_("Title"),
+    title = I18nTextLineField(title=_("Alternate title"),
                               description=_("Link title, as shown in front-office"),
-                              required=True)
+                              required=False)
 
     description = I18nTextField(title=_("Description"),
                                 description=_("Link description displayed by front-office template"),
@@ -51,9 +42,6 @@
     def get_editor_url(self):
         """Get URL for use in HTML editor"""
 
-    def get_url(self, request, view_name=None):
-        """Get link URL"""
-
 
 class IInternalLink(IBaseLink):
     """Internal link interface"""
@@ -92,28 +80,13 @@
                        constraint=EMAIL_REGEX.match,
                        required=True)
 
-
-class ILinkContainer(IContainer):
-    """Links container"""
-
-    contains(IBaseLink)
-
-
-class ILinkContainerTarget(Interface):
-    """Links container marker interface"""
+    address_name = TextLine(title=_("Address name"),
+                            description=_("Address as displayed in address book"),
+                            required=True)
 
 
-class ILinkLinksContainer(Interface):
-    """Links links container interface"""
-
-    links = PersistentList(title=_("Contained links"),
-                           description=_("List of internal or external links linked to this object"),
-                           value_type=Choice(vocabulary="PyAMS content links"),
-                           required=False)
-
-
-class ILinkLinksContainerTarget(Interface):
-    """Links links container marker interface"""
+class ILinkContainerTarget(IAssociationTarget):
+    """Links container marker interface"""
 
 
 class IInternalReferencesList(Interface):
--- a/src/pyams_content/component/links/zmi/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/links/zmi/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,22 +16,21 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.links.interfaces import ILinkContainerTarget, IInternalLink, ILinkContainer, IBaseLink, \
+from pyams_content.component.association.interfaces import IAssociationContainer
+from pyams_content.component.association.zmi.interfaces import IAssociationsView
+from pyams_content.component.links.interfaces import ILinkContainerTarget, IInternalLink, IBaseLink, \
     IExternalLink, IMailtoLink
 from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_i18n.interfaces import II18n
 from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
 from pyams_skin.layer import IPyAMSLayer
 from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
 
 # import packages
+from pyams_content.component.association.zmi import AssociationItemAJAXAddForm, AssociationItemAJAXEditForm
 from pyams_content.component.links import InternalLink, ExternalLink, MailtoLink
-from pyams_content.component.links.zmi.container import LinkContainerView
-from pyams_form.form import AJAXAddForm, AJAXEditForm
 from pyams_form.security import ProtectedFormObjectMixin
 from pyams_pagelet.pagelet import pagelet_config
 from pyams_skin.viewlet.toolbar import ToolbarMenuItem
-from pyams_utils.traversing import get_parent
 from pyams_viewlet.viewlet import viewlet_config
 from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
 from pyramid.view import view_config
@@ -44,7 +43,7 @@
 # Internal links views
 #
 
-@viewlet_config(name='add-internal-link.menu', context=ILinkContainerTarget, view=LinkContainerView,
+@viewlet_config(name='add-internal-link.menu', context=ILinkContainerTarget, view=IAssociationsView,
                 layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=50)
 class InternalLinkAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
     """Internal link add menu"""
@@ -64,16 +63,8 @@
     legend = _("Add new internal link")
     icon_css_class = 'fa fa-fw fa-link'
 
-    fields = field.Fields(IInternalLink).omit('__parent__', '__name__')
-
-    @property
-    def ajax_handler(self):
-        origin = self.request.params.get('origin')
-        if origin == 'link':
-            return 'add-internal-link-link.json'
-        else:
-            return 'add-internal-link.json'
-
+    fields = field.Fields(IInternalLink).select('reference', 'title', 'description')
+    ajax_handler = 'add-internal-link.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
     def updateWidgets(self, prefix=None):
@@ -85,36 +76,14 @@
         return InternalLink()
 
     def add(self, object):
-        ILinkContainer(self.context)['none'] = object
+        IAssociationContainer(self.context).append(object)
 
 
 @view_config(name='add-internal-link.json', context=ILinkContainerTarget, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class InternalLinkAJAXAddForm(AJAXAddForm, InternalLinkAddForm):
+class InternalLinkAJAXAddForm(AssociationItemAJAXAddForm, InternalLinkAddForm):
     """Internal link add form, JSON renderer"""
 
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': '#links.html'}
-
-
-@view_config(name='add-internal-link-link.json', context=ILinkContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class InternalLinkLinkAJAXAddForm(AJAXAddForm, InternalLinkAddForm):
-    """Internal link link add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        target = get_parent(self.context, ILinkContainerTarget)
-        container = ILinkContainer(target)
-        links = [{'id': link.__name__,
-                  'text': II18n(link).query_attribute('title', request=self.request)}
-                 for link in container.values()]
-        return {'status': 'callback',
-                'callback': 'PyAMS_content.links.refresh',
-                'options': {'links': links,
-                            'new_link': {'id': changes.__name__,
-                                         'text': II18n(changes).query_attribute('title', request=self.request)}}}
-
 
 @pagelet_config(name='properties.html', context=IInternalLink, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
 class InternalLinkPropertiesEditForm(AdminDialogEditForm):
@@ -123,7 +92,7 @@
     legend = _("Edit internal link properties")
     icon_css_class = 'fa fa-fw fa-link'
 
-    fields = field.Fields(IInternalLink).omit('__parent__', '__name__')
+    fields = field.Fields(IInternalLink).select('reference', 'title', 'description')
     ajax_handler = 'properties.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
@@ -135,14 +104,13 @@
 
 @view_config(name='properties.json', context=IInternalLink, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class InternalLinkPropertiesAJAXEditForm(AJAXEditForm, InternalLinkPropertiesEditForm):
+class InternalLinkPropertiesAJAXEditForm(AssociationItemAJAXEditForm, InternalLinkPropertiesEditForm):
     """Internal link properties edit form, JSON renderer"""
 
     def get_ajax_output(self, changes):
         if ('title' in changes.get(IBaseLink, ())) or \
            ('reference' in changes.get(IInternalLink, ())):
-            return {'status': 'reload',
-                    'location': '#links.html'}
+            return self.get_associations_table()
         else:
             return super(InternalLinkPropertiesAJAXEditForm, self).get_ajax_output(changes)
 
@@ -151,7 +119,7 @@
 # External links views
 #
 
-@viewlet_config(name='add-external-link.menu', context=ILinkContainerTarget, view=LinkContainerView,
+@viewlet_config(name='add-external-link.menu', context=ILinkContainerTarget, view=IAssociationsView,
                 layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=51)
 class ExternalLinkAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
     """External link add menu"""
@@ -171,16 +139,8 @@
     legend = _("Add new external link")
     icon_css_class = 'fa fa-fw fa-external-link'
 
-    fields = field.Fields(IExternalLink).omit('__parent__', '__name__')
-
-    @property
-    def ajax_handler(self):
-        origin = self.request.params.get('origin')
-        if origin == 'link':
-            return 'add-external-link-link.json'
-        else:
-            return 'add-external-link.json'
-
+    fields = field.Fields(IExternalLink).select('url', 'title', 'description', 'language')
+    ajax_handler = 'add-external-link.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
     def updateWidgets(self, prefix=None):
@@ -192,36 +152,14 @@
         return ExternalLink()
 
     def add(self, object):
-        ILinkContainer(self.context)['none'] = object
+        IAssociationContainer(self.context).append(object)
 
 
 @view_config(name='add-external-link.json', context=ILinkContainerTarget, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExternalLinkAJAXAddForm(AJAXAddForm, ExternalLinkAddForm):
+class ExternalLinkAJAXAddForm(AssociationItemAJAXAddForm, ExternalLinkAddForm):
     """External link add form, JSON renderer"""
 
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': '#links.html'}
-
-
-@view_config(name='add-external-link-link.json', context=ILinkContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExternalLinkLinkAJAXAddForm(AJAXAddForm, ExternalLinkAddForm):
-    """External link link add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        target = get_parent(self.context, ILinkContainerTarget)
-        container = ILinkContainer(target)
-        links = [{'id': link.__name__,
-                  'text': II18n(link).query_attribute('title', request=self.request)}
-                 for link in container.values()]
-        return {'status': 'callback',
-                'callback': 'PyAMS_content.links.refresh',
-                'options': {'links': links,
-                            'new_link': {'id': changes.__name__,
-                                         'text': II18n(changes).query_attribute('title', request=self.request)}}}
-
 
 @pagelet_config(name='properties.html', context=IExternalLink, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
 class ExternalLinkPropertiesEditForm(AdminDialogEditForm):
@@ -230,7 +168,7 @@
     legend = _("Edit external link properties")
     icon_css_class = 'fa fa-fw fa-external-link'
 
-    fields = field.Fields(IExternalLink).omit('__parent__', '__name__')
+    fields = field.Fields(IExternalLink).select('url', 'title', 'description', 'language')
     ajax_handler = 'properties.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
@@ -242,14 +180,13 @@
 
 @view_config(name='properties.json', context=IExternalLink, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class ExternalLinkPropertiesAJAXEditForm(AJAXEditForm, ExternalLinkPropertiesEditForm):
+class ExternalLinkPropertiesAJAXEditForm(AssociationItemAJAXEditForm, ExternalLinkPropertiesEditForm):
     """External link properties edit form, JSON renderer"""
 
     def get_ajax_output(self, changes):
         if ('title' in changes.get(IBaseLink, ())) or \
-           ('reference' in changes.get(IExternalLink, ())):
-            return {'status': 'reload',
-                    'location': '#links.html'}
+           ('url' in changes.get(IExternalLink, ())):
+            return self.get_associations_table()
         else:
             return super(ExternalLinkPropertiesAJAXEditForm, self).get_ajax_output(changes)
 
@@ -259,7 +196,7 @@
 #
 
 
-@viewlet_config(name='add-mailto-link.menu', context=ILinkContainerTarget, view=LinkContainerView,
+@viewlet_config(name='add-mailto-link.menu', context=ILinkContainerTarget, view=IAssociationsView,
                 layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=52)
 class MailtoLinkAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
     """Mailto link add menu"""
@@ -279,16 +216,8 @@
     legend = _("Add new mailto link")
     icon_css_class = 'fa fa-fw fa-envelope-o'
 
-    fields = field.Fields(IMailtoLink).omit('__parent__', '__name__')
-
-    @property
-    def ajax_handler(self):
-        origin = self.request.params.get('origin')
-        if origin == 'link':
-            return 'add-mailto-link-link.json'
-        else:
-            return 'add-mailto-link.json'
-
+    fields = field.Fields(IMailtoLink).select('address', 'address_name', 'title', 'description')
+    ajax_handler = 'add-mailto-link.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
     def updateWidgets(self, prefix=None):
@@ -300,36 +229,14 @@
         return MailtoLink()
 
     def add(self, object):
-        ILinkContainer(self.context)['none'] = object
+        IAssociationContainer(self.context).append(object)
 
 
 @view_config(name='add-mailto-link.json', context=ILinkContainerTarget, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class MailtoLinkAJAXAddForm(AJAXAddForm, MailtoLinkAddForm):
+class MailtoLinkAJAXAddForm(AssociationItemAJAXAddForm, MailtoLinkAddForm):
     """Mailto link add form, JSON renderer"""
 
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': '#links.html'}
-
-
-@view_config(name='add-mailto-link-link.json', context=ILinkContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class MailtoLinkLinkAJAXAddForm(AJAXAddForm, MailtoLinkAddForm):
-    """Mailto link link add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        target = get_parent(self.context, ILinkContainerTarget)
-        container = ILinkContainer(target)
-        links = [{'id': link.__name__,
-                  'text': II18n(link).query_attribute('title', request=self.request)}
-                 for link in container.values()]
-        return {'status': 'callback',
-                'callback': 'PyAMS_content.links.refresh',
-                'options': {'links': links,
-                            'new_link': {'id': changes.__name__,
-                                         'text': II18n(changes).query_attribute('title', request=self.request)}}}
-
 
 @pagelet_config(name='properties.html', context=IMailtoLink, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
 class MailtoLinkPropertiesEditForm(AdminDialogEditForm):
@@ -338,7 +245,7 @@
     legend = _("Edit mailto link properties")
     icon_css_class = 'fa fa-fw fa-envelope-o'
 
-    fields = field.Fields(IMailtoLink).omit('__parent__', '__name__')
+    fields = field.Fields(IMailtoLink).select('address', 'address_name', 'title', 'description')
     ajax_handler = 'properties.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
@@ -350,13 +257,12 @@
 
 @view_config(name='properties.json', context=IMailtoLink, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class MailtoLinkPropertiesAJAXEditForm(AJAXEditForm, MailtoLinkPropertiesEditForm):
+class MailtoLinkPropertiesAJAXEditForm(AssociationItemAJAXEditForm, MailtoLinkPropertiesEditForm):
     """Mailto link properties edit form, JSON renderer"""
 
     def get_ajax_output(self, changes):
         if ('title' in changes.get(IBaseLink, ())) or \
            ('reference' in changes.get(IMailtoLink, ())):
-            return {'status': 'reload',
-                    'location': '#links.html'}
+            return self.get_associations_table()
         else:
             return super(MailtoLinkPropertiesAJAXEditForm, self).get_ajax_output(changes)
--- a/src/pyams_content/component/links/zmi/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/links/zmi/container.py	Mon Sep 11 14:54:30 2017 +0200
@@ -14,56 +14,19 @@
 
 
 # import standard library
-import html
 
 # import interfaces
-from pyams_content.component.extfile.interfaces import IExtFileContainer, IExtFileContainerTarget
-from pyams_content.component.links.interfaces import ILinkContainerTarget, ILinkContainer, IInternalLink, \
-    ILinkLinksContainerTarget, ILinkLinksContainer
-from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_content.component.association.interfaces import IAssociationContainer, IAssociationTarget, IAssociationInfo
+from pyams_content.component.extfile.interfaces import IBaseExtFile
+from pyams_content.component.links.interfaces import IBaseLink
 from pyams_i18n.interfaces import II18n
-from pyams_skin.interfaces import IInnerPage, IPageHeader
 from pyams_skin.layer import IPyAMSLayer
-from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
-from pyams_utils.interfaces.data import IObjectData
-from pyams_zmi.interfaces.menu import IPropertiesMenu
-from pyams_zmi.layer import IAdminLayer
-from z3c.table.interfaces import IColumn, IValues
 
 # import packages
-from pyams_content.component.links.zmi.widget import LinkLinksSelectFieldWidget
-from pyams_content.shared.common.zmi import WfModifiedContentColumnMixin
-from pyams_form.form import AJAXEditForm
-from pyams_form.security import ProtectedFormObjectMixin
-from pyams_pagelet.pagelet import pagelet_config
-from pyams_sequence.utility import get_sequence_dict
-from pyams_skin.page import DefaultPageHeaderAdapter
-from pyams_skin.table import BaseTable, I18nColumn, TrashColumn
-from pyams_skin.viewlet.menu import MenuItem
-from pyams_template.template import template_config
-from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
 from pyams_utils.traversing import get_parent
 from pyams_utils.url import absolute_url
-from pyams_viewlet.viewlet import viewlet_config
-from pyams_zmi.form import AdminDialogEditForm
-from pyams_zmi.view import AdminView
 from pyramid.view import view_config
-from pyramid.decorator import reify
-from z3c.form import field
-from z3c.table.column import GetAttrColumn
-from zope.interface import implementer, alsoProvides, Interface
-
-from pyams_content import _
-
-
-@viewlet_config(name='links.menu', context=ILinkContainerTarget, layer=IAdminLayer,
-                manager=IPropertiesMenu, permission=VIEW_SYSTEM_PERMISSION, weight=210)
-class LinkContainerMenu(MenuItem):
-    """Links container menu"""
-
-    label = _("Useful links...")
-    icon_class = 'fa-link'
-    url = '#links.html'
+from zope.interface import Interface
 
 
 #
@@ -77,12 +40,12 @@
     result = []
     key_field_name = request.params.get('keyFieldName', 'title')
     value_field_name = request.params.get('valueFieldName', 'value')
-    target = get_parent(request.context, ILinkContainerTarget)
+    target = get_parent(request.context, IAssociationTarget)
     if target is not None:
-        container = ILinkContainer(target)
-        result.extend([{key_field_name: link.__name__,
-                        value_field_name: II18n(link).query_attribute('title', request=request)}
-                       for link in container.values()])
+        container = IAssociationContainer(target)
+        result.extend([{key_field_name: item.__name__,
+                        value_field_name: IAssociationInfo(item).user_title}
+                       for item in container.values()])
     return sorted(result, key=lambda x: x[value_field_name])
 
 
@@ -91,148 +54,15 @@
 def get_links_list(request):
     """Get links list in JSON format for TinyMCE editor"""
     result = []
-    target = get_parent(request.context, IExtFileContainerTarget)
+    target = get_parent(request.context, IAssociationTarget)
     if target is not None:
-        container = IExtFileContainer(target)
-        result.extend([{'title': II18n(file).query_attribute('title', request=request),
-                        'value': absolute_url(II18n(file).query_attribute('data', request=request), request)}
-                       for file in container.values()])
-    target = get_parent(request.context, ILinkContainerTarget)
-    if target is not None:
-        container = ILinkContainer(target)
-        result.extend([{'title': II18n(link).query_attribute('title', request=request),
-                        'value': link.get_editor_url()}
-                       for link in container.values()])
+        container = IAssociationContainer(target)
+        for item in container.values():
+            if IBaseLink.providedBy(item):
+                result.append({'title': IAssociationInfo(item).user_title,
+                               'value': item.get_editor_url()})
+            elif IBaseExtFile.providedBy(item):
+                result.append({'title': IAssociationInfo(item).user_title,
+                               'value': absolute_url(II18n(item).query_attribute('data', request=request),
+                                                     request=request)})
     return sorted(result, key=lambda x: x['title'])
-
-
-@pagelet_config(name='links.html', context=ILinkContainerTarget, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-@template_config(template='templates/container.pt', layer=IPyAMSLayer)
-@implementer(IInnerPage)
-class LinkContainerView(AdminView):
-    """Links container view"""
-
-    title = _("Useful links list")
-
-    def __init__(self, context, request):
-        super(LinkContainerView, self).__init__(context, request)
-        self.links_table = LinkContainerTable(context, request)
-
-    def update(self):
-        super(LinkContainerView, self).update()
-        self.links_table.update()
-
-
-class LinkContainerTable(BaseTable):
-    """Links container table"""
-
-    hide_header = True
-    cssClasses = {'table': 'table table-bordered table-striped table-hover table-tight'}
-
-    def __init__(self, context, request):
-        super(LinkContainerTable, self).__init__(context, request)
-        self.object_data = {'ams-widget-toggle-button': 'false'}
-        alsoProvides(self, IObjectData)
-
-    @property
-    def data_attributes(self):
-        attributes = super(LinkContainerTable, self).data_attributes
-        attributes['table'] = {'data-ams-location': absolute_url(ILinkContainer(self.context), self.request),
-                               'data-ams-datatable-sort': 'false',
-                               'data-ams-datatable-pagination': 'false'}
-        return attributes
-
-    @reify
-    def values(self):
-        return list(super(LinkContainerTable, self).values)
-
-    def render(self):
-        if not self.values:
-            translate = self.request.localizer.translate
-            return translate(_("No currently defined link."))
-        return super(LinkContainerTable, self).render()
-
-
-@adapter_config(name='name', context=(ILinkContainerTarget, IPyAMSLayer, LinkContainerTable), provides=IColumn)
-class LinkContainerNameColumn(I18nColumn, WfModifiedContentColumnMixin, GetAttrColumn):
-    """Links container name column"""
-
-    _header = _("Title")
-
-    weight = 10
-
-    def getValue(self, obj):
-        return II18n(obj).query_attribute('title', request=self.request)
-
-
-@adapter_config(name='target', context=(ILinkContainerTarget, IPyAMSLayer, LinkContainerTable), provides=IColumn)
-class LinkContainerTargetColumn(I18nColumn, GetAttrColumn):
-    """Links container target column"""
-
-    _header = _("Link target")
-
-    weight = 20
-
-    def getValue(self, obj):
-        if IInternalLink.providedBy(obj):
-            mapping = get_sequence_dict(obj.get_target())
-            return mapping['text']
-        else:
-            return html.escape(obj.get_url(self.request))
-
-
-@adapter_config(name='trash', context=(ILinkContainerTarget, IPyAMSLayer, LinkContainerTable), provides=IColumn)
-class LinkContainerTrashColumn(ProtectedFormObjectMixin, TrashColumn):
-    """Links container trash column"""
-
-
-@adapter_config(context=(ILinkContainerTarget, IPyAMSLayer, LinkContainerTable), provides=IValues)
-class LinkContainerValues(ContextRequestViewAdapter):
-    """Links container values"""
-
-    @property
-    def values(self):
-        return ILinkContainer(self.context).values()
-
-
-@adapter_config(context=(ILinkContainerTarget, IPyAMSLayer, LinkContainerView), provides=IPageHeader)
-class LinkHeaderAdapter(DefaultPageHeaderAdapter):
-    """Links container header adapter"""
-
-    back_url = '#properties.html'
-    icon_class = 'fa fa-fw fa-link'
-
-
-#
-# Links links edit form
-#
-
-@pagelet_config(name='link-links.html', context=ILinkLinksContainerTarget, layer=IPyAMSLayer,
-                permission=VIEW_SYSTEM_PERMISSION)
-class LinkLinksContainerLinksEditForm(AdminDialogEditForm):
-    """Links links container edit form"""
-
-    legend = _("Edit useful links links")
-
-    fields = field.Fields(ILinkLinksContainer)
-    fields['links'].widgetFactory = LinkLinksSelectFieldWidget
-
-    ajax_handler = 'link-links.json'
-    edit_permission = MANAGE_CONTENT_PERMISSION
-
-
-@view_config(name='link-links.json', context=ILinkLinksContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class LinkLinksContainerAJAXEditForm(AJAXEditForm, LinkLinksContainerLinksEditForm):
-    """Links links container edit form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        if 'links' in changes.get(ILinkLinksContainer, ()):
-            return {'status': 'success',
-                    'event': 'PyAMS_content.changed_item',
-                    'event_options': {'object_type': 'links_container',
-                                      'object_name': self.context.__name__,
-                                      'nb_links': len(ILinkLinksContainer(self.context).links or ())}}
-        else:
-            return super(LinkLinksContainerAJAXEditForm, self).get_ajax_output(changes)
--- a/src/pyams_content/component/links/zmi/templates/container.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-<div class="ams-widget">
-	<header>
-		<span tal:condition="view.widget_icon_class | nothing"
-			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
-		</span>
-		<h2 tal:content="view.title"></h2>
-		<tal:var content="structure provider:pyams.widget_title" />
-		<tal:var content="structure provider:pyams.toolbar" />
-	</header>
-	<div class="widget-body no-widget-toolbar">
-		<tal:var content="structure view.links_table.render()" />
-	</div>
-</div>
--- a/src/pyams_content/component/links/zmi/templates/widget-display.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,21 +0,0 @@
-<input type="hidden" autocomplete="off" readonly
-	tal:attributes="id view/id;
-					name view/name;
-					class string:select2 ${view/klass} ordered;
-					style view/style;
-					title view/title;
-					value python:','.join(view.value);
-					lang view/lang;
-					onclick view/onclick;
-					ondblclick view/ondblclick;
-					onmousedown view/onmousedown;
-					onmouseup view/onmouseup;
-					onmouseover view/onmouseover;
-					onmousemove view/onmousemove;
-					onmouseout view/onmouseout;
-					onkeypress view/onkeypress;
-					onkeydown view/onkeydown;
-					onkeyup view/onkeyup;
-					disabled view/disabled;
-					tabindex view/tabindex;
-					data-ams-select2-values view/values_map;" />
--- a/src/pyams_content/component/links/zmi/templates/widget-input.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,60 +0,0 @@
-<label class="input bordered with-icon" i18n:domain="pyams_content"
-	   data-ams-plugins="pyams_content"
-	   tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content')">
-	<div class="btn-group icon-append">
-		<i class="fa fa-fw fa-bars txt-color-green opaque" data-toggle="dropdown"
-			tal:attributes="data-ams-select2-target view/name"></i>
-		<ul class="dropdown-menu pull-right">
-			<li class="small">
-				<a data-ams-url="add-internal-link.html?origin=link"
-				   data-ams-stop-propagation="true" data-toggle="modal">
-					<i class="fa fa-fw fa-link"></i>
-					<span i18n:translate="">Add internal link...</span>
-				</a>
-			</li>
-			<li class="small">
-				<a data-ams-url="add-external-link.html?origin=link"
-				   data-ams-stop-propagation="true" data-toggle="modal">
-					<i class="fa fa-fw fa-external-link"></i>
-					<span i18n:translate="">Add external link...</span>
-				</a>
-			</li>
-			<li class="small">
-				<a data-ams-url="add-mailto-link.html?origin=link"
-				   data-ams-stop-propagation="true" data-toggle="modal">
-					<i class="fa fa-fw fa-envelope-o"></i>
-					<span i18n:translate="">Add mailto link...</span>
-				</a>
-			</li>
-		</ul>
-	</div>
-	<div class="select2-parent">
-		<input type="hidden" class="select2 ordered"
-				data-ams-events-handlers='{"select2-open": "PyAMS_content.links.init"}'
-				data-ams-select2-allow-clear="true"
-				data-ams-select2-multiple="false"
-				tal:attributes="id view/id;
-								name view/name;
-								class string:${view/klass} select2 ordered;
-								style view/style;
-								title view/title;
-								lang view/lang;
-								onclick view/onclick;
-								ondblclick view/ondblclick;
-								onmousedown view/onmousedown;
-								onmouseup view/onmouseup;
-								onmouseover view/onmouseover;
-								onmousemove view/onmousemove;
-								onmouseout view/onmouseout;
-								onkeypress view/onkeypress;
-								onkeydown view/onkeydown;
-								onkeyup view/onkeyup;
-								disabled view/disabled;
-								tabindex view/tabindex;
-								onfocus view/onfocus;
-								onblur view/onblur;
-								onchange view/onchange;
-								value python:','.join(view.value);
-								data-ams-select2-data view/values_data;" />
-	</div>
-</label>
--- a/src/pyams_content/component/links/zmi/templates/widget-list-display.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-<input type="hidden" autocomplete="off" readonly
-	data-ams-select2-multiple="true"
-	tal:attributes="id view/id;
-					name view/name;
-					class string:select2 ${view/klass} ordered;
-					style view/style;
-					title view/title;
-					value python:','.join(view.value);
-					lang view/lang;
-					onclick view/onclick;
-					ondblclick view/ondblclick;
-					onmousedown view/onmousedown;
-					onmouseup view/onmouseup;
-					onmouseover view/onmouseover;
-					onmousemove view/onmousemove;
-					onmouseout view/onmouseout;
-					onkeypress view/onkeypress;
-					onkeydown view/onkeydown;
-					onkeyup view/onkeyup;
-					disabled view/disabled;
-					tabindex view/tabindex;
-					data-ams-select2-values view/values_map;" />
--- a/src/pyams_content/component/links/zmi/templates/widget-list-input.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-<label class="input bordered with-icon" i18n:domain="pyams_content"
-	   data-ams-plugins="pyams_content"
-	   tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content')">
-	<div class="btn-group icon-append">
-		<i class="fa fa-fw fa-bars txt-color-green opaque" data-toggle="dropdown"
-			tal:attributes="data-ams-select2-target string:${view/name}:list"></i>
-		<ul class="dropdown-menu pull-right">
-			<li class="small">
-				<a data-ams-url="add-internal-link.html?origin=link"
-				   data-ams-stop-propagation="true" data-toggle="modal">
-					<i class="fa fa-fw fa-link"></i>
-					<span i18n:translate="">Add internal link...</span>
-				</a>
-			</li>
-			<li class="small">
-				<a data-ams-url="add-external-link.html?origin=link"
-				   data-ams-stop-propagation="true" data-toggle="modal">
-					<i class="fa fa-fw fa-external-link"></i>
-					<span i18n:translate="">Add external link...</span>
-				</a>
-			</li>
-			<li class="small">
-				<a data-ams-url="add-mailto-link.html?origin=link"
-				   data-ams-stop-propagation="true" data-toggle="modal">
-					<i class="fa fa-fw fa-envelope-o"></i>
-					<span i18n:translate="">Add mailto link...</span>
-				</a>
-			</li>
-		</ul>
-	</div>
-	<div class="select2-parent">
-		<select class="select2 ordered"
-				data-ams-select2-allow-clear="true"
-				tal:attributes="id view/id;
-								name string:${view/name}:list;
-								class string:${view/klass} select2 ordered;
-								style view/style;
-								title view/title;
-								lang view/lang;
-								onclick view/onclick;
-								ondblclick view/ondblclick;
-								onmousedown view/onmousedown;
-								onmouseup view/onmouseup;
-								onmouseover view/onmouseover;
-								onmousemove view/onmousemove;
-								onmouseout view/onmouseout;
-								onkeypress view/onkeypress;
-								onkeydown view/onkeydown;
-								onkeyup view/onkeyup;
-								disabled view/disabled;
-								tabindex view/tabindex;
-								onfocus view/onfocus;
-								onblur view/onblur;
-								onchange view/onchange;
-								multiple view/multiple;
-								size view/size">
-			<option tal:repeat="entry view/selectedItems"
-					tal:attributes="value entry/value;
-									selected python:entry['value'] in view.value;"
-					tal:content="entry/content"></option>
-			<option tal:repeat="entry view/notselectedItems"
-					tal:attributes="value entry/value;
-									selected python:entry['value'] in view.value;"
-					tal:content="entry/content"></option>
-		</select>
-	</div>
-</label>
--- a/src/pyams_content/component/links/zmi/widget.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-import json
-
-# import interfaces
-from pyams_skin.layer import IPyAMSLayer
-
-# import packages
-from pyams_form.widget import widgettemplate_config
-from z3c.form.browser.orderedselect import OrderedSelectWidget
-from z3c.form.widget import FieldWidget
-
-
-@widgettemplate_config(mode='input', template='templates/widget-input.pt', layer=IPyAMSLayer)
-@widgettemplate_config(mode='display', template='templates/widget-display.pt', layer=IPyAMSLayer)
-class SingleLinkLinkSelectWidget(OrderedSelectWidget):
-    """Single Link link select widget"""
-
-    @property
-    def values_data(self):
-        result = sorted([{'id': entry['value'], 'text': entry['content']} for entry in self.items],
-                        key=lambda x: x['text'])
-        return json.dumps(result)
-
-    @property
-    def values_map(self):
-        result = {}
-        [result.update({entry['value']: entry['content']}) for entry in self.selectedItems]
-        return json.dumps(result)
-
-
-def SingleLinkLinkSelectFieldWidget(field, request):
-    """Single link link select widget factory"""
-    return FieldWidget(field, SingleLinkLinkSelectWidget(request))
-
-
-@widgettemplate_config(mode='input', template='templates/widget-list-input.pt', layer=IPyAMSLayer)
-@widgettemplate_config(mode='display', template='templates/widget-list-display.pt', layer=IPyAMSLayer)
-class LinkLinksSelectWidget(OrderedSelectWidget):
-    """Multiple inks links select widget"""
-
-    @property
-    def values_map(self):
-        result = {}
-        [result.update({entry['value']: entry['content']}) for entry in self.selectedItems]
-        return json.dumps(result)
-
-
-def LinkLinksSelectFieldWidget(field, request):
-    """Links links select widget factory"""
-    return FieldWidget(field, LinkLinksSelectWidget(request))
--- a/src/pyams_content/component/paragraph/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -9,6 +9,7 @@
 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
 # FOR A PARTICULAR PURPOSE.
 #
+from pyams_utils.request import check_request
 
 __docformat__ = 'restructuredtext'
 
@@ -16,31 +17,78 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.paragraph.interfaces import IBaseParagraph, IHTMLParagraph
+from pyams_content.component.paragraph.interfaces import IBaseParagraph, IParagraphFactory, IParagraphContainerTarget, \
+    IParagraphContainer, IParagraphFactorySettings
 from pyams_content.shared.common.interfaces import IWfSharedContent
 from pyams_form.interfaces.form import IFormContextPermissionChecker
+from pyams_workflow.interfaces import IWorkflowState
 from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IObjectRemovedEvent
 
 # import packages
 from persistent import Persistent
 from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.registry import query_utility
 from pyams_utils.traversing import get_parent
+from pyams_utils.vocabulary import vocabulary_config
 from pyramid.events import subscriber
 from pyramid.threadlocal import get_current_registry
+from zope.component.globalregistry import getGlobalSiteManager
 from zope.container.contained import Contained
 from zope.interface import implementer
 from zope.lifecycleevent import ObjectModifiedEvent
 from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
 
 
+#
+# Auto-creation of default paragraphs
+#
+
+@subscriber(IObjectAddedEvent, context_selector=IParagraphContainerTarget)
+def handle_new_paragraphs_container(event):
+    """Handle new paragraphs container"""
+    container = IParagraphContainer(event.object)
+    content = get_parent(container, IWfSharedContent)
+    version_state = IWorkflowState(content, None) if content is not None else None
+    if (version_state is None) or (version_state.version_id == 1):
+        # only apply to first version
+        settings = get_parent(container, IParagraphFactorySettings)
+        if settings is not None:
+            for factory_name in settings.auto_created_paragraphs or ():
+                factory = query_utility(IParagraphFactory, name=factory_name)
+                if factory is not None:
+                    container.append(factory.content_type())
+
+
+#
+# Base paragraph classes and subscribers
+#
+
 @implementer(IBaseParagraph)
 class BaseParagraph(Persistent, Contained):
     """Base paragraph persistent class"""
 
+    icon_class = ''
+    icon_hint = ''
+
     visible = FieldProperty(IBaseParagraph['visible'])
     title = FieldProperty(IBaseParagraph['title'])
 
 
+@vocabulary_config(name='PyAMS paragraph factories')
+class ParagraphFactoriesVocabulary(SimpleVocabulary):
+    """Paragraph factories vocabulary"""
+
+    def __init__(self, context=None):
+        request = check_request()
+        registry = request.registry
+        translate = request.localizer.translate
+        terms = sorted([SimpleTerm(name, title=translate(util.name))
+                        for name, util in registry.getUtilitiesFor(IParagraphFactory)],
+                       key=lambda x: x.title)
+        super(ParagraphFactoriesVocabulary, self).__init__(terms)
+
+
 @adapter_config(context=IBaseParagraph, provides=IFormContextPermissionChecker)
 class BaseParagraphPermissionChecker(ContextAdapter):
     """Paragraph permission checker"""
--- a/src/pyams_content/component/paragraph/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/container.py	Mon Sep 11 14:54:30 2017 +0200
@@ -37,9 +37,9 @@
 
     last_id = 1
 
-    def __setitem__(self, key, value):
+    def append(self, value):
         key = str(self.last_id)
-        super(ParagraphContainer, self).__setitem__(key, value)
+        self[key] = value
         self.last_id += 1
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/paragraph/header.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,53 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IParagraphFactory
+from pyams_content.component.paragraph.interfaces.header import IHeaderParagraph
+from pyams_i18n.interfaces import II18n
+
+# import packages
+from pyams_content.component.paragraph import BaseParagraph
+from pyams_utils.registry import utility_config
+from pyams_utils.text import get_text_start
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+from pyams_content import _
+
+
+@implementer(IHeaderParagraph)
+class HeaderParagraph(BaseParagraph):
+    """Header paragraph"""
+
+    icon_class = 'fa-header'
+    icon_hint = _("Header")
+
+    @property
+    def title(self):
+        header = II18n(self).query_attribute('header')
+        return get_text_start(header, 50, 10)
+
+    header = FieldProperty(IHeaderParagraph['header'])
+
+
+@utility_config(name='Header paragraph', provides=IParagraphFactory)
+class HTMLParagraphFactory(object):
+    """HTML paragraph factory"""
+
+    name = _("Header paragraph")
+    content_type = HeaderParagraph
--- a/src/pyams_content/component/paragraph/html.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/html.py	Mon Sep 11 14:54:30 2017 +0200
@@ -14,25 +14,134 @@
 
 
 # import standard library
+import re
 
 # import interfaces
-from pyams_content.component.extfile.interfaces import IExtFileLinksContainerTarget
-from pyams_content.component.gallery.interfaces import IGalleryLinksContainerTarget
-from pyams_content.component.links.interfaces import ILinkLinksContainerTarget
-from pyams_content.component.paragraph.interfaces import IHTMLParagraph
+from pyams_content.component.association.interfaces import IAssociationContainer
+from pyams_content.component.extfile.interfaces import IExtFileContainerTarget, IBaseExtFile
+from pyams_content.component.illustration.interfaces import IIllustrationTarget
+from pyams_content.component.links.interfaces import ILinkContainerTarget, IInternalLink, IExternalLink, IMailtoLink
+from pyams_content.component.paragraph.interfaces import IParagraphFactory
+from pyams_content.component.paragraph.interfaces.html import IHTMLParagraph
+from pyams_i18n.interfaces import II18n
+from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectModifiedEvent
 
 # import packages
+from pyams_content.component.links import InternalLink, ExternalLink, MailtoLink
 from pyams_content.component.paragraph import BaseParagraph
+from pyams_utils.registry import utility_config
+from pyams_utils.request import check_request
+from pyams_utils.url import absolute_url
+from pyquery import PyQuery
+from pyramid.events import subscriber
+from pyramid.threadlocal import get_current_registry
 from zope.interface import implementer
+from zope.lifecycleevent import ObjectCreatedEvent
 from zope.schema.fieldproperty import FieldProperty
 
+from pyams_content import _
+
 
 #
 # HTML paragraph
 #
 
-@implementer(IHTMLParagraph, IExtFileLinksContainerTarget, ILinkLinksContainerTarget, IGalleryLinksContainerTarget)
+@implementer(IHTMLParagraph, IIllustrationTarget, IExtFileContainerTarget, ILinkContainerTarget)
 class HTMLParagraph(BaseParagraph):
     """HTML paragraph"""
 
+    icon_class = 'fa-html5'
+    icon_hint = _("HTML paragraph")
+
     body = FieldProperty(IHTMLParagraph['body'])
+
+
+@utility_config(name='HTML paragraph', provides=IParagraphFactory)
+class HTMLParagraphFactory(object):
+    """HTML paragraph factory"""
+
+    name = _("HTML paragraph")
+    content_type = HTMLParagraph
+
+
+FULL_EMAIL = re.compile('(.*) \<(.*)\>')
+
+
+def check_associations(context, body, lang, notify=True):
+    """Check for link associations from HTML content"""
+    registry = get_current_registry()
+    associations = IAssociationContainer(context)
+    html = PyQuery('<html>{0}</html>'.format(body))
+    for link in html('a[href]'):
+        link_info = None
+        has_link = False
+        href = link.attrib['href']
+        if href.startswith('oid://'):
+            oid = href.split('//', 1)[1]
+            for association in associations.values():
+                internal_info = IInternalLink(association, None)
+                if (internal_info is not None) and (internal_info.reference == oid):
+                    has_link = True
+                    break
+            if not has_link:
+                link_info = InternalLink()
+                link_info.visible = False
+                link_info.reference = oid
+                link_info.title = {lang: link.attrib.get('title') or link.text}
+        elif href.startswith('mailto:'):
+            name = None
+            email = href[7:]
+            if '<' in email:
+                groups = FULL_EMAIL.findall(email)
+                if groups:
+                    name, email = groups[0]
+            for association in associations.values():
+                mailto_info = IMailtoLink(association, None)
+                if (mailto_info is not None) and (mailto_info.address == email):
+                    has_link = True
+                    break
+            if not has_link:
+                link_info = MailtoLink()
+                link_info.visible = False
+                link_info.address = email
+                link_info.address_name = name or email
+                link_info.title = {lang: link.attrib.get('title') or link.text}
+        else:
+            for association in associations.values():
+                external_info = IExternalLink(association, None)
+                if (external_info is not None) and (external_info.url == href):
+                    has_link = True
+                    break
+                else:
+                    extfile_info = IBaseExtFile(association, None)
+                    if extfile_info is not None:
+                        request = check_request()
+                        extfile_url = absolute_url(II18n(extfile_info).query_attribute('data', request=request),
+                                                   request=request)
+                        if extfile_url.endswith(href):
+                            has_link = True
+                            break
+            if not has_link:
+                link_info = ExternalLink()
+                link_info.visible = False
+                link_info.url = href
+                link_info.title = {lang: link.attrib.get('title') or link.text}
+        if link_info is not None:
+            registry.notify(ObjectCreatedEvent(link_info))
+            associations.append(link_info, notify=notify)
+
+
+@subscriber(IObjectAddedEvent, context_selector=IHTMLParagraph)
+def handle_added_html_paragraph(event):
+    """Check for new associations from added paragraph"""
+    paragraph = event.object
+    for lang, body in (paragraph.body or {}).items():
+        check_associations(paragraph, body, lang, notify=False)
+
+
+@subscriber(IObjectModifiedEvent, context_selector=IHTMLParagraph)
+def handle_modified_html_paragraph(event):
+    """Check for new associations from modified paragraph"""
+    paragraph = event.object
+    for lang, body in (paragraph.body or {}).items():
+        check_associations(paragraph, body, lang, notify=False)
--- a/src/pyams_content/component/paragraph/illustration.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
-# import interfaces
-from pyams_content.component.paragraph.interfaces import IIllustrationParagraph, IIllustrationRenderer
-from pyams_file.interfaces import DELETED_FILE, IResponsiveImage
-
-# import packages
-from pyams_content.component.paragraph import BaseParagraph
-from pyams_file.property import FileProperty
-from pyams_utils.request import check_request
-from pyams_utils.vocabulary import vocabulary_config
-from zope.interface import implementer, alsoProvides
-from zope.schema.fieldproperty import FieldProperty
-from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
-
-
-#
-# Illustration
-#
-
-@implementer(IIllustrationParagraph)
-class Illustration(BaseParagraph):
-    """Illustration class"""
-
-    _data = FileProperty(IIllustrationParagraph['data'])
-    legend = FieldProperty(IIllustrationParagraph['legend'])
-    renderer = FieldProperty(IIllustrationParagraph['renderer'])
-
-    @property
-    def data(self):
-        return self._data
-
-    @data.setter
-    def data(self, value):
-        self._data = value
-        if (value is not None) and (value is not DELETED_FILE):
-            alsoProvides(self._data, IResponsiveImage)
-
-
-@vocabulary_config(name='PyAMS illustration renderers')
-class IllustrationRendererVocabulary(SimpleVocabulary):
-    """Illustration renderer utilities vocabulary"""
-
-    def __init__(self, context=None):
-        request = check_request()
-        translate = request.localizer.translate
-        registry = request.registry
-        context = Illustration()
-        terms = [SimpleTerm(name, title=translate(adapter.label))
-                 for name, adapter in sorted(registry.getAdapters((context, request), IIllustrationRenderer),
-                                             key=lambda x: x[1].weight)]
-        super(IllustrationRendererVocabulary, self).__init__(terms)
--- a/src/pyams_content/component/paragraph/interfaces/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/interfaces/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -21,11 +21,10 @@
 from zope.contentprovider.interfaces import IContentProvider
 
 # import packages
-from pyams_file.schema import ImageField
-from pyams_i18n.schema import I18nTextLineField, I18nHTMLField
+from pyams_i18n.schema import I18nTextLineField
 from zope.container.constraints import containers, contains
 from zope.interface import Interface, Attribute
-from zope.schema import Bool, Choice
+from zope.schema import Bool, List, Choice
 
 from pyams_content import _
 
@@ -38,12 +37,15 @@
 
     containers('.IParagraphContainer')
 
+    icon_class = Attribute("Icon class in paragraphs list")
+    icon_hint = Attribute("Icon hint in paragraphs list")
+
     visible = Bool(title=_("Visible?"),
                    description=_("Is this paragraph visible in front-office?"),
                    required=True,
                    default=True)
 
-    title = I18nTextLineField(title=_("Title"),
+    title = I18nTextLineField(title=_("§ Title"),
                               description=_("Paragraph title"),
                               required=False)
 
@@ -53,46 +55,34 @@
 
     contains(IBaseParagraph)
 
+    def append(self, value):
+        """Add given value to container"""
+
 
 class IParagraphContainerTarget(Interface):
     """Paragraphs container marker interface"""
 
 
+class IParagraphFactory(Interface):
+    """Paragraph factory utility interface"""
+
+    name = Attribute("Factory name")
+    content_type = Attribute("Factory content type")
+
+
+class IParagraphFactorySettings(Interface):
+    """Paragraph factory settings interface
+
+    This interface is used to defined default auto-created paragraphs
+    for a given shared tool."""
+
+    auto_created_paragraphs = List(title=_("Default paragraphs"),
+                                   description=_("List of paragraphs automatically added to a new content"),
+                                   required=False,
+                                   value_type=Choice(vocabulary='PyAMS paragraph factories'))
+
+
 class IParagraphSummary(IContentProvider):
     """Paragraph summary renderer"""
 
     language = Attribute("Summary language")
-
-
-#
-# HTML paragraph
-#
-
-class IHTMLParagraph(IBaseParagraph):
-    """HTML body paragraph"""
-
-    body = I18nHTMLField(title=_("Body"),
-                         required=False)
-
-
-#
-# Illustration
-#
-
-class IIllustrationRenderer(IContentProvider):
-    """Illustration renderer utility interface"""
-
-    label = Attribute("Renderer label")
-
-
-class IIllustrationParagraph(IBaseParagraph):
-    """Illustration paragraph"""
-
-    data = ImageField(title=_("Image data"),
-                      required=True)
-
-    legend = I18nTextLineField(title=_("Legend"),
-                               required=False)
-
-    renderer = Choice(title=_("Image style"),
-                      vocabulary='PyAMS illustration renderers')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/paragraph/interfaces/header.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,35 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IBaseParagraph
+
+# import packages
+from pyams_i18n.schema import I18nHTMLField, I18nTextField
+
+from pyams_content import _
+
+
+#
+# HTML paragraph
+#
+
+class IHeaderParagraph(IBaseParagraph):
+    """Header paragraph"""
+
+    header = I18nTextField(title=_("Header"),
+                           required=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/paragraph/interfaces/html.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,35 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IBaseParagraph
+
+# import packages
+from pyams_i18n.schema import I18nHTMLField
+
+from pyams_content import _
+
+
+#
+# HTML paragraph
+#
+
+class IHTMLParagraph(IBaseParagraph):
+    """HTML body paragraph"""
+
+    body = I18nHTMLField(title=_("Body"),
+                         required=False)
--- a/src/pyams_content/component/paragraph/zmi/__init__.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/zmi/__init__.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,27 +16,84 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.paragraph.interfaces import IBaseParagraph
+from pyams_content.component.paragraph.interfaces import IBaseParagraph, IParagraphFactorySettings
+from pyams_content.interfaces import MANAGE_TOOL_PERMISSION
+from pyams_form.interfaces.form import IFormHelp
 from pyams_i18n.interfaces import II18n
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.interfaces.menu import IPropertiesMenu
+from pyams_zmi.layer import IAdminLayer
 
 # import packages
 from pyams_form.form import AJAXEditForm
+from pyams_form.help import FormHelp
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_utils.adapter import adapter_config
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogEditForm
+from pyramid.view import view_config
+from z3c.form import field
 
+from pyams_content import _
+
+
+#
+# Default paragraphs settings
+#
+
+@viewlet_config(name='default-paragraphs.menu', context=IParagraphFactorySettings, layer=IAdminLayer,
+                manager=IPropertiesMenu, permission=MANAGE_TOOL_PERMISSION, weight=5)
+class DefaultParagraphsSettingsMenu(MenuItem):
+    """Default paragraphs settings menu"""
+
+    label = _("Default paragraphs...")
+    icon_class = 'fa-paragraph'
+    url = 'default-paragraphs.html'
+    modal_target = True
+
+
+@pagelet_config(name='default-paragraphs.html', context=IParagraphFactorySettings, layer=IPyAMSLayer,
+                permission=MANAGE_TOOL_PERMISSION)
+class DefaultParagraphsEditForm(AdminDialogEditForm):
+    """Default paragraphs edit form"""
+
+    legend = _("Default paragraphs")
+
+    fields = field.Fields(IParagraphFactorySettings)
+    ajax_handler = 'default-paragraphs.json'
+    edit_permission = MANAGE_TOOL_PERMISSION
+
+
+@view_config(name='default-paragraphs.json', context=IParagraphFactorySettings, request_type=IPyAMSLayer,
+             permission=MANAGE_TOOL_PERMISSION, renderer='json', xhr=True)
+class DefaultParagraphAJAXEditForm(AJAXEditForm, DefaultParagraphsEditForm):
+    """Default paragraphs edit form, JSON renderer"""
+
+
+@adapter_config(context=(IParagraphFactorySettings, IPyAMSLayer, DefaultParagraphsEditForm), provides=IFormHelp)
+class DefaultParagraphsEditFormHelp(FormHelp):
+    """Default paragraphs edit form help"""
+
+    message = _("These paragraphs will be added automatically to any new created content.")
+    message_format = 'rest'
+
+
+#
+# Base paragraph add form
+#
 
 class BaseParagraphAJAXEditForm(AJAXEditForm):
     """Base paragraph AJAX edit form"""
 
     def get_ajax_output(self, changes):
-        updated = changes.get(IBaseParagraph, ())
-        if ('title' in updated) or ('visible' in updated):
-            return {'status': 'success',
-                    'event': 'PyAMS_content.changed_item',
-                    'event_options': {'object_type': 'paragraph',
-                                      'object_name': self.context.__name__,
-                                      'title': II18n(self.context).query_attribute('title', request=self.request),
-                                      'visible': self.context.visible}}
+        output = super(BaseParagraphAJAXEditForm, self).get_ajax_output(changes)
         if 'title' in changes.get(IBaseParagraph, ()):
-            return {'status': 'reload',
-                    'location': '#paragraphs.html'}
-        else:
-            return super(BaseParagraphAJAXEditForm, self).get_ajax_output(changes)
+            output.setdefault('events', []).append({
+                'event': 'PyAMS_content.changed_item',
+                'options': {'object_type': 'paragraph',
+                            'object_name': self.context.__name__,
+                            'title': II18n(self.context).query_attribute('title', request=self.request),
+                            'visible': self.context.visible}
+            })
+        return output
--- a/src/pyams_content/component/paragraph/zmi/container.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/zmi/container.py	Mon Sep 11 14:54:30 2017 +0200
@@ -9,6 +9,7 @@
 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
 # FOR A PARTICULAR PURPOSE.
 #
+from pyams_content.component.association.zmi import AssociationsContainerView
 
 __docformat__ = 'restructuredtext'
 
@@ -18,9 +19,9 @@
 
 # import interfaces
 from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_content.component.extfile.interfaces import IExtFileLinksContainerTarget, IExtFileLinksContainer
-from pyams_content.component.gallery.interfaces import IGalleryLinksContainerTarget, IGalleryLinksContainer
-from pyams_content.component.links.interfaces import ILinkLinksContainerTarget, ILinkLinksContainer
+from pyams_content.component.association.interfaces import IAssociationContainer
+from pyams_content.component.extfile.interfaces import IExtFileContainerTarget, IExtFile
+from pyams_content.component.links.interfaces import ILinkContainerTarget, IBaseLink
 from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IParagraphContainer, IBaseParagraph
 from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor, IParagraphTitleToolbar
 from pyams_form.interfaces.form import IFormSecurityContext
@@ -38,7 +39,7 @@
 from pyams_form.security import ProtectedFormObjectMixin
 from pyams_pagelet.pagelet import pagelet_config
 from pyams_skin.page import DefaultPageHeaderAdapter
-from pyams_skin.table import BaseTable, I18nColumn, TrashColumn, ActionColumn, JsActionColumn
+from pyams_skin.table import BaseTable, I18nColumn, TrashColumn, JsActionColumn, SorterColumn, ImageColumn
 from pyams_skin.viewlet.menu import MenuItem
 from pyams_template.template import template_config
 from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
@@ -131,21 +132,9 @@
 
 @adapter_config(name='sorter', context=(IParagraphContainerTarget, IPyAMSLayer, ParagraphContainerTable),
                 provides=IColumn)
-class ParagraphContainerSorterColumn(ProtectedFormObjectMixin, ActionColumn):
+class ParagraphContainerSorterColumn(ProtectedFormObjectMixin, SorterColumn):
     """Paragraphs container sorter column"""
 
-    cssClasses = {'th': 'action',
-                  'td': 'action sorter'}
-
-    icon_class = 'fa fa-fw fa-sort'
-    icon_hint = _("Click and drag to sort paragraphs...")
-
-    url = '#'
-    weight = 1
-
-    def get_url(self, item):
-        return '#'
-
 
 @adapter_config(name='show-hide', context=(IParagraphContainerTarget, IPyAMSLayer, ParagraphContainerTable),
                 provides=IColumn)
@@ -176,6 +165,20 @@
             return super(ParagraphContainerShowHideColumn, self).renderCell(item)
 
 
+@adapter_config(name='pictogram', context=(IParagraphContainerTarget, IPyAMSLayer, ParagraphContainerTable),
+                provides=IColumn)
+class ParagraphContainerPictogramColumn(ImageColumn):
+    """Paragraph container pictogram column"""
+
+    weight = 6
+
+    def get_icon_class(self, item):
+        return item.icon_class
+
+    def get_icon_hint(self, item):
+        return self.request.localizer.translate(item.icon_hint)
+
+
 @adapter_config(context=ParagraphContainerShowHideColumn, provides=IFormSecurityContext)
 def ShowHideColumnSecurityContextFactory(column):
     """Show/hide column security context factory"""
@@ -190,58 +193,14 @@
     """Paragraph title toolbar viewlet manager"""
 
 
-@viewlet_config(name='files', context=IExtFileLinksContainerTarget, layer=IPyAMSLayer, view=ParagraphContainerTable,
-                manager=IParagraphTitleToolbar, permission=VIEW_SYSTEM_PERMISSION, weight=10)
-@template_config(template='templates/paragraph-title-icon.pt', layer=IPyAMSLayer)
-class ParagraphContainerExtFileLinksAction(Viewlet):
-    """Paragraph container external files links column"""
-
-    action_class = 'action extfiles nowrap width-40'
-    icon_class = 'fa fa-fw fa-file-text-o'
-    icon_hint = _("External files")
-
-    url = 'extfile-links.html'
-    modal_target = True
-
-    @property
-    def count(self):
-        return len(IExtFileLinksContainer(self.context).files or ())
-
-
-@viewlet_config(name='links', context=ILinkLinksContainerTarget, layer=IPyAMSLayer, view=ParagraphContainerTable,
-                manager=IParagraphTitleToolbar, permission=VIEW_SYSTEM_PERMISSION, weight=20)
-@template_config(template='templates/paragraph-title-icon.pt', layer=IPyAMSLayer)
-class ParagraphContainerLinkLinksAction(Viewlet):
-    """Paragraph container links links column"""
-
-    action_class = 'action links nowrap width-40'
-    icon_class = 'fa fa-fw fa-link'
-    icon_hint = _("Useful links")
-
-    url = 'link-links.html'
-    modal_target = True
-
-    @property
-    def count(self):
-        return len(ILinkLinksContainer(self.context).links or ())
-
-
-@viewlet_config(name='gallery', context=IGalleryLinksContainerTarget, layer=IPyAMSLayer, view=ParagraphContainerTable,
-                manager=IParagraphTitleToolbar, permission=VIEW_SYSTEM_PERMISSION, weight=30)
-@template_config(template='templates/paragraph-title-icon.pt', layer=IPyAMSLayer)
-class ParagraphContainerGalleryLinksAction(Viewlet):
-    """Paragraph container gallery links column"""
-
-    action_class = 'action galleries nowrap width-40'
-    icon_class = 'fa fa-fw fa-picture-o'
-    icon_hint = _("Images galleries")
-
-    url = 'gallery-links.html'
-    modal_target = True
-
-    @property
-    def count(self):
-        return len(IGalleryLinksContainer(self.context).galleries or ())
+def getParagraphTitleHints(item, request, table):
+    """Get paragraphs column title hints"""
+    registry = request.registry
+    provider = registry.queryMultiAdapter((item, request, table), IContentProvider,
+                                          name='pyams_paragraph.title_toolbar')
+    if provider is not None:
+        provider.update()
+        return provider.render()
 
 
 @adapter_config(name='name', context=(IParagraphContainerTarget, IPyAMSLayer, ParagraphContainerTable),
@@ -249,7 +208,7 @@
 class ParagraphContainerTitleColumn(I18nColumn, WfModifiedContentColumnMixin, GetAttrColumn):
     """Paragraph container title column"""
 
-    _header = _("Title")
+    _header = _("Show/hide all paragraphs")
 
     weight = 50
 
@@ -258,30 +217,24 @@
                '      data-ams-stop-propagation="true"' \
                '      data-ams-click-handler="PyAMS_content.paragraphs.switchAllEditors">' \
                '    <i class="fa fa-plus-square-o"></i>' \
-               '</span> '.format(
+               '</span>&nbsp;&nbsp;&nbsp;'.format(
                     title=self.request.localizer.translate(_("Click to open/close all paragraphs editors"))) + \
                super(ParagraphContainerTitleColumn, self).renderHeadCell()
 
     def renderCell(self, item):
-        registry = self.request.registry
-        provider = registry.queryMultiAdapter((item, self.request, self.table), IContentProvider,
-                                              name='pyams_paragraph.title_toolbar')
-        if provider is not None:
-            provider.update()
-            provider = provider.render()
-        else:
-            provider = ''
+        provider = getParagraphTitleHints(item, self.request, self.table) or ''
         return '<div>{provider}<span class="small hint" title="{title}" data-ams-hint-gravity="e"' \
                '      data-ams-stop-propagation="true" ' \
                '      data-ams-click-handler="PyAMS_content.paragraphs.switchEditor">' \
                '    <i class="fa fa-plus-square-o"></i>' \
-               '</span> '.format(provider=provider,
-                                 title=self.request.localizer.translate(_("Click to open/close paragraph editor"))) + \
+               '</span>&nbsp;&nbsp;&nbsp;'.format(
+                    provider=provider,
+                    title=self.request.localizer.translate(_("Click to open/close paragraph editor"))) + \
                '<span class="title">{0}</span>'.format(super(ParagraphContainerTitleColumn, self).renderCell(item)) + \
                '</div><div class="inner-table-form editor margin-x-10 margin-bottom-0"></div>'
 
     def getValue(self, obj):
-        return II18n(obj).query_attribute('title', request=self.request) or '--'
+        return II18n(obj).query_attribute('title', request=self.request) or ' - - - - - - - -'
 
 
 @adapter_config(name='trash', context=(IParagraphContainerTarget, IPyAMSLayer, ParagraphContainerTable),
@@ -304,17 +257,47 @@
     """Paragraphs container header adapter"""
 
     back_url = '#properties.html'
-
     icon_class = 'fa fa-fw fa-paragraph'
 
 
-@view_config(name='set-paragraphs-order.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
+@viewlet_config(name='links', context=ILinkContainerTarget, layer=IPyAMSLayer, view=ParagraphContainerTable,
+                manager=IParagraphTitleToolbar, permission=VIEW_SYSTEM_PERMISSION, weight=10)
+@template_config(template='templates/paragraph-title-icon.pt', layer=IPyAMSLayer)
+class ParagraphContainerLinksCounter(Viewlet):
+    """Paragraph container external links count column"""
+
+    action_class = 'action links nowrap width-40'
+    icon_class = 'fa fa-fw fa-link'
+    icon_hint = _("Links")
+
+    @property
+    def count(self):
+        return len([file for file in IAssociationContainer(self.context).values()
+                    if IBaseLink.providedBy(file)])
+
+
+@viewlet_config(name='files', context=IExtFileContainerTarget, layer=IPyAMSLayer, view=ParagraphContainerTable,
+                manager=IParagraphTitleToolbar, permission=VIEW_SYSTEM_PERMISSION, weight=10)
+@template_config(template='templates/paragraph-title-icon.pt', layer=IPyAMSLayer)
+class ParagraphContainerExtFileCounter(Viewlet):
+    """Paragraph container external files count column"""
+
+    action_class = 'action extfiles nowrap width-40'
+    icon_class = 'fa fa-fw fa-file-text-o'
+    icon_hint = _("External files")
+
+    @property
+    def count(self):
+        return len([file for file in IAssociationContainer(self.context).values()
+                    if IExtFile.providedBy(file)])
+
+
+@view_config(name='set-paragraphs-order.json', context=IParagraphContainer, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
 def set_paragraphs_order(request):
     """Update paragraphs order"""
-    container = IParagraphContainer(request.context)
     order = list(map(str, json.loads(request.params.get('names'))))
-    container.updateOrder(order)
+    request.context.updateOrder(order)
     return {'status': 'success'}
 
 
@@ -363,3 +346,44 @@
             editor.update()
             result[key] = editor.render()
     return result
+
+
+#
+# Paragraphs associations view
+#
+
+@viewlet_config(name='paragraphs-associations.menu', context=IParagraphContainerTarget, layer=IAdminLayer,
+                manager=IPropertiesMenu, permission=VIEW_SYSTEM_PERMISSION, weight=101)
+class ParagraphsAssociationsMenu(MenuItem):
+    """Paragraphs associations container menu"""
+
+    label = _("Associations...")
+    icon_class = 'fa-link'
+    url = '#paragraphs-associations.html'
+
+
+@pagelet_config(name='paragraphs-associations.html', context=IParagraphContainerTarget, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+@template_config(template='templates/associations.pt', layer=IPyAMSLayer)
+@implementer(IInnerPage)
+class ParagraphsAssociationsView(AdminView):
+    """Paragraphs associations view"""
+
+    title = _("Paragraphs associations")
+
+    @reify
+    def associations(self):
+        result = []
+        for paragraph in IParagraphContainer(self.context).values():
+            associations = IAssociationContainer(paragraph, None)
+            if associations is not None:
+                view = AssociationsContainerView(paragraph, self.request)
+                view.widget_icon_class = 'fa fa-fw {0}'.format(paragraph.icon_class)
+                view.title = II18n(paragraph).query_attribute('title', request=self.request) or ' - - - - - - - -'
+                result.append(view)
+        return result
+
+    def update(self):
+        super(ParagraphsAssociationsView, self).update()
+        for association in self.associations:
+            association.update()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/paragraph/zmi/header.py	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,174 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IParagraphContainer, \
+    IParagraphSummary
+from pyams_content.component.paragraph.interfaces.header import IHeaderParagraph
+from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor
+from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_form.interfaces.form import IInnerForm, IEditFormButtons
+from pyams_i18n.interfaces import II18n
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from z3c.form.interfaces import INPUT_MODE
+
+# import packages
+from pyams_content.component.paragraph.header import HeaderParagraph
+from pyams_content.component.paragraph.zmi import BaseParagraphAJAXEditForm
+from pyams_content.component.paragraph.zmi.container import ParagraphContainerView
+from pyams_form.form import AJAXAddForm
+from pyams_form.security import ProtectedFormObjectMixin
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_template.template import template_config, get_view_template
+from pyams_utils.adapter import adapter_config, ContextRequestAdapter
+from pyams_utils.traversing import get_parent
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-header-paragraph.menu', context=IParagraphContainerTarget, view=ParagraphContainerView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=40)
+class HeaderParagraphAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
+    """Header paragraph add menu"""
+
+    label = _("Add header paragraph...")
+    label_css_class = 'fa fa-fw fa-header'
+    url = 'add-header-paragraph.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-header-paragraph.html', context=IParagraphContainerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class HeaderParagraphAddForm(AdminDialogAddForm):
+    """Header paragraph add form"""
+
+    legend = _("Add new header paragraph")
+    icon_css_class = 'fa fa-fw fa-header'
+
+    fields = field.Fields(IHeaderParagraph).select('header')
+    ajax_handler = 'add-header-paragraph.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(HeaderParagraphAddForm, self).updateWidgets(prefix)
+        if 'header' in self.widgets:
+            self.widgets['header'].widget_css_class = 'textarea height-100'
+
+    def create(self, data):
+        return HeaderParagraph()
+
+    def add(self, object):
+        IParagraphContainer(self.context).append(object)
+
+
+@view_config(name='add-header-paragraph.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class HeaderParagraphAJAXAddForm(AJAXAddForm, HeaderParagraphAddForm):
+    """Header paragraph add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'reload',
+                'location': '#paragraphs.html'}
+
+
+@pagelet_config(name='properties.html', context=IHeaderParagraph, layer=IPyAMSLayer,
+                permission=MANAGE_CONTENT_PERMISSION)
+class HeaderParagraphPropertiesEditForm(AdminDialogEditForm):
+    """Header paragraph properties edit form"""
+
+    @property
+    def title(self):
+        content = get_parent(self.context, IWfSharedContent)
+        return II18n(content).query_attribute('title', request=self.request)
+
+    legend = _("Edit header paragraph properties")
+    icon_css_class = 'fa fa-fw fa-header'
+
+    fields = field.Fields(IHeaderParagraph).select('header')
+    ajax_handler = 'properties.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(HeaderParagraphPropertiesEditForm, self).updateWidgets(prefix)
+        if 'header' in self.widgets:
+            self.widgets['header'].widget_css_class = 'textarea height-100'
+
+
+@view_config(name='properties.json', context=IHeaderParagraph, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class HeaderParagraphPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, HeaderParagraphPropertiesEditForm):
+    """Header paragraph properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(HeaderParagraphPropertiesAJAXEditForm, self).get_ajax_output(changes)
+        if 'header' in changes.get(IHeaderParagraph, ()):
+            output.setdefault('events', []).append({
+                'event': 'PyAMS_content.changed_item',
+                'options': {'object_type': 'paragraph',
+                            'object_name': self.context.__name__,
+                            'title': II18n(self.context).query_attribute('title', request=self.request),
+                            'visible': self.context.visible}
+            })
+        return output
+
+
+@adapter_config(context=(IHeaderParagraph, IPyAMSLayer), provides=IParagraphInnerEditor)
+@implementer(IInnerForm)
+class HeaderParagraphInnerEditForm(HeaderParagraphPropertiesEditForm):
+    """Header paragraph inner edit form"""
+
+    legend = None
+    label_css_class = 'control-label col-md-2'
+    input_css_class = 'col-md-10'
+
+    @property
+    def buttons(self):
+        if self.mode == INPUT_MODE:
+            return button.Buttons(IEditFormButtons)
+        else:
+            return button.Buttons()
+
+
+#
+# HTML paragraph summary
+#
+
+@adapter_config(context=(IHeaderParagraph, IPyAMSLayer), provides=IParagraphSummary)
+@template_config(template='templates/header-summary.pt', layer=IPyAMSLayer)
+class HeaderParagraphSummary(ContextRequestAdapter):
+    """Header paragraph renderer"""
+
+    language = None
+
+    def update(self):
+        i18n = II18n(self.context)
+        if self.language:
+            for attr in ('header', ):
+                setattr(self, attr, i18n.get_attribute(attr, self.language, request=self.request))
+        else:
+            for attr in ('header', ):
+                setattr(self, attr, i18n.query_attribute(attr, request=self.request))
+
+    render = get_view_template()
--- a/src/pyams_content/component/paragraph/zmi/html.py	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/zmi/html.py	Mon Sep 11 14:54:30 2017 +0200
@@ -16,8 +16,11 @@
 # import standard library
 
 # import interfaces
-from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IHTMLParagraph, \
-    IParagraphContainer, IParagraphSummary
+from pyams_content.component.association.zmi.interfaces import IAssociationsParentForm
+from pyams_content.component.illustration.interfaces import IIllustration, IIllustrationRenderer
+from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IParagraphContainer, \
+    IParagraphSummary
+from pyams_content.component.paragraph.interfaces.html import IHTMLParagraph
 from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor
 from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
 from pyams_content.shared.common.interfaces import IWfSharedContent
@@ -25,9 +28,11 @@
 from pyams_i18n.interfaces import II18n
 from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
 from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.interfaces import IPropertiesEditForm
 from z3c.form.interfaces import INPUT_MODE
 
 # import packages
+from pyams_content.component.association.zmi import AssociationsTable
 from pyams_content.component.paragraph.html import HTMLParagraph
 from pyams_content.component.paragraph.zmi import BaseParagraphAJAXEditForm
 from pyams_content.component.paragraph.zmi.container import ParagraphContainerView
@@ -40,9 +45,10 @@
 from pyams_utils.traversing import get_parent
 from pyams_viewlet.viewlet import viewlet_config
 from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyramid.threadlocal import get_current_registry
 from pyramid.view import view_config
 from z3c.form import field, button
-from zope.interface import implementer
+from zope.interface import implementer, Interface
 
 from pyams_content import _
 
@@ -73,7 +79,7 @@
     label_css_class = 'control-label col-md-2'
     input_css_class = 'col-md-10'
 
-    fields = field.Fields(IHTMLParagraph).omit('__parent__', '__name__')
+    fields = field.Fields(IHTMLParagraph).omit('__parent__', '__name__', 'visible')
     ajax_handler = 'add-html-paragraph.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
@@ -86,7 +92,7 @@
         return HTMLParagraph()
 
     def add(self, object):
-        IParagraphContainer(self.context)['none'] = object
+        IParagraphContainer(self.context).append(object)
 
 
 @view_config(name='add-html-paragraph.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
@@ -115,27 +121,48 @@
     label_css_class = 'control-label col-md-2'
     input_css_class = 'col-md-10'
 
-    fields = field.Fields(IHTMLParagraph).omit('__parent__', '__name__')
+    fields = field.Fields(IHTMLParagraph).omit('__parent__', '__name__', 'visible')
     ajax_handler = 'properties.json'
     edit_permission = MANAGE_CONTENT_PERMISSION
 
     def updateWidgets(self, prefix=None):
         super(HTMLParagraphPropertiesEditForm, self).updateWidgets(prefix)
         if 'body' in self.widgets:
-            for lang in self.widgets['body'].langs:
-                widget = self.widgets['body'].widgets[lang]
+            body_widget = self.widgets['body']
+            for lang in body_widget.langs:
+                widget = body_widget.widgets[lang]
                 widget.id = '{id}_{name}'.format(id=widget.id, name=self.context.__name__)
-            self.widgets['body'].widget_css_class = 'textarea'
+            body_widget.widget_css_class = 'textarea'
+
+
+class IHTMLParagraphInnerEditForm(Interface):
+    """Marker interface for HTML paragraph inner form"""
+
+
+@view_config(name='properties.json', context=IHTMLParagraph, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class HTMLParagraphPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, HTMLParagraphPropertiesEditForm):
+    """HTML paragraph properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(HTMLParagraphPropertiesAJAXEditForm, self).get_ajax_output(changes)
+        if 'body' in changes.get(IHTMLParagraph, ()):
+            associations_table = AssociationsTable(self.context, self.request, None)
+            associations_table.update()
+            output.setdefault('callbacks', []).append({
+                'callback': 'PyAMS_content.associations.afterUpdateCallback',
+                'options': {'parent': associations_table.id,
+                            'table': associations_table.render()}})
+        return output
 
 
 @adapter_config(context=(IHTMLParagraph, IPyAMSLayer), provides=IParagraphInnerEditor)
-@implementer(IInnerForm)
+@implementer(IInnerForm, IPropertiesEditForm, IAssociationsParentForm, IHTMLParagraphInnerEditForm)
 class HTMLParagraphInnerEditForm(HTMLParagraphPropertiesEditForm):
     """HTML paragraph inner edit form"""
 
     legend = None
-    main_group_legend = _("HTML paragraph")
-    main_group_class = 'inner'
+    ajax_handler = 'inner-properties.json'
 
     @property
     def buttons(self):
@@ -145,10 +172,22 @@
             return button.Buttons()
 
 
-@view_config(name='properties.json', context=IHTMLParagraph, request_type=IPyAMSLayer,
+@view_config(name='inner-properties.json', context=IHTMLParagraph, request_type=IPyAMSLayer,
              permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class HTMLParagraphPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, HTMLParagraphPropertiesEditForm):
-    """HTML paragraph properties edit form, JSON renderer"""
+class HTMLParagraphInnerAJAXEditForm(BaseParagraphAJAXEditForm, HTMLParagraphInnerEditForm):
+    """HTML paragraph inner edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(HTMLParagraphInnerAJAXEditForm, self).get_ajax_output(changes)
+        if 'body' in changes.get(IHTMLParagraph, ()):
+            associations_table = AssociationsTable(self.context, self.request, None)
+            associations_table.update()
+            output.setdefault('callbacks', []).append({
+                'callback': 'PyAMS_content.associations.afterUpdateCallback',
+                'options': {'parent': associations_table.id,
+                            'table': associations_table.render()}
+            })
+        return output
 
 
 #
@@ -160,6 +199,8 @@
 class HTMLParagraphSummary(ContextRequestAdapter):
     """HTML paragraph renderer"""
 
+    illustration = None
+    illustration_renderer = None
     language = None
 
     def update(self):
@@ -170,5 +211,18 @@
         else:
             for attr in ('title', 'body'):
                 setattr(self, attr, i18n.query_attribute(attr, request=self.request))
+        self.illustration = IIllustration(self.context)
+        if self.illustration.data:
+            registry = get_current_registry()
+            renderer = self.illustration_renderer = registry.queryMultiAdapter((self.illustration, self.request),
+                                                                               IIllustrationRenderer,
+                                                                               name=self.illustration.renderer)
+            if renderer is not None:
+                renderer.update()
 
     render = get_view_template()
+
+    def render_illustration(self):
+        if not self.illustration_renderer:
+            return ''
+        return self.illustration_renderer.render()
--- a/src/pyams_content/component/paragraph/zmi/illustration.py	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,207 +0,0 @@
-#
-# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
-# All Rights Reserved.
-#
-# This software is subject to the provisions of the Zope Public License,
-# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-# FOR A PARTICULAR PURPOSE.
-#
-
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
-# import interfaces
-from pyams_content.component.paragraph.interfaces import IParagraphContainerTarget, IIllustrationParagraph, \
-    IParagraphContainer, IParagraphSummary, IIllustrationRenderer
-from pyams_content.component.paragraph.zmi.interfaces import IParagraphInnerEditor
-from pyams_content.interfaces import MANAGE_CONTENT_PERMISSION
-from pyams_content.shared.common.interfaces import IWfSharedContent
-from pyams_form.interfaces.form import IInnerForm, IEditFormButtons
-from pyams_i18n.interfaces import II18n
-from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
-from pyams_skin.layer import IPyAMSLayer
-from z3c.form.interfaces import INPUT_MODE
-
-# import packages
-from pyams_content.component.paragraph.illustration import Illustration
-from pyams_content.component.paragraph.zmi import BaseParagraphAJAXEditForm
-from pyams_content.component.paragraph.zmi.container import ParagraphContainerView
-from pyams_form.form import AJAXAddForm
-from pyams_form.security import ProtectedFormObjectMixin
-from pyams_pagelet.pagelet import pagelet_config
-from pyams_skin.viewlet.toolbar import ToolbarMenuItem
-from pyams_template.template import template_config, get_view_template
-from pyams_utils.adapter import ContextRequestAdapter, adapter_config
-from pyams_utils.traversing import get_parent
-from pyams_viewlet.viewlet import viewlet_config
-from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
-from pyramid.view import view_config
-from z3c.form import field, button
-from zope.interface import implementer
-
-from pyams_content import _
-
-
-#
-# Illustration
-#
-
-@viewlet_config(name='add-illustration.menu', context=IParagraphContainerTarget, view=ParagraphContainerView,
-                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=60)
-class IllustrationAddMenu(ProtectedFormObjectMixin, ToolbarMenuItem):
-    """Illustration add menu"""
-
-    label = _("Add illustration...")
-    label_css_class = 'fa fa-fw fa-file-image-o'
-    url = 'add-illustration.html'
-    modal_target = True
-
-
-@pagelet_config(name='add-illustration.html', context=IParagraphContainerTarget, layer=IPyAMSLayer,
-                permission=MANAGE_CONTENT_PERMISSION)
-class IllustrationAddForm(AdminDialogAddForm):
-    """Illustration add form"""
-
-    legend = _("Add new illustration")
-    dialog_class = 'modal-large'
-    icon_css_class = 'fa fa-fw fa-file-image-o'
-
-    fields = field.Fields(IIllustrationParagraph).omit('__parent__', '__name__')
-    ajax_handler = 'add-illustration.json'
-    edit_permission = MANAGE_CONTENT_PERMISSION
-
-    def create(self, data):
-        return Illustration()
-
-    def add(self, object):
-        IParagraphContainer(self.context)['none'] = object
-
-
-@view_config(name='add-illustration.json', context=IParagraphContainerTarget, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class IllustrationAJAXAddForm(AJAXAddForm, IllustrationAddForm):
-    """HTML paragraph add form, JSON renderer"""
-
-    def get_ajax_output(self, changes):
-        return {'status': 'reload',
-                'location': '#paragraphs.html'}
-
-
-@pagelet_config(name='properties.html', context=IIllustrationParagraph, layer=IPyAMSLayer,
-                permission=MANAGE_CONTENT_PERMISSION)
-class IllustrationPropertiesEditForm(AdminDialogEditForm):
-    """Illustration properties edit form"""
-
-    @property
-    def title(self):
-        content = get_parent(self.context, IWfSharedContent)
-        return II18n(content).query_attribute('title', request=self.request)
-
-    legend = _("Edit illustration properties")
-    dialog_class = 'modal-large'
-    icon_css_class = 'fa fa-fw fa-file-image-o'
-
-    fields = field.Fields(IIllustrationParagraph).omit('__parent__', '__name__')
-    ajax_handler = 'properties.json'
-    edit_permission = MANAGE_CONTENT_PERMISSION
-
-
-@adapter_config(context=(IIllustrationParagraph, IPyAMSLayer), provides=IParagraphInnerEditor)
-@implementer(IInnerForm)
-class IllustrationInnerEditForm(IllustrationPropertiesEditForm):
-    """Illustration inner edit form"""
-
-    legend = None
-    main_group_legend = _("Illustration")
-    main_group_class = 'inner'
-
-    @property
-    def buttons(self):
-        if self.mode == INPUT_MODE:
-            return button.Buttons(IEditFormButtons)
-        else:
-            return button.Buttons()
-
-
-@view_config(name='properties.json', context=IIllustrationParagraph, request_type=IPyAMSLayer,
-             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
-class IllustrationPropertiesAJAXEditForm(BaseParagraphAJAXEditForm, IllustrationPropertiesEditForm):
-    """HTML paragraph properties edit form, JSON renderer"""
-
-
-#
-# Illustration summary
-#
-
-@adapter_config(context=(IIllustrationParagraph, IPyAMSLayer), provides=IParagraphSummary)
-class IllustrationSummary(ContextRequestAdapter):
-    """Illustration renderer"""
-
-    def __init__(self, context, request):
-        super(IllustrationSummary, self).__init__(context, request)
-        self.renderer = request.registry.queryMultiAdapter((context, request), IIllustrationRenderer,
-                                                           name=self.context.renderer)
-
-    language = None
-
-    def update(self):
-        if self.renderer is not None:
-            self.renderer.language = self.language
-            self.renderer.update()
-
-    def render(self):
-        if self.renderer is not None:
-            return self.renderer.render()
-        else:
-            return ''
-
-
-#
-# Illustration renderers
-#
-
-class BaseIllustrationRenderer(ContextRequestAdapter):
-    """Base illustration renderer"""
-
-    language = None
-
-    def update(self):
-        i18n = II18n(self.context)
-        if self.language:
-            self.legend = i18n.get_attribute('legend', self.language, request=self.request)
-        else:
-            self.legend = i18n.query_attribute('legend', request=self.request)
-
-    render = get_view_template()
-
-
-@adapter_config(name='default', context=(IIllustrationParagraph, IPyAMSLayer), provides=IIllustrationRenderer)
-@template_config(template='templates/illustration.pt', layer=IPyAMSLayer)
-class DefaultIllustrationRenderer(BaseIllustrationRenderer):
-    """Default illustration renderer"""
-
-    label = _("Centered illustration")
-    weight = 1
-
-
-@adapter_config(name='left+zoom', context=(IIllustrationParagraph, IPyAMSLayer), provides=IIllustrationRenderer)
-@template_config(template='templates/illustration-left.pt', layer=IPyAMSLayer)
-class LeftIllustrationWithZoomRenderer(BaseIllustrationRenderer):
-    """Illustrtaion renderer with small image and zoom"""
-
-    label = _("Small illustration on the left with zoom")
-    weight = 2
-
-
-@adapter_config(name='right+zoom', context=(IIllustrationParagraph, IPyAMSLayer), provides=IIllustrationRenderer)
-@template_config(template='templates/illustration-right.pt', layer=IPyAMSLayer)
-class RightIllustrationWithZoomRenderer(BaseIllustrationRenderer):
-    """Illustrtaion renderer with small image and zoom"""
-
-    label = _("Small illustration on the right with zoom")
-    weight = 3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/paragraph/zmi/templates/associations.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,15 @@
+<div class="ams-widget">
+	<header>
+		<span tal:condition="view.widget_icon_class | nothing"
+			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+		</span>
+		<h2 tal:content="view.title"></h2>
+		<tal:var content="structure provider:pyams.widget_title" />
+		<tal:var content="structure provider:pyams.toolbar" />
+	</header>
+	<div class="widget-body no-widget-toolbar">
+		<tal:loop repeat="table view.associations">
+			<tal:var content="structure table.render()" />
+		</tal:loop>
+	</div>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/component/paragraph/zmi/templates/header-summary.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -0,0 +1,1 @@
+<div class="margin-bottom-10" tal:content="structure extension:html(view.header)">header</div>
--- a/src/pyams_content/component/paragraph/zmi/templates/html-summary.pt	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/zmi/templates/html-summary.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -1,2 +1,3 @@
 <h3 tal:content="view.title">title</h3>
 <div tal:content="structure view.body">body</div>
+<tal:var content="structure view.render_illustration()">Illustration</tal:var>
--- a/src/pyams_content/component/paragraph/zmi/templates/illustration-left.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-<div class="pull-left margin-10">
-	<a class="fancybox" data-toggle
-	   data-ams-fancybox-type="image"
-	   tal:define="thumbnails extension:thumbnails(context.data);
-				   target thumbnails.get_thumbnail('800x600', 'png');
-				   thumb thumbnails.get_thumbnail('300x200', 'png');"
-	   tal:attributes="href extension:absolute_url(target)">
-		<img tal:attributes="src extension:absolute_url(thumb)" /><br />
-		<span tal:content="view.legend">legend</span>
-	</a><br />
-</div>
--- a/src/pyams_content/component/paragraph/zmi/templates/illustration-right.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-<div class="pull-right margin-10">
-	<a class="fancybox" data-toggle
-	   data-ams-fancybox-type="image"
-	   tal:define="thumbnails extension:thumbnails(context.data);
-				   target thumbnails.get_thumbnail('800x600', 'png');
-				   thumb thumbnails.get_thumbnail('300x200', 'png');"
-	   tal:attributes="href extension:absolute_url(target)">
-		<img tal:attributes="src extension:absolute_url(thumb)" /><br />
-		<span tal:content="view.legend">legend</span>
-	</a><br />
-</div>
--- a/src/pyams_content/component/paragraph/zmi/templates/illustration.pt	Mon Sep 11 14:53:15 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-<div class="text-center margin-y-5">
-	<img tal:define="thumbnails extension:thumbnails(context.data);
-					 target thumbnails.get_thumbnail('800x600', 'jpeg');"
-		 tal:attributes="src extension:absolute_url(target)" /><br />
-	<span tal:content="view.legend">legend</span>
-</div>
--- a/src/pyams_content/component/paragraph/zmi/templates/paragraph-title-icon.pt	Mon Sep 11 14:53:15 2017 +0200
+++ b/src/pyams_content/component/paragraph/zmi/templates/paragraph-title-icon.pt	Mon Sep 11 14:54:30 2017 +0200
@@ -1,14 +1,10 @@
-<div tal:attributes="class string:${view.action_class} pull-left">
-	<a class="hint"
-	   tal:attributes="title view.icon_hint;
-					   href extension:absolute_url(context, view.url);
-					   data-toggle 'modal' if view.modal_target else None;"
-	   data-ams-stop-propagation="true" data-ams-hint-gravity="s" data-ams-hint-offset="2">
-		<i tal:attributes="class view.icon_class"></i>
-	</a>
+<div tal:attributes="class string:${view.action_class} pull-left"
+	 tal:define="count view.count" tal:condition="count">
 	<span class="count">
-		<tal:if define="count view.count" condition="count">
-			(<span tal:content="count"></span>)
-		</tal:if>
+		<span tal:content="count"></span>
 	</span>
+	<i tal:attributes="class string:${view.icon_class} hint opaque align-base;
+					   title view.icon_hint;"
+	   data-ams-hint-offset="3"
+	   i18n:attributes="title"></i>
 </div>