UX: add unlink button, simplify unlink code

This commit is contained in:
Pieter Vander Vennet 2025-06-07 02:52:06 +02:00
parent 45c0f1a8d6
commit 1192434b45
13 changed files with 117 additions and 69 deletions

View file

@ -34,6 +34,9 @@ export default class GenericImageProvider extends ImageProvider {
provider: this, provider: this,
id: value, id: value,
isSpherical: undefined, isSpherical: undefined,
originalAttribute: {
key, value
}
}, },
] ]
} }

View file

@ -26,6 +26,7 @@ export interface ProvidedImage {
host?: string host?: string
isSpherical: boolean isSpherical: boolean
license?: LicenseInfo license?: LicenseInfo
originalAttribute?: {key: string, value: string}
} }
export interface PanoramaView { export interface PanoramaView {

View file

@ -33,6 +33,7 @@ export class Imgur extends ImageProvider {
provider: this, provider: this,
id: value, id: value,
isSpherical: false, isSpherical: false,
originalAttribute: {key, value}
}, },
] ]
} }

View file

@ -170,8 +170,7 @@ export class Mapillary extends ImageProvider {
properties: { properties: {
url: response.thumb_2048_url, url: response.thumb_2048_url,
northOffset: response.computed_compass_angle, northOffset: response.computed_compass_angle,
provider: this, provider: this
imageMeta: <any>image
}, },
} }
} }
@ -246,6 +245,7 @@ export class Mapillary extends ImageProvider {
response.camera_type === "spherical" || response.camera_type === "equirectangular", response.camera_type === "spherical" || response.camera_type === "equirectangular",
lat: geometry.coordinates[1], lat: geometry.coordinates[1],
lon: geometry.coordinates[0], lon: geometry.coordinates[0],
originalAttribute: {key, value}
} }
} }

View file

@ -174,6 +174,7 @@ export default class PanoramaxImageProvider extends ImageProvider {
} }
const providedImage = await this.getInfo(value) const providedImage = await this.getInfo(value)
providedImage.alt_id = alt_id providedImage.alt_id = alt_id
providedImage.originalAttribute = {key, value}
return [providedImage] return [providedImage]
} }

View file

