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

@ -596,10 +596,33 @@
"seeNearby": "Browse nearby pictures", "seeNearby": "Browse nearby pictures",
"title": "Nearby streetview imagery" "title": "Nearby streetview imagery"
}, },
"panoramax": {
"deletionRequested": "The report has been sent. A moderator will look to it shortly",
"freeform": "Is there other relevant information?",
"otherFreeform": "Please specify why this image should be removed:",
"placeholder": "Explain why the picture should be deleted",
"report": {
"blur_excess": "To much is blurred, making the picture useless",
"blur_missing": "A face or license plate is not blurred in this picture",
"copyright": "The picture contains copyrighted content",
"inappropriate": "This picture is inappropriate (it contains nudity, calls for hate or is not streetview)",
"mislocated": "The picture is from a different location",
"other": "Another reason, please specify",
"picture_low_quality": "The picture is of low quality",
"privacy": "The picture shows a private property"
},
"requestDeletion": "Request picture deletion",
"title": "Why should this image be permanently deleted?"
},
"pleaseLogin": "Please log in to add a picture", "pleaseLogin": "Please log in to add a picture",
"processing": "The server is processing your image", "processing": "The server is processing your image",
"respectPrivacy": "Do not upload from Google Maps, Google Streetview or other copyrighted sources.", "respectPrivacy": "Do not upload from Google Maps, Google Streetview or other copyrighted sources.",
"toBig": "Your image is too large as it is {actual_size}. Please use images of at most {max_size}", "toBig": "Your image is too large as it is {actual_size}. Please use images of at most {max_size}",
"unlink": {
"button": "Unlink picture",
"explanation": "By unlinking this image, this picture will not be shown anymore with this object. It will still appear in the nearby-images and possibly other objects.",
"title": "Unlink this image?"
},
"upload": { "upload": {
"failReasons": "You might have lost connection to the internet", "failReasons": "You might have lost connection to the internet",
"failReasonsAdvanced": "Alternatively, make sure your browser and extensions do not block third-party API's.", "failReasonsAdvanced": "Alternatively, make sure your browser and extensions do not block third-party API's.",

14
package-lock.json generated
View file

@ -65,7 +65,7 @@
"opening_hours": "^3.6.0", "opening_hours": "^3.6.0",
"osm-auth": "^2.5.0", "osm-auth": "^2.5.0",
"osmtogeojson": "^3.0.0-beta.5", "osmtogeojson": "^3.0.0-beta.5",
"panoramax-js": "^0.3.10", "panoramax-js": "^0.4.7",
"panzoom": "^9.4.3", "panzoom": "^9.4.3",
"papaparse": "^5.3.1", "papaparse": "^5.3.1",
"pg": "^8.11.3", "pg": "^8.11.3",
@ -16128,9 +16128,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/panoramax-js": { "node_modules/panoramax-js": {
"version": "0.3.10", "version": "0.4.7",
"resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.3.10.tgz", "resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.4.7.tgz",
"integrity": "sha512-ZI9gH98FB3RFWYy69Evsv6vWA+crwhlsdiY8KiZgXAdVYnW7C1YzuQg/Mls546ZHh8/WHj1GMwfe8w5UU6OcFg==", "integrity": "sha512-Lai4IXbxQ/sDBUyl11zgoL7D+4s7YErPPgvGjWj5oZJBjsBFMLnai+du8WcVvRYrZNIDKCGk1vPLsmIvFsR4rw==",
"dependencies": { "dependencies": {
"@ogcapi-js/features": "^1.1.1", "@ogcapi-js/features": "^1.1.1",
"@ogcapi-js/shared": "^1.1.1", "@ogcapi-js/shared": "^1.1.1",
@ -32312,9 +32312,9 @@
"version": "1.0.0" "version": "1.0.0"
}, },
"panoramax-js": { "panoramax-js": {
"version": "0.3.10", "version": "0.4.7",
"resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.3.10.tgz", "resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.4.7.tgz",
"integrity": "sha512-ZI9gH98FB3RFWYy69Evsv6vWA+crwhlsdiY8KiZgXAdVYnW7C1YzuQg/Mls546ZHh8/WHj1GMwfe8w5UU6OcFg==", "integrity": "sha512-Lai4IXbxQ/sDBUyl11zgoL7D+4s7YErPPgvGjWj5oZJBjsBFMLnai+du8WcVvRYrZNIDKCGk1vPLsmIvFsR4rw==",
"requires": { "requires": {
"@ogcapi-js/features": "^1.1.1", "@ogcapi-js/features": "^1.1.1",
"@ogcapi-js/shared": "^1.1.1", "@ogcapi-js/shared": "^1.1.1",

View file

@ -212,7 +212,7 @@
"opening_hours": "^3.6.0", "opening_hours": "^3.6.0",
"osm-auth": "^2.5.0", "osm-auth": "^2.5.0",
"osmtogeojson": "^3.0.0-beta.5", "osmtogeojson": "^3.0.0-beta.5",
"panoramax-js": "^0.3.10", "panoramax-js": "^0.4.7",
"panzoom": "^9.4.3", "panzoom": "^9.4.3",
"papaparse": "^5.3.1", "papaparse": "^5.3.1",
"pg": "^8.11.3", "pg": "^8.11.3",

View file

@ -1462,6 +1462,10 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-right: 4rem; margin-right: 4rem;
} }
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 { .mt-4 {
margin-top: 1rem; margin-top: 1rem;
} }
@ -1478,10 +1482,6 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-bottom: 4rem; margin-bottom: 4rem;
} }
.mb-4 {
margin-bottom: 1rem;
}
.ml-1 { .ml-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
@ -1698,14 +1698,14 @@ input[type="range"].range-lg::-moz-range-thumb {
height: 6rem; height: 6rem;
} }
.h-full {
height: 100%;
}
.h-screen { .h-screen {
height: 100vh; height: 100vh;
} }
.h-full {
height: 100%;
}
.h-fit { .h-fit {
height: -webkit-fit-content; height: -webkit-fit-content;
height: -moz-fit-content; height: -moz-fit-content;
@ -2157,6 +2157,11 @@ input[type="range"].range-lg::-moz-range-thumb {
max-width: 100%; max-width: 100%;
} }
.max-w-max {
max-width: -webkit-max-content;
max-width: max-content;
}
.max-w-fit { .max-w-fit {
max-width: -webkit-fit-content; max-width: -webkit-fit-content;
max-width: -moz-fit-content; max-width: -moz-fit-content;
@ -2576,18 +2581,18 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-bottom: calc(0px * var(--tw-space-y-reverse)); margin-bottom: calc(0px * var(--tw-space-y-reverse));
} }
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) { .space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse)); margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
} }
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) { .space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse)); margin-right: calc(0.75rem * var(--tw-space-x-reverse));
@ -4108,6 +4113,10 @@ input[type="range"].range-lg::-moz-range-thumb {
text-align: justify; text-align: justify;
} }
.text-start {
text-align: start;
}
.text-xl { .text-xl {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -5612,12 +5621,6 @@ svg.apply-fill path {
/************************* LEGACY MARKER - CLEANUP BELOW ********************************/ /************************* LEGACY MARKER - CLEANUP BELOW ********************************/
.slideshow-item img {
/* Legacy: should be replace when the image element is ported to Svelte*/
height: var(--image-carousel-height);
width: unset;
}
.animate-height { .animate-height {
/* Legacy: should be replaced by headlessui disclosure in time */ /* Legacy: should be replaced by headlessui disclosure in time */
transition: max-height 0.5s ease-in-out; transition: max-height 0.5s ease-in-out;

View file

@ -66,8 +66,9 @@ export default class AllImageProviders {
return AllImageProviders.genericImageProvider return AllImageProviders.genericImageProvider
} }
private static readonly _cachedImageStores: Record<string, Store<ProvidedImage[]>> = {}
/** /**
* Tries to extract all image data for this image * Tries to extract all image data for this image. Cachedon tags?.data?.id
*/ */
public static LoadImagesFor( public static LoadImagesFor(
tags: Store<Record<string, string>>, tags: Store<Record<string, string>>,
@ -76,6 +77,10 @@ export default class AllImageProviders {
if (tags?.data?.id === undefined) { if (tags?.data?.id === undefined) {
return undefined return undefined
} }
const id = tags?.data?.id
if(this._cachedImageStores[id]){
return this._cachedImageStores[id]
}
const source = new UIEventSource([]) const source = new UIEventSource([])
const allSources: Store<ProvidedImage[]>[] = [] const allSources: Store<ProvidedImage[]>[] = []
@ -93,6 +98,7 @@ export default class AllImageProviders {
source.set(dedup) source.set(dedup)
}) })
} }
this._cachedImageStores[id] = source
return source return source
} }

