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