UX: add proper delete dialog, add option to report images

This commit is contained in:
Pieter Vander Vennet 2024-11-05 00:18:16 +01:00
parent 8690ad35bb
commit 5b618dc367
18 changed files with 334 additions and 176 deletions

View file

@ -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} />

View 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>

View 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>

View file

@ -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)
)
}
}

View file

@ -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"

View file

@ -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
}
}