View file

@ -88,4 +88,12 @@ export default abstract class ImageProvider {
}): Promise<LicenseInfo> }): Promise<LicenseInfo>
public abstract apiUrls(): string[] public abstract apiUrls(): string[]
public static async offerImageAsDownload(image: ProvidedImage){
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",
})
}
} }

View file

@ -138,11 +138,12 @@ export default class PanoramaxImageProvider extends ImageProvider {
} }
return data?.some( return data?.some(
(img) => (img) =>
img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken" img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken" && img?.status !== "hidden"
) )
} }
Stores.Chronic(1500, () => hasLoading(source.data)).addCallback((_) => { Stores.Chronic(1500, () => hasLoading(source.data)).addCallback((_) => {
console.log("Testing panoramax URLS again as some were loading", source.data, hasLoading(source.data))
super.getRelevantUrlsFor(tags, prefixes).then((data) => { super.getRelevantUrlsFor(tags, prefixes).then((data) => {
source.set(data) source.set(data)
return !hasLoading(data) return !hasLoading(data)
@ -168,6 +169,17 @@ export default class PanoramaxImageProvider extends ImageProvider {
public apiUrls(): string[] { public apiUrls(): string[] {
return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"] return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"]
} }
public static getPanoramaxInstance (host: string){
host = new URL(host).host
if(new URL(this.defaultPanoramax.host).host === host){
return this.defaultPanoramax
}
if(new URL(this.xyz.host).host === host){
return this.xyz
}
return new Panoramax(host)
}
} }
export class PanoramaxUploader implements ImageUploader { export class PanoramaxUploader implements ImageUploader {

View file

@ -28,7 +28,7 @@
} }
</script> </script>
<div class="relative" style="z-index: 50"> <div class="relative" style="z-index: 39">
<div <div
class="sidebar-unit absolute {menuPosition} collapsable normal-background button-unstyled" class="sidebar-unit absolute {menuPosition} collapsable normal-background button-unstyled"
class:transition-background={hideBackground} class:transition-background={hideBackground}

View file

@ -7,6 +7,13 @@
*/ */
export let fullscreen: boolean = false export let fullscreen: boolean = false
export let bodyPadding = "p-4 md:p-5 "
export let shown: UIEventSource<boolean>
export let dismissable = true
/**
* Default: 50
*/
export let zIndex : string = "z-50"
const shared = const shared =
"in-page normal-background dark:bg-gray-800 rounded-lg border-gray-200 dark:border-gray-700 border-gray-200 dark:border-gray-700 divide-gray-200 dark:divide-gray-700 shadow-md" "in-page normal-background dark:bg-gray-800 rounded-lg border-gray-200 dark:border-gray-700 border-gray-200 dark:border-gray-700 divide-gray-200 dark:divide-gray-700 shadow-md"
@ -14,19 +21,16 @@
if (fullscreen) { if (fullscreen) {
defaultClass = shared defaultClass = shared
} }
let dialogClass = "fixed top-0 start-0 end-0 h-modal inset-0 z-50 w-full p-4 flex" let dialogClass = "fixed top-0 start-0 end-0 h-modal inset-0 w-full p-4 flex "+zIndex
if (fullscreen) { if (fullscreen) {
dialogClass += " h-full-child" dialogClass += " h-full-child"
} }
export let bodyPadding = "p-4 md:p-5 "
let bodyClass = bodyPadding + " h-full space-y-4 flex-1 overflow-y-auto overscroll-contain" let bodyClass = bodyPadding + " h-full space-y-4 flex-1 overflow-y-auto overscroll-contain"
let headerClass = "flex justify-between items-center p-2 px-4 md:px-5 rounded-t-lg" let headerClass = "flex justify-between items-center p-2 px-4 md:px-5 rounded-t-lg"
if (!$$slots.header) { if (!$$slots.header) {
headerClass = "hidden" headerClass = "hidden"
} }
export let shown: UIEventSource<boolean>
export let dismissable = true
let _shown = false let _shown = false
shown.addCallbackAndRun((sh) => { shown.addCallbackAndRun((sh) => {
_shown = sh _shown = sh

View file

@ -3,6 +3,7 @@
export let expanded = false export let expanded = false
export let noBorder = false export let noBorder = false
export let contentClass = noBorder ? "normal-background" : "low-interaction rounded-b p-2"
let defaultClass: string = undefined let defaultClass: string = undefined
if (noBorder) { if (noBorder) {
defaultClass = "unstyled w-full flex-grow" defaultClass = "unstyled w-full flex-grow"
@ -14,7 +15,7 @@
<span slot="header" class={!noBorder ? "w-full p-2 text-base" : "w-full"}> <span slot="header" class={!noBorder ? "w-full p-2 text-base" : "w-full"}>
<slot name="header" /> <slot name="header" />
</span> </span>
<div class="low-interaction rounded-b p-2"> <div class={contentClass}>
<slot /> <slot />
</div> </div>
</AccordionItem> </AccordionItem>

View file

@ -16,6 +16,7 @@
import Loading from "../Base/Loading.svelte" import Loading from "../Base/Loading.svelte"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import DotMenu from "../Base/DotMenu.svelte"
export let image: Partial<ProvidedImage> export let image: Partial<ProvidedImage>
let fallbackImage: string = undefined let fallbackImage: string = undefined
@ -36,12 +37,12 @@
if (!shown) { if (!shown) {
previewedImage.set(undefined) previewedImage.set(undefined)
} }
}) }),
) )
onDestroy( onDestroy(
previewedImage.addCallbackAndRun((previewedImage) => { previewedImage.addCallbackAndRun((previewedImage) => {
showBigPreview.set(previewedImage?.id === image.id) showBigPreview.set(previewedImage?.id === image.id)
}) }),
) )
function highlight(entered: boolean = true) { function highlight(entered: boolean = true) {
@ -73,6 +74,7 @@
<div style="height: 80vh"> <div style="height: 80vh">
<ImageOperations {image}> <ImageOperations {image}>
<slot name="preview-action" /> <slot name="preview-action" />
<slot name="dot-menu-actions" slot="dot-menu-actions" />
</ImageOperations> </ImageOperations>
</div> </div>
<div class="absolute top-4 right-4"> <div class="absolute top-4 right-4">
@ -85,7 +87,7 @@
/> />
</div> </div>
</Popup> </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"> <div class="flex h-full flex-col justify-center">
<Loading> <Loading>
<Tr t={Translations.t.image.processing} /> <Tr t={Translations.t.image.processing} />
@ -98,6 +100,11 @@
on:mouseenter={() => highlight()} on:mouseenter={() => highlight()}
on:mouseleave={() => highlight(false)} 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 <img
bind:this={imgEl} bind:this={imgEl}
on:load={() => (loaded = true)} on:load={() => (loaded = true)}
@ -122,6 +129,8 @@
<MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" /> <MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" />
</div> </div>
{/if} {/if}
</div> </div>
<div class="absolute bottom-0 left-0"> <div class="absolute bottom-0 left-0">
<ImageAttribution {image} {attributionFormat} /> <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) * 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 type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider"
import ImageAttribution from "./ImageAttribution.svelte" import ImageAttribution from "./ImageAttribution.svelte"
import ImagePreview from "./ImagePreview.svelte" import ImagePreview from "./ImagePreview.svelte"
import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid" import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Utils } from "../../Utils"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import Loading from "../Base/Loading.svelte" import Loading from "../Base/Loading.svelte"
@ -20,13 +20,6 @@
let isLoaded = new UIEventSource(false) 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> </script>
<div class={twMerge("relative h-full w-full", clss)}> <div class={twMerge("relative h-full w-full", clss)}>
@ -40,13 +33,16 @@
</div> </div>
<DotMenu dotsPosition="top-0 left-0" dotsSize="w-8 h-8" hideBackground> <DotMenu dotsPosition="top-0 left-0" dotsSize="w-8 h-8" hideBackground>
<slot name="dot-menu-actions">
<button <button
class="no-image-background pointer-events-auto flex items-center" class="no-image-background pointer-events-auto flex items-center"
on:click={() => download()} on:click={() => ImageProvider.offerImageAsDownload(image)}
> >
<DownloadIcon class="h-6 w-6 px-2 opacity-100" /> <DownloadIcon class="h-6 w-6 px-2 opacity-100" />
<Tr t={Translations.t.general.download.downloadImage} /> <Tr t={Translations.t.general.download.downloadImage} />
</button> </button>
</slot>
</DotMenu> </DotMenu>
<div <div
class="pointer-events-none absolute bottom-0 left-0 flex w-full flex-wrap items-end justify-between" 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
}
}

View file

@ -15,7 +15,6 @@ import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis"
import { ImmutableStore, Store, Stores, UIEventSource } from "../Logic/UIEventSource" import { ImmutableStore, Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import AllTagsPanel from "./Popup/AllTagsPanel.svelte" import AllTagsPanel from "./Popup/AllTagsPanel.svelte"
import AllImageProviders from "../Logic/ImageProviders/AllImageProviders" import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"
import { ImageCarousel } from "./Image/ImageCarousel"
import { VariableUiElement } from "./Base/VariableUIElement" import { VariableUiElement } from "./Base/VariableUIElement"
import { Utils } from "../Utils" import { Utils } from "../Utils"
import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata" import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata"
@ -83,7 +82,6 @@ import DynLink from "./Base/DynLink.svelte"
import Locale from "./i18n/Locale" import Locale from "./i18n/Locale"
import LanguageUtils from "../Utils/LanguageUtils" import LanguageUtils from "../Utils/LanguageUtils"
import MarkdownUtils from "../Utils/MarkdownUtils" import MarkdownUtils from "../Utils/MarkdownUtils"
import ArrowDownTray from "@babeard/svelte-heroicons/mini/ArrowDownTray"
import Trash from "@babeard/svelte-heroicons/mini/Trash" import Trash from "@babeard/svelte-heroicons/mini/Trash"
import NothingKnown from "./Popup/NothingKnown.svelte" import NothingKnown from "./Popup/NothingKnown.svelte"
import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch"
@ -96,6 +94,7 @@ import GroupedView from "./Popup/GroupedView.svelte"
import { QuestionableTagRenderingConfigJson } from "../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson" import { QuestionableTagRenderingConfigJson } from "../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import NoteCommentElement from "./Popup/Notes/NoteCommentElement.svelte" import NoteCommentElement from "./Popup/Notes/NoteCommentElement.svelte"
import FediverseLink from "./Popup/FediverseLink.svelte" import FediverseLink from "./Popup/FediverseLink.svelte"
import ImageCarousel from "./Image/ImageCarousel.svelte"
class NearbyImageVis implements SpecialVisualization { class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -712,11 +711,8 @@ export default class SpecialVisualizations {
if (args.length > 0) { if (args.length > 0) {
imagePrefixes = [].concat(...args.map((a) => a.split(","))) imagePrefixes = [].concat(...args.map((a) => a.split(",")))
} }
return new ImageCarousel( const images = AllImageProviders.LoadImagesFor(tags, imagePrefixes)
AllImageProviders.LoadImagesFor(tags, imagePrefixes), return new SvelteUIElement(ImageCarousel, { state, tags, images })
tags,
state,
)
}, },
}, },
{ {

View file

@ -647,11 +647,6 @@ svg.apply-fill path {
/************************* LEGACY MARKER - CLEANUP BELOW ********************************/ /************************* LEGACY MARKER - CLEANUP BELOW ********************************/
.slideshow-item img {
/* Legacy: should be replace when the image element is ported to Svelte*/
height: var(--image-carousel-height);
width: unset;
}
.animate-height { .animate-height {
/* Legacy: should be replaced by headlessui disclosure in time */ /* Legacy: should be replaced by headlessui disclosure in time */