diff --git a/app/assets/images/apercu-indisponible.png b/app/assets/images/apercu-indisponible.png new file mode 100644 index 000000000..83ed22706 Binary files /dev/null and b/app/assets/images/apercu-indisponible.png differ diff --git a/app/assets/images/pdf-placeholder.png b/app/assets/images/pdf-placeholder.png new file mode 100644 index 000000000..58da75f9f Binary files /dev/null and b/app/assets/images/pdf-placeholder.png differ diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss new file mode 100644 index 000000000..55580c607 --- /dev/null +++ b/app/assets/stylesheets/gallery.scss @@ -0,0 +1,91 @@ +.gallery { + a { + background-image: none; + } + + .champ-libelle { + margin-top: 0.5rem; + } + + img { + height: 200px; + width: 200px; + object-fit: cover; + } + + .thumbnail { + position: relative; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid var(--border-default-grey); + background-color: var(--border-default-grey); + + img { + position: relative; + z-index: 1; + } + + .fr-btn { + position: absolute; + z-index: 10; + background-color: var(--background-default-grey); + color: var(--text-active-blue-france); + border: 1px solid var(--border-active-blue-france); + padding: 0.25rem 0.75rem; + + &:hover { + background-color: var(--hover-tint); + } + + &:active { + background-color: var(--active-tint); + } + } + } +} + +.gallery-pieces-jointes { + display: flex; + flex-wrap: wrap; + + .gallery-item { + margin: 0 2rem 1.5rem 0; + } + + img { + height: 200px; + width: 200px; + } +} + +.gallery-demande { + img { + height: 150px; + width: 150px; + } + + .fr-download { + margin-bottom: 0.5rem; + } + + .thumbnail { + width: fit-content; + margin-bottom: 1rem; + } +} + +.lg-has-iframe { + width: 80% !important; + margin-top: 50px; +} + +.lg-icon { + --hover-tint: none; + --active-tint: none; +} + +.lg-sub-html { + background-image: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1)) !important; + padding: 30px 40px 0 40px !important; +} diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index aea716925..b363a9172 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -159,6 +159,14 @@ } } + &-eye { + &:before, + &:after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z'%3E%3C/path%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z'%3E%3C/path%3E%3C/svg%3E"); + } + } + &-file-copy-line { &:before, &:after { diff --git a/app/components/attachment/show_component.rb b/app/components/attachment/show_component.rb index 4bc8416e7..ca109617a 100644 --- a/app/components/attachment/show_component.rb +++ b/app/components/attachment/show_component.rb @@ -1,10 +1,11 @@ class Attachment::ShowComponent < ApplicationComponent - def initialize(attachment:, new_tab: false) + def initialize(attachment:, new_tab: false, truncate: false) @attachment = attachment @new_tab = new_tab + @truncate = truncate end - attr_reader :attachment, :new_tab + attr_reader :attachment, :new_tab, :truncate def should_display_link? (attachment.virus_scanner.safe? || !attachment.virus_scanner.started?) && !attachment.watermark_pending? diff --git a/app/components/attachment/show_component/show_component.html.haml b/app/components/attachment/show_component/show_component.html.haml index 49b9768df..f8bd81f44 100644 --- a/app/components/attachment/show_component/show_component.html.haml +++ b/app/components/attachment/show_component/show_component.html.haml @@ -1,6 +1,6 @@ %div{ id: dom_id(attachment, :show), class: class_names("attachment-error": error?, "fr-mb-2w": !should_display_link?) } - if should_display_link? - = render Dsfr::DownloadComponent.new(attachment: attachment, virus_not_analyzed: !attachment.virus_scanner.started?, new_tab: new_tab) + = render Dsfr::DownloadComponent.new(attachment: attachment, virus_not_analyzed: !attachment.virus_scanner.started?, new_tab: new_tab, truncate: truncate) - else .attachment-filename.fr-mb-1w.fr-mr-1w= attachment.filename.to_s diff --git a/app/components/dsfr/download_component.rb b/app/components/dsfr/download_component.rb index ef1e8862f..326c0865b 100644 --- a/app/components/dsfr/download_component.rb +++ b/app/components/dsfr/download_component.rb @@ -5,14 +5,16 @@ class Dsfr::DownloadComponent < ApplicationComponent attr_reader :ephemeral_link attr_reader :virus_not_analyzed attr_reader :new_tab + attr_reader :truncate - def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false) + def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false, truncate: false) @attachment = attachment @name = name || attachment.filename.to_s @url = url @ephemeral_link = ephemeral_link @virus_not_analyzed = virus_not_analyzed @new_tab = new_tab + @truncate = truncate end def title diff --git a/app/components/dsfr/download_component/download_component.html.haml b/app/components/dsfr/download_component/download_component.html.haml index 71d53624a..cd30a9f57 100644 --- a/app/components/dsfr/download_component/download_component.html.haml +++ b/app/components/dsfr/download_component/download_component.html.haml @@ -1,7 +1,7 @@ .fr-download %p = link_to url, {class: "fr-download__link", title: title}.merge(new_tab ? { target: '_blank' } : { download: '' }) do - = name + = truncate ? name.truncate(20) : name %span.fr-download__detail = helpers.download_details(attachment) - if ephemeral_link diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index c4d013794..51c45ebe6 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -358,6 +358,13 @@ module Instructeurs redirect_to instructeur_procedure_path(procedure) end + def pieces_jointes + @dossier = current_instructeur.dossiers.find(params[:dossier_id]) + @champs_with_pieces_jointes = @dossier + .champs + .filter { _1.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } + end + private def dossier_scope diff --git a/app/javascript/controllers/lightbox_controller.ts b/app/javascript/controllers/lightbox_controller.ts new file mode 100644 index 000000000..8d4660d70 --- /dev/null +++ b/app/javascript/controllers/lightbox_controller.ts @@ -0,0 +1,36 @@ +import { Controller } from '@hotwired/stimulus'; +import lightGallery from 'lightgallery'; +import { LightGallery } from 'lightgallery/lightgallery'; +import lgThumbnail from 'lightgallery/plugins/thumbnail'; +import lgZoom from 'lightgallery/plugins/zoom'; +import lgRotate from 'lightgallery/plugins/rotate'; +import 'lightgallery/css/lightgallery-bundle.css'; + +export default class extends Controller { + lightGallery?: LightGallery; + + connect(): void { + const options = { + plugins: [lgZoom, lgThumbnail, lgRotate], + flipVertical: false, + flipHorizontal: false, + animateThumb: false, + zoomFromOrigin: false, + allowMediaOverlap: true, + toggleThumb: true, + selector: '.gallery-link' + }; + + this.lightGallery = lightGallery(this.element as HTMLElement, options); + + const downloadIcon = document.querySelector('.lg-download'); + + if (downloadIcon != null) { + downloadIcon.removeAttribute('target'); + } + } + + disconnect(): void { + this.lightGallery?.destroy(); + } +} diff --git a/app/views/instructeurs/dossiers/_header_bottom.html.haml b/app/views/instructeurs/dossiers/_header_bottom.html.haml index 57ea40455..ce922e524 100644 --- a/app/views/instructeurs/dossiers/_header_bottom.html.haml +++ b/app/views/instructeurs/dossiers/_header_bottom.html.haml @@ -7,6 +7,10 @@ instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:demande]) + - if dossier.revision.types_de_champ.any?(&:piece_justificative?) + = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.attachments'), + pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier)) + = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.private_annotations'), annotations_privees_instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:annotations_privees]) diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml new file mode 100644 index 000000000..e4ef2686a --- /dev/null +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -0,0 +1,40 @@ +- content_for(:title, "Pièces jointes") + += render partial: "header", locals: { dossier: @dossier } + +.fr-container + - if @champs_with_pieces_jointes.map(&:piece_justificative_file).flatten.none? + .empty-text + Ce dossier ne contient pas de pièces jointes + - else + .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } + - @champs_with_pieces_jointes.each do |champ| + - champ.piece_justificative_file.each do |attachment| + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do + .thumbnail + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(blob.url) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - else + .thumbnail + = image_tag('apercu-indisponible.png') + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index dd81dde84..9098e88fb 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -1,4 +1,19 @@ .fr-downloads-group - %ul - - champ.piece_justificative_file.attachments.each do |attachment| - %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) + - champ.piece_justificative_file.attachments.each do |attachment| + %ul + %li= render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do + .thumbnail + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' + + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(blob.url) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' diff --git a/app/views/shared/dossiers/_demande.html.haml b/app/views/shared/dossiers/_demande.html.haml index 4a7e9d1d9..6c6e6d601 100644 --- a/app/views/shared/dossiers/_demande.html.haml +++ b/app/views/shared/dossiers/_demande.html.haml @@ -2,7 +2,7 @@ - content_for(:notice_info) do = render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.user.france_connect_informations.first } -.fr-container.counter-start-header-section.dossier-show{ class: class_names("dossier-show-instructeur" => profile =="instructeur") } +.fr-container.counter-start-header-section.dossier-show.gallery.gallery-demande{ class: class_names("dossier-show-instructeur" => profile =="instructeur"), "data-controller": "lightbox" } .fr-grid-row.fr-grid-row--center .fr-col-12.fr-col-xl-8 - if profile == 'instructeur' && dossier.termine_and_accuse_lecture? diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index fe5410266..e5af1c74f 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -1,15 +1,25 @@ -AUTHORIZED_CONTENT_TYPES = [ - # multimedia +AUTHORIZED_PDF_TYPES = [ + 'application/pdf', # text x 4628654 + 'application/x-pdf', # text x 30 + 'image/pdf', # text x 23 + 'text/pdf' # text x 12 +] + +AUTHORIZED_IMAGE_TYPES = [ 'image/jpeg', # multimedia x 1467465 'image/png', # multimedia x 126662 'image/tiff', # multimedia x 3985 'image/bmp', # multimedia x 3656 - 'video/mp4', # multimedia x 2075 'image/webp', # multimedia x 529 - 'video/quicktime', # multimedia x 486 'image/gif', # multimedia x 463 + 'image/vnd.dwg' # multimedia x 137 auto desk +] + +AUTHORIZED_CONTENT_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + [ + # multimedia + 'video/mp4', # multimedia x 2075 + 'video/quicktime', # multimedia x 486 'video/3gpp', # multimedia x 216 - 'image/vnd.dwg', # multimedia x 137 auto desk 'audio/mpeg', # multimedia x 26 'video/x-ms-wm', # multimedia x 15 video microsoft ? 'audio/mp4', # audio .mp4, .m4a @@ -45,7 +55,6 @@ AUTHORIZED_CONTENT_TYPES = [ 'text/xml', # program x 10 # text / sheet / presentation - 'application/pdf', # text x 4628654 'application/vnd.ms-excel', # text x 166674 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # text x 103879 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # text x 86336 @@ -69,18 +78,15 @@ AUTHORIZED_CONTENT_TYPES = [ 'application/vnd.ms-word.document.macroenabled.12', # text x 61 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', # text x 59 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', # text x 32 - 'application/x-pdf', # text x 30 'application/kswps', # inconnu x 26 , text ? 'application/x-iwork-numbers-sffnumbers', # text x 25 'text/rtf', # text x 25 - 'image/pdf', # text x 23 'application/vnd.ms-xpsdocument', # text x 23 'application/vnd.ms-excel.sheet.binary.macroenabled.12', # text x 21 'application/vnd.ms-powerpoint.presentation.macroenabled.12', # text x 15 'application/x-msword', # text x 15 'application/vnd.oasis.opendocument.spreadsheet-template', # text x 14 'application/vnd.oasis.opendocument.text-master', # text x 12 - 'text/pdf', # text x 12 'application/x-abiword', # text x 11 'application/x-iwork-keynote-sffnumbers', # text x 11 'application/x-iwork-keynote-sffkey', # text x 10 diff --git a/config/locales/en.yml b/config/locales/en.yml index d86e72fc9..ef9cb539a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -379,6 +379,7 @@ en: messaging: Messaging involved_persons: Involved persons reaffectation: reassignment + attachments: Attachments tab_explainations: a_suivre: No instructor is assigned to follow up on these files. Be the first ! suivis: The folders that are in this tab are only those that you follow. You can exchange with the requester until you can accept them, refuse them or classify them without follow-up. diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 8da981b0e..6b5dfe622 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -382,6 +382,7 @@ fr: messaging: Messagerie involved_persons: Personnes impliquées reaffectation: Réaffectation + attachments: Pièces jointes tab_explainations: a_suivre: Aucun instructeur n’est affecté au suivi de ces dossiers. Soyez le premier ! suivis: Les dossiers qui sont dans cet onglet sont uniquement ceux que vous suivez. Vous pouvez échanger avec le demandeur jusqu’à pouvoir les accepter, les refuser ou les classer sans suite. diff --git a/config/routes.rb b/config/routes.rb index 094758d24..0ab8203f2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -502,6 +502,7 @@ Rails.application.routes.draw do get 'print' => 'dossiers#print' get 'telecharger_pjs' => 'dossiers#telecharger_pjs' get 'reaffectation' + get 'pieces_jointes' post 'reaffecter' end end diff --git a/package.json b/package.json index 0a2b30333..4186d3462 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "highcharts": "^10.3.3", "intersection-observer": "^0.12.2", "is-hotkey": "^0.2.0", + "lightgallery": "^2.7.2", "maplibre-gl": "^1.15.2", "match-sorter": "^6.3.4", "patch-package": "^7.0.0", diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index f72f52003..108c675ba 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1372,4 +1372,29 @@ describe Instructeurs::DossiersController, type: :controller do it { expect(subject).to have_http_status(:ok) } end + + describe '#pieces_jointes' do + let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :piece_justificative }], instructeurs:) } + let(:dossier) { create(:dossier, :en_construction, :with_populated_champs, procedure: procedure) } + + before do + dossier.champs.first.piece_justificative_file.attach( + io: StringIO.new("image file"), + filename: "image.jpeg", + content_type: "image/jpeg", + # we don't want to run virus scanner on this file + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + ) + get :pieces_jointes, params: { + procedure_id: procedure.id, + dossier_id: dossier.id + } + end + + it do + expect(response.body).to include('Télécharger le fichier toto.txt') + expect(response.body).to include('Télécharger le fichier image.jpeg') + expect(response.body).to include('Visualiser') + end + end end