@ -61,7 +61,11 @@ export class WikidataImageProvider extends ImageProvider {
allImages.push(promises) allImages.push(promises)
} }
const resolved = await Promise.all(Utils.NoNull(allImages)) const resolved = await Promise.all(Utils.NoNull(allImages))
return [].concat(...resolved) const flattened = resolved.flatMap( x => x)
if(flattened.length === 1){
flattened[0].originalAttribute = {key, value}
}
return flattened
} }
public DownloadAttribution(): Promise<undefined> { public DownloadAttribution(): Promise<undefined> {

View file

@ -145,14 +145,14 @@ export class WikimediaImageProvider extends ImageProvider {
.map((image) => this.UrlForImage(image)) .map((image) => this.UrlForImage(image))
} }
if (value.startsWith("File:")) { if (value.startsWith("File:")) {
return [this.UrlForImage(value)] return [this.UrlForImage(value, key, value)]
} }
if (value.startsWith("http")) { if (value.startsWith("http")) {
// Probably an error // Probably an error
return undefined return undefined
} }
// We do a last effort and assume this is a file // We do a last effort and assume this is a file
return [this.UrlForImage("File:" + value)] return [this.UrlForImage("File:" + value, key, value)]
} }
public async DownloadAttribution(img: { id: string }): Promise<LicenseInfo> { public async DownloadAttribution(img: { id: string }): Promise<LicenseInfo> {
@ -211,9 +211,9 @@ export class WikimediaImageProvider extends ImageProvider {
return licenseInfo return licenseInfo
} }
private UrlForImage(image: string): ProvidedImage { private UrlForImage(image: string, key?: string, value?: string): ProvidedImage {
image = "File:" + WikimediaImageProvider.makeCanonical(image) image = "File:" + WikimediaImageProvider.makeCanonical(image)
return { const providedImage: ProvidedImage = {
url: WikimediaImageProvider.PrepareUrl(image), url: WikimediaImageProvider.PrepareUrl(image),
url_hd: WikimediaImageProvider.PrepareUrl(image, true), url_hd: WikimediaImageProvider.PrepareUrl(image, true),
key: undefined, key: undefined,
@ -221,6 +221,10 @@ export class WikimediaImageProvider extends ImageProvider {
id: image, id: image,
isSpherical: false, isSpherical: false,
} }
if(key && value){
providedImage.originalAttribute = {key, value}
}
return providedImage
} }
getPanoramaInfo(): Promise<Feature<Point, PanoramaView>> | undefined { getPanoramaInfo(): Promise<Feature<Point, PanoramaView>> | undefined {

View file

@ -200,7 +200,6 @@ export default class UserRelatedState {
public static readonly usersettingsConfig = UserRelatedState.initUserSettingsState() public static readonly usersettingsConfig = UserRelatedState.initUserSettingsState()
public static readonly availableUserSettingsIds: string[] = public static readonly availableUserSettingsIds: string[] =
UserRelatedState.usersettingsConfig?.tagRenderings?.map((tr) => tr.id) ?? [] UserRelatedState.usersettingsConfig?.tagRenderings?.map((tr) => tr.id) ?? []
public static readonly SHOW_TAGS_VALUES = ["always", "yes", "full"] as const
/** /**
The user credentials The user credentials
*/ */
@ -212,6 +211,7 @@ export default class UserRelatedState {
public readonly installedUserThemes: Store<string[]> public readonly installedUserThemes: Store<string[]>
public readonly showAllQuestionsAtOnce: UIEventSource<boolean> public readonly showAllQuestionsAtOnce: UIEventSource<boolean>
public readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full"> public readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
public readonly showTagsB: Store<boolean>
public readonly showCrosshair: UIEventSource<"yes" | "always" | "no" | undefined> public readonly showCrosshair: UIEventSource<"yes" | "always" | "no" | undefined>
public readonly translationMode: UIEventSource<"false" | "true" | "mobile" | undefined | string> public readonly translationMode: UIEventSource<"false" | "true" | "mobile" | undefined | string>
@ -269,6 +269,20 @@ export default class UserRelatedState {
) )
this.language = this.osmConnection.getPreference("language") this.language = this.osmConnection.getPreference("language")
this.showTags = this.osmConnection.getPreference("show_tags") this.showTags = this.osmConnection.getPreference("show_tags")
this.showTagsB = this.showTags.map(showTags => {
if (showTags === "always" || showTags === "full") {
return true
}
if (showTags === "no") {
return false
}
const userdetails = this.osmConnection.userDetails.data
if (!userdetails) {
return false
}
const csCount = userdetails.csCount
return csCount >= Constants.userJourney.tagsVisibleAt
}, [this.osmConnection.userDetails])
this.showCrosshair = this.osmConnection.getPreference("show_crosshair") this.showCrosshair = this.osmConnection.getPreference("show_crosshair")
this.fixateNorth = this.osmConnection.getPreference("fixate-north") this.fixateNorth = this.osmConnection.getPreference("fixate-north")
this.morePrivacy = this.osmConnection.getPreference("more_privacy", { defaultValue: "no" }) this.morePrivacy = this.osmConnection.getPreference("more_privacy", { defaultValue: "no" })

View file

@ -22,7 +22,8 @@
*/ */
export let silentFail: boolean = false export let silentFail: boolean = false
/** /**
* If set and the OSM-api fails, do _not_ show any error messages nor the successful state, just hide * If set and the OSM-api fails, do _not_ show any error messages nor the successful state, just hide.
* Will still show the "not-logged-in"-slot
*/ */
export let hiddenFail: boolean = false export let hiddenFail: boolean = false
let loadingStatus = state?.osmConnection?.loadingStatus ?? new ImmutableStore("logged-in") let loadingStatus = state?.osmConnection?.loadingStatus ?? new ImmutableStore("logged-in")

View file

@ -24,14 +24,7 @@
let isDisplayed: UIEventSource<boolean> = filteredLayer.isDisplayed let isDisplayed: UIEventSource<boolean> = filteredLayer.isDisplayed
let isDebugging = state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false) let isDebugging = state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false)
let showTags = state?.userRelatedState?.showTags?.map( let showTags = state?.userRelatedState?.showTagsB
(s) =>
(s === "yes" &&
state?.userRelatedState?.osmConnection?.userDetails?.data?.csCount >=
Constants.userJourney.tagsVisibleAt) ||
s === "always" ||
s === "full"
)
/** /**
* Gets a UIEventSource as boolean for the given option, to be used with a checkbox * Gets a UIEventSource as boolean for the given option, to be used with a checkbox

View file

@ -24,6 +24,7 @@
import Panorama360 from "../../assets/svg/Panorama360.svelte" import Panorama360 from "../../assets/svg/Panorama360.svelte"
import { ExternalLinkIcon } from "@rgossiaux/svelte-heroicons/solid" import { ExternalLinkIcon } from "@rgossiaux/svelte-heroicons/solid"
import { ExclamationTriangle as TriangleOutline } from "@babeard/svelte-heroicons/outline/ExclamationTriangle" import { ExclamationTriangle as TriangleOutline } from "@babeard/svelte-heroicons/outline/ExclamationTriangle"
import LoginToggle from "../Base/LoginToggle.svelte"
export let image: Partial<ProvidedImage> & { id: string; url: string } export let image: Partial<ProvidedImage> & { id: string; url: string }
let fallbackImage: string = undefined let fallbackImage: string = undefined
@ -43,16 +44,39 @@
let loaded = false let loaded = false
let error = false let error = false
let notFound = false
let ignoreHidden = false let ignoreHidden = false
let isInStrictMode = new UIEventSource(false) let isInStrictMode = new UIEventSource(false)
function onError() { async function detectErrorReason() {
error = true try {
const response = await fetch(
image.url,
{
headers: {
"Accept": "image/avif,image/webp,*/*",
},
},
)
if (response.status === 404) {
notFound = true
}
} catch
(e) {
console.log("Could not load image while trying to remediate", e)
}
}
async function onError() {
Mapillary.isInStrictMode().addCallbackAndRunD(isStrict => { Mapillary.isInStrictMode().addCallbackAndRunD(isStrict => {
isInStrictMode.set(isStrict) isInStrictMode.set(isStrict)
return true // unregister return true // unregister
}) })
await detectErrorReason()
error = true
} }
let visitUrl = image.provider?.visitUrl(image) let visitUrl = image.provider?.visitUrl(image)
let showBigPreview = new UIEventSource(false) let showBigPreview = new UIEventSource(false)
onDestroy( onDestroy(
@ -112,15 +136,25 @@
</Popup> </Popup>
{#if error} {#if error}
<div class="h-80 w-60 interactive flex flex-col justify-center items-center p-4 text-center"> <div class="h-80 w-60 interactive flex flex-col justify-center items-center p-4 text-center">
<div class="alert flex items-center"> {#if notFound}
<TriangleOutline class="shrink-0 h-8 w-8" /> <div class="alert flex items-center">
<Tr t={Translations.t.image.loadingFailed}/> <TriangleOutline class="shrink-0 h-8 w-8" />
</div> Not found
{#if image.provider.name.toLowerCase() === "mapillary" && $isInStrictMode} </div>
<Tr t={Translations.t.image.mapillaryTrackingProtection}/> This image is probably incorrect or deleted.
{:else if $isInStrictMode} <slot name="not-found-extra" />
<Tr t={Translations.t.image.strictProtectionDetected}/> {:else}
<div class="subtle text-sm mt-8">{image.url}</div> <div class="alert flex items-center">
<TriangleOutline class="shrink-0 h-8 w-8" />
<Tr t={Translations.t.image.loadingFailed} />
</div>
{#if image.provider.name.toLowerCase() === "mapillary" && $isInStrictMode}
<Tr t={Translations.t.image.mapillaryTrackingProtection} />
{:else if $isInStrictMode}
<Tr t={Translations.t.image.strictProtectionDetected} />
{image.provider.name}
<div class="subtle text-sm mt-8">{image.url}</div>
{/if}
{/if} {/if}
</div> </div>
{:else if image.status !== undefined && image.status !== "ready" && image.status !== "hidden"} {:else if image.status !== undefined && image.status !== "ready" && image.status !== "hidden"}

View file

@ -39,6 +39,8 @@
let reportFreeText = new UIEventSource<string>(undefined) let reportFreeText = new UIEventSource<string>(undefined)
let reported = new UIEventSource<boolean>(false) let reported = new UIEventSource<boolean>(false)
let canBeUnlinked = image.originalAttribute !== undefined
async function requestDeletion() { async function requestDeletion() {
if (reportReason.data === "other" && !reportFreeText.data) { if (reportReason.data === "other" && !reportFreeText.data) {
return return
@ -63,31 +65,20 @@
} }
async function unlink() { async function unlink() {
console.log("Unlinking image", image.key, image.id) const {key} = image.originalAttribute
if (image.id.length < 10) { await state?.changes?.applyAction(
console.error("Suspicious value, not deleting ", image.id) new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data, {
return changeType: "delete-image",
} theme: state.theme.id,
// The "key" is the provider key, but not necessarely the actual key that should be reset })
// We iterate over all tags. *Every* tag for which the value contains the id will be deleted )
const tgs = tags.data
for (const key in tgs) {
if (typeof tgs[key] !== "string" || tgs[key].indexOf(image.id) < 0) {
continue
}
await state?.changes?.applyAction(
new ChangeTagAction(tgs.id, new Tag(key, ""), tgs, {
changeType: "delete-image",
theme: state.theme.id,
})
)
}
} }
const t = Translations.t.image.panoramax const t = Translations.t.image.panoramax
const tu = Translations.t.image.unlink const tu = Translations.t.image.unlink
const placeholder = t.placeholder.current const placeholder = t.placeholder.current
let showTags = state.userRelatedState?.showTagsB
</script> </script>
<Popup shown={showDeleteDialog}> <Popup shown={showDeleteDialog}>
@ -169,10 +160,24 @@
<DownloadIcon /> <DownloadIcon />
<Tr t={Translations.t.general.download.downloadImage} /> <Tr t={Translations.t.general.download.downloadImage} />
</button> </button>
<button on:click={() => showDeleteDialog.set(true)} class="flex items-center"> {#if canBeUnlinked}
<TrashIcon /> <button on:click={() => showDeleteDialog.set(true)} class="flex items-center">
<Tr t={tu.button} /> <TrashIcon />
</button> <Tr t={tu.button} />
</button>
{/if}
</svelte:fragment>
<svelte:fragment slot="not-found-extra">
{#if canBeUnlinked}
<button on:click={() => unlink()}>
<Tr t={tu.button} />
</button>
{#if $showTags}
<div class="subtle line-through">
{image.originalAttribute.key}={image.originalAttribute.value}
</div>
{/if}
{/if}
</svelte:fragment> </svelte:fragment>
</AttributedImage> </AttributedImage>
</div> </div>

View file

@ -16,9 +16,7 @@
import SubtleButton from "../../Base/SubtleButton.svelte" import SubtleButton from "../../Base/SubtleButton.svelte"
import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte" import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte"
import { Translation } from "../../i18n/Translation" import { Translation } from "../../i18n/Translation"
import Constants from "../../../Models/Constants"
import { Unit } from "../../../Models/Unit" import { Unit } from "../../../Models/Unit"
import UserRelatedState from "../../../Logic/State/UserRelatedState"
import { twJoin } from "tailwind-merge" import { twJoin } from "tailwind-merge"
import { TagUtils } from "../../../Logic/Tags/TagUtils" import { TagUtils } from "../../../Logic/Tags/TagUtils"
@ -31,8 +29,8 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import Markdown from "../../Base/Markdown.svelte" import Markdown from "../../Base/Markdown.svelte"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import { TagTypes } from "../../../Logic/Tags/TagTypes"
import type { UploadableTag } from "../../../Logic/Tags/TagTypes" import type { UploadableTag } from "../../../Logic/Tags/TagTypes"
import { TagTypes } from "../../../Logic/Tags/TagTypes"
import Popup from "../../Base/Popup.svelte" import Popup from "../../Base/Popup.svelte"
import If from "../../Base/If.svelte" import If from "../../Base/If.svelte"
@ -315,8 +313,7 @@
let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false) let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false)
let featureSwitchIsDebugging = let featureSwitchIsDebugging =
state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false) state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false)
let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined) let showTags : Store<boolean> = state?.userRelatedState?.showTagsB ?? new ImmutableStore(false)
let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0
let question = config.question let question = config.question
let hideMappingsUnlessSearchedFor = let hideMappingsUnlessSearchedFor =
config.mappings.length > 8 && config.mappings.some((m) => m.priorityIf !== undefined) config.mappings.length > 8 && config.mappings.some((m) => m.priorityIf !== undefined)
@ -324,14 +321,6 @@
$: hideMappingsUnlessSearchedFor = $: hideMappingsUnlessSearchedFor =
config.mappings.length > 8 && config.mappings.some((m) => m.priorityIf !== undefined) config.mappings.length > 8 && config.mappings.some((m) => m.priorityIf !== undefined)
if (state?.osmConnection) {
onDestroy(
state.osmConnection?.userDetails?.addCallbackAndRun((ud) => {
numberOfCs = ud?.csCount
})
)
}
function clearAnswer() { function clearAnswer() {
const tagsToSet: UploadableTag[] = onMarkUnknown.data const tagsToSet: UploadableTag[] = onMarkUnknown.data
const change = new ChangeTagAction(tags.data.id, new And(tagsToSet), tags.data, { const change = new ChangeTagAction(tags.data.id, new And(tagsToSet), tags.data, {
@ -577,9 +566,7 @@
</h2> </h2>
<Tr t={Translations.t.unknown.explanation} /> <Tr t={Translations.t.unknown.explanation} />
<If <If
condition={state.userRelatedState?.showTags?.map( condition={state.userRelatedState?.showTagsB}
(v) => v === "yes" || v === "full" || v === "always"
)}
> >
<div class="subtle"> <div class="subtle">
<Tr t={Translations.t.unknown.removedKeys} /> <Tr t={Translations.t.unknown.removedKeys} />
@ -639,7 +626,7 @@
</div> </div>
</div> </div>
<!-- Taghint + debug info --> <!-- Taghint + debug info -->
{#if UserRelatedState.SHOW_TAGS_VALUES.indexOf($showTags) >= 0 || ($showTags === "" && numberOfCs >= Constants.userJourney.tagsVisibleAt) || $featureSwitchIsTesting || $featureSwitchIsDebugging} {#if $showTags || $featureSwitchIsTesting || $featureSwitchIsDebugging}
<span class="flex flex-wrap justify-between"> <span class="flex flex-wrap justify-between">
<TagHint tags={selectedTags} currentProperties={$tags} /> <TagHint tags={selectedTags} currentProperties={$tags} />
<span class="flex flex-wrap"> <span class="flex flex-wrap">