forked from MapComplete/MapComplete
		
	UX: add proper delete dialog, add option to report images
This commit is contained in:
		
							parent
							
								
									8690ad35bb
								
							
						
					
					
						commit
						5b618dc367
					
				
					 18 changed files with 334 additions and 176 deletions
				
			
		|  | @ -16,6 +16,7 @@ | |||
|   import Loading from "../Base/Loading.svelte" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import DotMenu from "../Base/DotMenu.svelte" | ||||
| 
 | ||||
|   export let image: Partial<ProvidedImage> | ||||
|   let fallbackImage: string = undefined | ||||
|  | @ -36,12 +37,12 @@ | |||
|       if (!shown) { | ||||
|         previewedImage.set(undefined) | ||||
|       } | ||||
|     }) | ||||
|     }), | ||||
|   ) | ||||
|   onDestroy( | ||||
|     previewedImage.addCallbackAndRun((previewedImage) => { | ||||
|       showBigPreview.set(previewedImage?.id === image.id) | ||||
|     }) | ||||
|     }), | ||||
|   ) | ||||
| 
 | ||||
|   function highlight(entered: boolean = true) { | ||||
|  | @ -73,6 +74,7 @@ | |||
|   <div style="height: 80vh"> | ||||
|     <ImageOperations {image}> | ||||
|       <slot name="preview-action" /> | ||||
|       <slot name="dot-menu-actions" slot="dot-menu-actions" /> | ||||
|     </ImageOperations> | ||||
|   </div> | ||||
|   <div class="absolute top-4 right-4"> | ||||
|  | @ -85,7 +87,7 @@ | |||
|     /> | ||||
|   </div> | ||||
| </Popup> | ||||
| {#if image.status !== undefined && image.status !== "ready"} | ||||
| {#if image.status !== undefined && image.status !== "ready" && image.status !== "hidden"} | ||||
|   <div class="flex h-full flex-col justify-center"> | ||||
|     <Loading> | ||||
|       <Tr t={Translations.t.image.processing} /> | ||||
|  | @ -98,6 +100,11 @@ | |||
|       on:mouseenter={() => highlight()} | ||||
|       on:mouseleave={() => highlight(false)} | ||||
|     > | ||||
|       {#if $$slots["dot-menu-actions"]} | ||||
|         <DotMenu dotsPosition="top-0 left-0 absolute" hideBackground> | ||||
|           <slot name="dot-menu-actions" /> | ||||
|         </DotMenu> | ||||
|       {/if} | ||||
|       <img | ||||
|         bind:this={imgEl} | ||||
|         on:load={() => (loaded = true)} | ||||
|  | @ -122,6 +129,8 @@ | |||
|           <MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" /> | ||||
|         </div> | ||||
|       {/if} | ||||
| 
 | ||||
| 
 | ||||
|     </div> | ||||
|     <div class="absolute bottom-0 left-0"> | ||||
|       <ImageAttribution {image} {attributionFormat} /> | ||||
|  |  | |||
							
								
								
									
										188
									
								
								src/UI/Image/DeletableImage.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/UI/Image/DeletableImage.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,188 @@ | |||
| <script lang="ts"> | ||||
|   import ImageProvider from "../../Logic/ImageProviders/ImageProvider" | ||||
|   import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" | ||||
| 
 | ||||
|   import Popup from "../Base/Popup.svelte" | ||||
|   import AccordionSingle from "../Flowbite/AccordionSingle.svelte" | ||||
|   import NextButton from "../Base/NextButton.svelte" | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import AttributedImage from "./AttributedImage.svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import Dropdown from "../Base/Dropdown.svelte" | ||||
|   import { REPORT_REASONS, ReportReason } from "panoramax-js" | ||||
|   import { onDestroy } from "svelte" | ||||
|   import PanoramaxImageProvider from "../../Logic/ImageProviders/Panoramax" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import { TrashIcon } from "@babeard/svelte-heroicons/mini" | ||||
|   import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction" | ||||
|   import { Tag } from "../../Logic/Tags/Tag" | ||||
| 
 | ||||
|   export let image: ProvidedImage | ||||
|   export let state: SpecialVisualizationState | ||||
|   export let tags: UIEventSource<Record<string, string>> | ||||
|   let showDeleteDialog = new UIEventSource(false) | ||||
|   onDestroy(showDeleteDialog.addCallbackAndRunD(shown => { | ||||
|     if (shown) { | ||||
|       state.previewedImage.set(undefined) | ||||
|     } | ||||
|   })) | ||||
| 
 | ||||
|   let reportReason = new UIEventSource<ReportReason>(REPORT_REASONS[0]) | ||||
|   let reportFreeText = new UIEventSource<string>(undefined) | ||||
|   let reported = new UIEventSource<boolean>(false) | ||||
| 
 | ||||
|   async function requestDeletion() { | ||||
|     if (reportReason.data === "other" && !reportFreeText.data) { | ||||
|       return | ||||
|     } | ||||
|     const panoramax = PanoramaxImageProvider.getPanoramaxInstance(image.host) | ||||
|     const url = window.location.href | ||||
|     const imageInfo = await panoramax.imageInfo(image.id) | ||||
|     let reporter_email: string = undefined | ||||
|     const userdetails = state.userRelatedState.osmConnection.userDetails | ||||
|     if (userdetails.data.loggedIn) { | ||||
|       reporter_email = userdetails.data.name + "@openstreetmap.org" | ||||
|     } | ||||
| 
 | ||||
|     await panoramax.report({ | ||||
|       picture_id: image.id, | ||||
|       issue: reportReason.data, | ||||
|       sequence_id: imageInfo.collection, | ||||
|       reporter_comments: (reportFreeText.data ?? "") + "\n\n" + "Reported from " + url, | ||||
|       reporter_email, | ||||
|     }) | ||||
|     reported.set(true) | ||||
|   } | ||||
| 
 | ||||
|   async function unlink() { | ||||
|     await state?.changes?.applyAction( | ||||
|       new ChangeTagAction(tags.data.id, | ||||
|         new Tag(image.key, ""), | ||||
|         tags.data, { | ||||
|           changeType: "delete-image", | ||||
|           theme: state.theme.id, | ||||
|         }), | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   const t = Translations.t.image.panoramax | ||||
|   const tu = Translations.t.image.unlink | ||||
|   const placeholder = t.placeholder.current | ||||
| </script> | ||||
| 
 | ||||
| 
 | ||||
| <Popup shown={showDeleteDialog}> | ||||
|   <Tr slot="header" t={tu.title} /> | ||||
| 
 | ||||
|   <div class="flex flex-col sm:flex-row gap-x-4"> | ||||
|     <img class="w-32 sm:w-64" src={image.url} /> | ||||
|     <div> | ||||
|       <div class="flex flex-col justify-between h-full"> | ||||
|         <Tr t={tu.explanation} /> | ||||
|         {#if $reported} | ||||
|           <Tr cls="thanks p-2" t={t.deletionRequested} /> | ||||
|         {:else if image.provider.name === "panoramax"} | ||||
|           <div class="my-4"> | ||||
|             <AccordionSingle noBorder> | ||||
|               <div slot="header" class="text-sm flex">Report inappropriate picture</div> | ||||
|               <div class="interactive p-2 flex flex-col"> | ||||
| 
 | ||||
|                 <h3> | ||||
|                   <Tr t={t.title} /> | ||||
|                 </h3> | ||||
| 
 | ||||
|                 <Dropdown value={reportReason} cls="w-full mt-2"> | ||||
|                   {#each REPORT_REASONS as reason} | ||||
|                     <option value={reason}> | ||||
|                       {#if t.report[reason]} | ||||
|                         <Tr t={t.report[reason]} /> | ||||
|                       {:else} | ||||
|                         {reason} | ||||
|                       {/if} | ||||
|                     </option> | ||||
|                   {/each} | ||||
|                 </Dropdown> | ||||
| 
 | ||||
|                 {#if $reportReason === "other" && !$reportFreeText} | ||||
|                   <Tr cls="font-bold" t={t.otherFreeform} /> | ||||
|                 {:else} | ||||
|                   <Tr t={t.freeform} /> | ||||
|                 {/if} | ||||
| 
 | ||||
|                 <textarea | ||||
|                   class="w-full" | ||||
|                   bind:value={$reportFreeText} | ||||
|                   inputmode={"text"} | ||||
|                   placeholder={$placeholder} | ||||
|                 /> | ||||
| 
 | ||||
|                 <button class="primary self-end" class:disabled={$reportReason === "other" && !$reportFreeText} | ||||
|                         on:click={() => requestDeletion()}> | ||||
|                   <Tr t={t.requestDeletion} /> | ||||
|                 </button> | ||||
| 
 | ||||
|               </div> | ||||
| 
 | ||||
|             </AccordionSingle> | ||||
|           </div> | ||||
|         {/if} | ||||
| 
 | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|   </div> | ||||
| 
 | ||||
|   <div slot="footer" class="flex justify-end flex-wrap"> | ||||
|     <button on:click={() => showDeleteDialog.set(false)}> | ||||
|       <Tr t={Translations.t.general.cancel} /> | ||||
|     </button> | ||||
| 
 | ||||
|     <NextButton clss={"primary "+($reported ? "disabled" : "") } on:click={() => unlink()}> | ||||
|       <TrashIcon class="w-6 h-6 mr-2" /> | ||||
|       <Tr t={tu.button} /> | ||||
|     </NextButton> | ||||
|   </div> | ||||
| 
 | ||||
| </Popup> | ||||
| 
 | ||||
| <div | ||||
|   class="w-fit shrink-0 relative" | ||||
|   style="scroll-snap-align: start" | ||||
| > | ||||
|   <div class="relative bg-gray-200 max-w-max flex items-center"> | ||||
| 
 | ||||
|     <AttributedImage | ||||
|       imgClass="carousel-max-height" | ||||
|       {image} | ||||
|       {state} | ||||
|       previewedImage={state?.previewedImage} | ||||
|     > | ||||
| 
 | ||||
|       <svelte:fragment slot="dot-menu-actions"> | ||||
| 
 | ||||
|         <button on:click={() => ImageProvider.offerImageAsDownload(image)}> | ||||
|           <DownloadIcon /> | ||||
|           <Tr t={Translations.t.general.download.downloadImage} /> | ||||
|         </button> | ||||
|         <button | ||||
|           on:click={() => showDeleteDialog.set(true)} | ||||
|           class="flex items-center" | ||||
|         > | ||||
|           <TrashIcon /> | ||||
|           <Tr t={tu.button} /> | ||||
|         </button> | ||||
|       </svelte:fragment> | ||||
| 
 | ||||
| 
 | ||||
|     </AttributedImage> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     :global(.carousel-max-height) { | ||||
|         max-height: var(--image-carousel-height); | ||||
|     } | ||||
| </style> | ||||
| 
 | ||||
							
								
								
									
										27
									
								
								src/UI/Image/ImageCarousel.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/UI/Image/ImageCarousel.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| <script lang="ts"> | ||||
|   import { Store, UIEventSource } from "../../Logic/UIEventSource.js" | ||||
|   import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" | ||||
|   import AttributedImage from "../Image/AttributedImage.svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import ToSvelte from "../Base/ToSvelte.svelte" | ||||
|   import DeleteImage from "./DeleteImage" | ||||
|   import Popup from "../Base/Popup.svelte" | ||||
|   import TitledPanel from "../Base/TitledPanel.svelte" | ||||
|   import AccordionSingle from "../Flowbite/AccordionSingle.svelte" | ||||
|   import NextButton from "../Base/NextButton.svelte" | ||||
|   import DeletableImage from "./DeletableImage.svelte" | ||||
| 
 | ||||
|   export let images: Store<ProvidedImage[]> | ||||
|   export let state: SpecialVisualizationState | ||||
|   export let tags: Store<Record<string, string>> | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| 
 | ||||
| <div class="flex w-full space-x-2 overflow-x-auto" style="scroll-snap-type: x proximity"> | ||||
|   {#each $images as image (image.url)} | ||||
|     <DeletableImage {image} {state} {tags}/> | ||||
|   {/each} | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
|  | @ -1,62 +0,0 @@ | |||
| import { SlideShow } from "./SlideShow" | ||||
| import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
| import Combine from "../Base/Combine" | ||||
| import DeleteImage from "./DeleteImage" | ||||
| import BaseUIElement from "../BaseUIElement" | ||||
| import Toggle from "../Input/Toggle" | ||||
| import ImageProvider, { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" | ||||
| import { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
| import { Changes } from "../../Logic/Osm/Changes" | ||||
| import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig" | ||||
| import SvelteUIElement from "../Base/SvelteUIElement" | ||||
| import AttributedImage from "./AttributedImage.svelte" | ||||
| 
 | ||||
| export class ImageCarousel extends Toggle { | ||||
|     constructor( | ||||
|         images: Store<{ id: string; key: string; url: string; provider: ImageProvider }[]>, | ||||
|         tags: Store<any>, | ||||
|         state: { | ||||
|             osmConnection?: OsmConnection | ||||
|             changes?: Changes | ||||
|             theme: ThemeConfig | ||||
|             previewedImage?: UIEventSource<ProvidedImage> | ||||
|         } | ||||
|     ) { | ||||
|         const uiElements = images.map( | ||||
|             (imageURLS: { key: string; url: string; provider: ImageProvider; id: string }[]) => { | ||||
|                 const uiElements: BaseUIElement[] = [] | ||||
|                 for (const url of imageURLS) { | ||||
|                     try { | ||||
|                         let image: BaseUIElement = new SvelteUIElement(AttributedImage, { | ||||
|                             image: url, | ||||
|                             state, | ||||
|                             previewedImage: state?.previewedImage, | ||||
|                         }).SetClass("h-full") | ||||
| 
 | ||||
|                         if (url.key !== undefined) { | ||||
|                             image = new Combine([ | ||||
|                                 image, | ||||
|                                 new DeleteImage(url.key, tags, state).SetClass( | ||||
|                                     "delete-image-marker absolute top-0 left-0 pl-3" | ||||
|                                 ), | ||||
|                             ]).SetClass("relative") | ||||
|                         } | ||||
|                         image | ||||
|                             .SetClass("w-full h-full block cursor-zoom-in low-interaction") | ||||
|                             .SetStyle("min-width: 50px;") | ||||
|                         uiElements.push(image) | ||||
|                     } catch (e) { | ||||
|                         console.error("Could not generate image element for", url.url, "due to", e) | ||||
|                     } | ||||
|                 } | ||||
|                 return uiElements | ||||
|             } | ||||
|         ) | ||||
| 
 | ||||
|         super( | ||||
|             new SlideShow(uiElements).SetClass("w-full block w-full my-4"), | ||||
|             undefined, | ||||
|             uiElements.map((els) => els.length > 0) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | @ -3,11 +3,11 @@ | |||
|    * The 'imageOperations' previews an image and offers some extra tools (e.g. download) | ||||
|    */ | ||||
| 
 | ||||
|   import ImageProvider from "../../Logic/ImageProviders/ImageProvider" | ||||
|   import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" | ||||
|   import ImageAttribution from "./ImageAttribution.svelte" | ||||
|   import ImagePreview from "./ImagePreview.svelte" | ||||
|   import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import { Utils } from "../../Utils" | ||||
|   import { twMerge } from "tailwind-merge" | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
|  | @ -20,13 +20,6 @@ | |||
| 
 | ||||
|   let isLoaded = new UIEventSource(false) | ||||
| 
 | ||||
|   async function download() { | ||||
|     const response = await fetch(image.url_hd ?? image.url) | ||||
|     const blob = await response.blob() | ||||
|     Utils.offerContentsAsDownloadableFile(blob, new URL(image.url).pathname.split("/").at(-1), { | ||||
|       mimetype: "image/jpg", | ||||
|     }) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <div class={twMerge("relative h-full w-full", clss)}> | ||||
|  | @ -40,13 +33,16 @@ | |||
|   </div> | ||||
| 
 | ||||
|   <DotMenu dotsPosition="top-0 left-0" dotsSize="w-8 h-8" hideBackground> | ||||
|     <button | ||||
|       class="no-image-background pointer-events-auto flex items-center" | ||||
|       on:click={() => download()} | ||||
|     > | ||||
|       <DownloadIcon class="h-6 w-6 px-2 opacity-100" /> | ||||
|       <Tr t={Translations.t.general.download.downloadImage} /> | ||||
|     </button> | ||||
|     <slot name="dot-menu-actions"> | ||||
|       <button | ||||
|         class="no-image-background pointer-events-auto flex items-center" | ||||
|         on:click={() =>  ImageProvider.offerImageAsDownload(image)} | ||||
|       > | ||||
|         <DownloadIcon class="h-6 w-6 px-2 opacity-100" /> | ||||
|         <Tr t={Translations.t.general.download.downloadImage} /> | ||||
|       </button> | ||||
| 
 | ||||
|     </slot> | ||||
|   </DotMenu> | ||||
|   <div | ||||
|     class="pointer-events-none absolute bottom-0 left-0 flex w-full flex-wrap items-end justify-between" | ||||
|  |  | |||
|  | @ -1,48 +0,0 @@ | |||
| import { Store } from "../../Logic/UIEventSource" | ||||
| import BaseUIElement from "../BaseUIElement" | ||||
| import { Utils } from "../../Utils" | ||||
| import Combine from "../Base/Combine" | ||||
| 
 | ||||
| export class SlideShow extends BaseUIElement { | ||||
|     private readonly embeddedElements: Store<BaseUIElement[]> | ||||
| 
 | ||||
|     constructor(embeddedElements: Store<BaseUIElement[]>) { | ||||
|         super() | ||||
|         this.embeddedElements = embeddedElements | ||||
|         this.SetStyle("scroll-snap-type: x mandatory; overflow-x: auto") | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const el = document.createElement("div") | ||||
|         el.style.minWidth = "min-content" | ||||
|         el.style.display = "flex" | ||||
|         el.style.justifyContent = "center" | ||||
|         this.embeddedElements.addCallbackAndRun((elements) => { | ||||
|             if (elements.length > 1) { | ||||
|                 el.style.justifyContent = "unset" | ||||
|             } | ||||
| 
 | ||||
|             while (el.firstChild) { | ||||
|                 el.removeChild(el.lastChild) | ||||
|             } | ||||
| 
 | ||||
|             elements = Utils.NoNull(elements).map((el) => | ||||
|                 new Combine([el]) | ||||
|                     .SetClass("block relative ml-1 bg-gray-200 m-1 rounded slideshow-item") | ||||
|                     .SetStyle( | ||||
|                         "min-width: 150px; width: max-content; height: var(--image-carousel-height);max-height: var(--image-carousel-height);scroll-snap-align: start;" | ||||
|                     ) | ||||
|             ) | ||||
| 
 | ||||
|             for (const element of elements ?? []) { | ||||
|                 el.appendChild(element.ConstructElement()) | ||||
|             } | ||||
|         }) | ||||
| 
 | ||||
|         const wrapper = document.createElement("div") | ||||
|         wrapper.style.maxWidth = "100%" | ||||
|         wrapper.style.overflowX = "auto" | ||||
|         wrapper.appendChild(el) | ||||
|         return wrapper | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue