Add themes to search functionality, including quickswitch between recent themes

This commit is contained in:
Pieter Vander Vennet 2024-08-22 02:54:46 +02:00
parent b4866cdbac
commit 329865a15e
22 changed files with 679 additions and 431 deletions

View file

@ -393,6 +393,9 @@
"search": { "search": {
"error": "Something went wrong…", "error": "Something went wrong…",
"nothing": "Nothing found…", "nothing": "Nothing found…",
"nothingFor": "No results found for {term}",
"recentThemes": "Recently visited maps",
"recents": "Recent searches",
"search": "Search a location", "search": "Search a location",
"searchShort": "Search…", "searchShort": "Search…",
"searching": "Searching…" "searching": "Searching…"

View file

@ -2558,11 +2558,6 @@ video {
border-color: rgb(209 213 219 / var(--tw-border-opacity)); border-color: rgb(209 213 219 / var(--tw-border-opacity));
} }
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-gray-800 { .border-gray-800 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(31 41 55 / var(--tw-border-opacity)); border-color: rgb(31 41 55 / var(--tw-border-opacity));
@ -2658,6 +2653,11 @@ video {
border-color: rgb(34 197 94 / var(--tw-border-opacity)); border-color: rgb(34 197 94 / var(--tw-border-opacity));
} }
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-gray-700 { .border-gray-700 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(55 65 81 / var(--tw-border-opacity)); border-color: rgb(55 65 81 / var(--tw-border-opacity));
@ -4380,9 +4380,9 @@ video {
:root { :root {
/* /*
* The main colour scheme of mapcomplete is configured here. * The main colour scheme of mapcomplete is configured here.
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these. * For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/ */
/* No support for dark mode yet, we disable it to prevent some elements to suddenly toggle */ /* No support for dark mode yet, we disable it to prevent some elements to suddenly toggle */
color-scheme: only light; color-scheme: only light;
/* Main color of the application: the background and text colours */ /* Main color of the application: the background and text colours */
@ -4407,9 +4407,9 @@ video {
--disabled: #B8B8B8; --disabled: #B8B8B8;
--disabled-font: #B8B8B8; --disabled-font: #B8B8B8;
/** /**
* Base colour of interactive elements, mainly the 'subtle button' * Base colour of interactive elements, mainly the 'subtle button'
* @deprecated * @deprecated
*/ */
--subtle-detail-color: #dbeafe; --subtle-detail-color: #dbeafe;
--subtle-detail-color-contrast: black; --subtle-detail-color-contrast: black;
--subtle-detail-color-light-contrast: lightgrey; --subtle-detail-color-light-contrast: lightgrey;
@ -4419,14 +4419,14 @@ video {
--catch-detail-color-contrast: #fb3afb; --catch-detail-color-contrast: #fb3afb;
--image-carousel-height: 350px; --image-carousel-height: 350px;
/** Technical value, used by icon.svelte /** Technical value, used by icon.svelte
*/ */
--svg-color: #000000; --svg-color: #000000;
} }
@font-face{ @font-face {
font-family:"Source Sans Pro"; font-family: "Source Sans Pro";
src:url("/assets/source-sans-pro.regular.ttf") format("woff"); src: url("/assets/source-sans-pro.regular.ttf") format("woff");
} }
/***********************************************************************\ /***********************************************************************\
@ -4663,18 +4663,18 @@ select:hover {
.neutral-label { .neutral-label {
/** This label styles as normal text. It's power comes from the many :not(.neutral-label) entries. /** This label styles as normal text. It's power comes from the many :not(.neutral-label) entries.
* Placed here for autocompletion * Placed here for autocompletion
*/ */
} }
label:not(.neutral-label):not(.button) { label:not(.neutral-label):not(.button) {
/** /**
* Label should _contain_ the input element * Label should _contain_ the input element
*/ */
padding: 0.25rem; padding: 0.25rem;
padding-right: 0.5rem; padding-right: 0.5rem;
padding-left: 0.5rem; padding-left: 0.5rem;
margin:0.25rem; margin: 0.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@ -4887,6 +4887,10 @@ a.link-underline {
color: unset !important; color: unset !important;
} }
a:hover {
background-color: var(--low-interaction-background);
}
.disable-links a.must-link, .disable-links a.must-link,
.disable-links .must-link a { .disable-links .must-link a {
/* Hide links if they are disabled */ /* Hide links if they are disabled */
@ -4901,7 +4905,7 @@ a.link-underline {
.selected svg:not(.noselect *) path.selectable { .selected svg:not(.noselect *) path.selectable {
/* A marker on the map gets the 'selected' class when it's properties are displayed /* A marker on the map gets the 'selected' class when it's properties are displayed
*/ */
stroke: white !important; stroke: white !important;
stroke-width: 20px !important; stroke-width: 20px !important;
overflow: visible !important; overflow: visible !important;
@ -4915,7 +4919,7 @@ a.link-underline {
.selected svg { .selected svg {
/* A marker on the map gets the 'selected' class when it's properties are displayed /* A marker on the map gets the 'selected' class when it's properties are displayed
*/ */
overflow: visible !important; overflow: visible !important;
} }

View file

@ -1,12 +1,13 @@
import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider" import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider"
import { Utils } from "../../Utils"
export default class CombinedSearcher implements GeocodingProvider { export default class CombinedSearcher implements GeocodingProvider {
private _providers: ReadonlyArray<GeocodingProvider> private _providers: ReadonlyArray<GeocodingProvider>
private _providersWithSuggest: ReadonlyArray<GeocodingProvider> private _providersWithSuggest: ReadonlyArray<GeocodingProvider>
constructor(...providers: ReadonlyArray<GeocodingProvider>) { constructor(...providers: ReadonlyArray<GeocodingProvider>) {
this._providers = providers this._providers = Utils.NoNull(providers)
this._providersWithSuggest = providers.filter(pr => pr.suggest !== undefined) this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined)
} }
/** /**

View file

@ -24,7 +24,7 @@ export type GeoCodeResult = {
osm_type?: "node" | "way" | "relation" osm_type?: "node" | "way" | "relation"
osm_id?: string, osm_id?: string,
category?: GeocodingCategory, category?: GeocodingCategory,
importance?: number payload?: object
} }
export interface GeocodingOptions { export interface GeocodingOptions {

View file

@ -7,21 +7,20 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export class RecentSearch { export class RecentSearch {
private readonly _recentSearches: UIEventSource<string[]>
public readonly recentSearches: Store<string[]>
private readonly _seenThisSession: UIEventSource<GeoCodeResult[]> = new UIEventSource<GeoCodeResult[]>([]) private readonly _seenThisSession: UIEventSource<GeoCodeResult[]>
public readonly seenThisSession: Store<GeoCodeResult[]> = this._seenThisSession public readonly seenThisSession: Store<GeoCodeResult[]>
constructor(state: { layout: LayoutConfig, osmConnection: OsmConnection, selectedElement: Store<Feature> }) { constructor(state: { layout: LayoutConfig, osmConnection: OsmConnection, selectedElement: Store<Feature> }) {
const longPref = state.osmConnection.preferencesHandler.GetLongPreference("recent-searches") // const prefs = state.osmConnection.preferencesHandler.GetLongPreference("previous-searches")
this._recentSearches = longPref.sync(str => !str ? [] : <string[]>JSON.parse(str), [], strs => JSON.stringify(strs)) this._seenThisSession = new UIEventSource<GeoCodeResult[]>([])//UIEventSource.asObject<GeoCodeResult[]>(prefs, [])
this.recentSearches = this._recentSearches this.seenThisSession = this._seenThisSession
state.selectedElement.addCallbackAndRunD(selected => { state.selectedElement.addCallbackAndRunD(selected => {
const [osm_type, osm_id] = selected.properties.id.split("/") const [osm_type, osm_id] = selected.properties.id.split("/")
const [lon, lat] = GeoOperations.centerpointCoordinates(selected) const [lon, lat] = GeoOperations.centerpointCoordinates(selected)
const entry = <GeoCodeResult> { const entry = <GeoCodeResult>{
feature: selected, feature: selected,
osm_id, osm_type, osm_id, osm_type,
description: "Viewed recently", description: "Viewed recently",
@ -33,7 +32,7 @@ export class RecentSearch {
} }
addSelected(entry: GeoCodeResult) { addSelected(entry: GeoCodeResult) {
const arr = [...this.seenThisSession.data.slice(0, 20), entry] const arr = [...(this.seenThisSession.data ?? []).slice(0, 20), entry]
const seenIds = new Set<string>() const seenIds = new Set<string>()
for (let i = arr.length - 1; i >= 0; i--) { for (let i = arr.length - 1; i >= 0; i--) {

View file

@ -0,0 +1,43 @@
import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider"
import * as themeOverview from "../../assets/generated/theme_overview.json"
import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import { Utils } from "../../Utils"
import MoreScreen from "../../UI/BigComponents/MoreScreen"
import { Store } from "../UIEventSource"
export default class ThemeSearch implements GeocodingProvider {
private static allThemes: MinimalLayoutInformation[] = (themeOverview["default"] ?? themeOverview)
private readonly _state: SpecialVisualizationState
private readonly _knownHiddenThemes: Store<Set<string>>
constructor(state: SpecialVisualizationState) {
this._state = state
this._knownHiddenThemes = MoreScreen.knownHiddenThemes(this._state.osmConnection)
}
search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
return this.suggest(query, options)
}
async suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
if(query.length < 1){
return []
}
const limit = options?.limit ?? 4
query = Utils.simplifyStringForSearch(query)
const withMatch = ThemeSearch.allThemes
.filter(th => !th.hideFromOverview )
.filter(th => th.id !== this._state.layout.id)
.filter(th => MoreScreen.MatchesLayout(th, query))
.slice(0, limit + 1)
return withMatch.map(match => (<GeoCodeResult> {
payload: match,
osm_id: match.id
}))
}
}

View file

@ -71,7 +71,7 @@ export class OsmPreferences {
} }
if (str === null) { if (str === null) {
console.error("Deleting " + allStartWith) console.error("Deleting " + allStartWith)
let count = parseInt(length.data) const count = parseInt(length.data)
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
// Delete all the preferences // Delete all the preferences
self.GetPreference(allStartWith + "-" + i, "", subOptions).setData("") self.GetPreference(allStartWith + "-" + i, "", subOptions).setData("")

View file

@ -78,6 +78,10 @@ export default class UserRelatedState {
public readonly preferencesAsTags: UIEventSource<Record<string, string>> public readonly preferencesAsTags: UIEventSource<Record<string, string>>
private readonly _mapProperties: MapProperties private readonly _mapProperties: MapProperties
private readonly _recentlyVisitedThemes: UIEventSource<string[]>
public readonly recentlyVisitedThemes: Store<string[]>
constructor( constructor(
osmConnection: OsmConnection, osmConnection: OsmConnection,
layout?: LayoutConfig, layout?: LayoutConfig,
@ -109,7 +113,7 @@ export default class UserRelatedState {
this.showAllQuestionsAtOnce = UIEventSource.asBoolean( this.showAllQuestionsAtOnce = UIEventSource.asBoolean(
this.osmConnection.GetPreference("show-all-questions", "false", { this.osmConnection.GetPreference("show-all-questions", "false", {
documentation: documentation:
"Either 'true' or 'false'. If set, all questions will be shown all at once", "Either 'true' or 'false'. If set, all questions will be shown all at once"
}) })
) )
this.language = this.osmConnection.GetPreference("language") this.language = this.osmConnection.GetPreference("language")
@ -129,7 +133,7 @@ export default class UserRelatedState {
undefined, undefined,
{ {
documentation: documentation:
"The ID of a layer or layer category that MapComplete uses by default", "The ID of a layer or layer category that MapComplete uses by default"
} }
) )
@ -137,12 +141,12 @@ export default class UserRelatedState {
"preferences-add-new-mode", "preferences-add-new-mode",
"button_click_right", "button_click_right",
{ {
documentation: "How adding a new feature is done", documentation: "How adding a new feature is done"
} }
) )
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", { this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
documentation: "The license under which new images are uploaded", documentation: "The license under which new images are uploaded"
}) })
this.installedUserThemes = this.InitInstalledUserThemes() this.installedUserThemes = this.InitInstalledUserThemes()
@ -150,6 +154,30 @@ export default class UserRelatedState {
this.preferencesAsTags = this.initAmendedPrefs(layout, featureSwitches) this.preferencesAsTags = this.initAmendedPrefs(layout, featureSwitches)
const prefs = this.osmConnection
this._recentlyVisitedThemes = UIEventSource.asObject(prefs.GetLongPreference("recently-visited-themes"), [])
this.recentlyVisitedThemes = this._recentlyVisitedThemes
if (layout) {
const osmConn =this.osmConnection
const recentlyVisited = this._recentlyVisitedThemes
function update() {
if (!osmConn.isLoggedIn.data) {
return
}
const previously = recentlyVisited.data
if (previously[0] === layout.id) {
return true
}
const newThemes = Utils.Dedup([layout.id, ...previously]).slice(0, 30)
recentlyVisited.set(newThemes)
return true
}
this._recentlyVisitedThemes.addCallbackAndRun(() => update())
this.osmConnection.isLoggedIn.addCallbackAndRun(() => update())
}
this.syncLanguage() this.syncLanguage()
} }
@ -171,13 +199,13 @@ export default class UserRelatedState {
public GetUnofficialTheme(id: string): public GetUnofficialTheme(id: string):
| { | {
id: string id: string
icon: string icon: string
title: any title: any
shortDescription: any shortDescription: any
definition?: any definition?: any
isOfficial: boolean isOfficial: boolean
} }
| undefined { | undefined {
console.log("GETTING UNOFFICIAL THEME") console.log("GETTING UNOFFICIAL THEME")
const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id) const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id)
@ -202,8 +230,8 @@ export default class UserRelatedState {
} catch (e) { } catch (e) {
console.warn( console.warn(
"Removing theme " + "Removing theme " +
id + id +
" as it could not be parsed from the preferences; the content is:", " as it could not be parsed from the preferences; the content is:",
str str
) )
pref.setData(null) pref.setData(null)
@ -233,7 +261,7 @@ export default class UserRelatedState {
icon: layout.icon, icon: layout.icon,
title: layout.title.translations, title: layout.title.translations,
shortDescription: layout.shortDescription.translations, shortDescription: layout.shortDescription.translations,
definition: layout["definition"], definition: layout["definition"]
}) })
) )
} }
@ -273,13 +301,13 @@ export default class UserRelatedState {
id: "home", id: "home",
"user:home": "yes", "user:home": "yes",
_lon: homeLonLat[0], _lon: homeLonLat[0],
_lat: homeLonLat[1], _lat: homeLonLat[1]
}, },
geometry: { geometry: {
type: "Point", type: "Point",
coordinates: homeLonLat, coordinates: homeLonLat
}, }
}, }
] ]
}) })
return new StaticFeatureSource(feature) return new StaticFeatureSource(feature)
@ -300,7 +328,7 @@ export default class UserRelatedState {
_applicationOpened: new Date().toISOString(), _applicationOpened: new Date().toISOString(),
_supports_sharing: _supports_sharing:
typeof window === "undefined" ? "no" : window.navigator.share ? "yes" : "no", typeof window === "undefined" ? "no" : window.navigator.share ? "yes" : "no",
_iframe: Utils.isIframe ? "yes" : "no", _iframe: Utils.isIframe ? "yes" : "no"
}) })
for (const key in Constants.userJourney) { for (const key in Constants.userJourney) {
@ -355,18 +383,18 @@ export default class UserRelatedState {
const zenLinks: { link: string; id: string }[] = Utils.NoNull([ const zenLinks: { link: string; id: string }[] = Utils.NoNull([
hasMissingTheme hasMissingTheme
? { ? {
id: "theme:" + layout.id, id: "theme:" + layout.id,
link: LinkToWeblate.hrefToWeblateZen( link: LinkToWeblate.hrefToWeblateZen(
language, language,
"themes", "themes",
layout.id layout.id
), )
} }
: undefined, : undefined,
...missingLayers.map((id) => ({ ...missingLayers.map((id) => ({
id: "layer:" + id, id: "layer:" + id,
link: LinkToWeblate.hrefToWeblateZen(language, "layers", id), link: LinkToWeblate.hrefToWeblateZen(language, "layers", id)
})), }))
]) ])
const untranslated_count = untranslated.length const untranslated_count = untranslated.length
amendedPrefs.data["_translation_total"] = "" + total amendedPrefs.data["_translation_total"] = "" + total
@ -391,8 +419,8 @@ export default class UserRelatedState {
for (const k in userDetails) { for (const k in userDetails) {
amendedPrefs.data["_" + k] = "" + userDetails[k] amendedPrefs.data["_" + k] = "" + userDetails[k]
} }
if(userDetails.description){ if (userDetails.description) {
amendedPrefs.data["_description_html"] = Utils.purify(new Showdown.Converter() amendedPrefs.data["_description_html"] = Utils.purify(new Showdown.Converter()
.makeHtml(userDetails.description) .makeHtml(userDetails.description)
?.replace(/&gt;/g, ">") ?.replace(/&gt;/g, ">")
?.replace(/&lt;/g, "<") ?.replace(/&lt;/g, "<")

View file

@ -104,7 +104,9 @@ export abstract class Store<T> implements Readable<T> {
extraStoresToWatch: Store<any>[], extraStoresToWatch: Store<any>[],
callbackDestroyFunction: (f: () => void) => void callbackDestroyFunction: (f: () => void) => void
): Store<J> ): Store<J>
M M
public mapD<J>( public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J, f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<any>[], extraStoresToWatch?: Store<any>[],
@ -246,6 +248,7 @@ export abstract class Store<T> implements Readable<T> {
return f(<Exclude<T, undefined | null>>t) return f(<Exclude<T, undefined | null>>t)
}) })
} }
public stabilized(millisToStabilize): Store<T> { public stabilized(millisToStabilize): Store<T> {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return this return this
@ -311,12 +314,14 @@ export class ImmutableStore<T> extends Store<T> {
public readonly data: T public readonly data: T
static FALSE = new ImmutableStore<boolean>(false) static FALSE = new ImmutableStore<boolean>(false)
static TRUE = new ImmutableStore<boolean>(true) static TRUE = new ImmutableStore<boolean>(true)
constructor(data: T) { constructor(data: T) {
super() super()
this.data = data this.data = data
} }
private static readonly pass: () => void = () => {} private static readonly pass: () => void = () => {
}
addCallback(_: (data: T) => void): () => void { addCallback(_: (data: T) => void): () => void {
// pass: data will never change // pass: data will never change
@ -718,6 +723,27 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
) )
} }
static asObject<T extends object>(stringUIEventSource: UIEventSource<string>, defaultV: T): UIEventSource<T> {
return stringUIEventSource.sync(
(str) => {
if (str === undefined || str === null || str === "") {
return defaultV
}
try {
return <T> JSON.parse(str)
} catch (e) {
console.error("Could not parse value", str,"due to",e)
return defaultV
}
},
[],
(b) => {
console.log("Stringifying", b)
return JSON.stringify(b) ?? ""
}
)
}
/** /**
* Create a new UIEVentSource. Whenever 'source' changes, the returned UIEventSource will get this value as well. * Create a new UIEVentSource. Whenever 'source' changes, the returned UIEventSource will get this value as well.
* However, this value can be overriden without affecting source * However, this value can be overriden without affecting source
@ -863,7 +889,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
const newSource = new UIEventSource<J>(f(this.data), "map(" + this.tag + ")@" + callee) const newSource = new UIEventSource<J>(f(this.data), "map(" + this.tag + ")@" + callee)
const update = function () { const update = function() {
newSource.setData(f(self.data)) newSource.setData(f(self.data))
return allowUnregister && newSource._callbacks.length() === 0 return allowUnregister && newSource._callbacks.length() === 0
} }

View file

@ -12,7 +12,21 @@ import { RasterLayerProperties } from "../RasterLayerProperties"
import { ConversionContext } from "./Conversion/ConversionContext" import { ConversionContext } from "./Conversion/ConversionContext"
import { Translatable } from "./Json/Translatable" import { Translatable } from "./Json/Translatable"
import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "./Json/TagRenderingConfigJson"
/**
* Minimal information about a theme
**/
export class MinimalLayoutInformation {
id: string
icon: string
title: Translatable
shortDescription: Translatable
definition?: Translatable
mustHaveLanguage?: boolean
hideFromOverview?: boolean
keywords?: (Translatable | TagRenderingConfigJson)[]
}
/** /**
* Minimal information about a theme * Minimal information about a theme
**/ **/
@ -27,6 +41,8 @@ export class LayoutInformation {
keywords?: (Translatable | Translation)[] keywords?: (Translatable | Translation)[]
} }
export default class LayoutConfig implements LayoutInformation { export default class LayoutConfig implements LayoutInformation {
public static readonly defaultSocialImage = "assets/SocialImage.png" public static readonly defaultSocialImage = "assets/SocialImage.png"
public readonly id: string public readonly id: string

View file

@ -81,6 +81,7 @@ import CoordinateSearch from "../Logic/Geocoding/CoordinateSearch"
import LocalElementSearch from "../Logic/Geocoding/LocalElementSearch" import LocalElementSearch from "../Logic/Geocoding/LocalElementSearch"
import { RecentSearch } from "../Logic/Geocoding/RecentSearch" import { RecentSearch } from "../Logic/Geocoding/RecentSearch"
import PhotonSearch from "../Logic/Geocoding/PhotonSearch" import PhotonSearch from "../Logic/Geocoding/PhotonSearch"
import ThemeSearch from "../Logic/Geocoding/ThemeSearch"
/** /**
* *
@ -393,6 +394,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
new LocalElementSearch(this, 5), new LocalElementSearch(this, 5),
new PhotonSearch(), // new NominatimGeocoding(), new PhotonSearch(), // new NominatimGeocoding(),
new CoordinateSearch(), new CoordinateSearch(),
this.featureSwitches.featureSwitchBackToThemeOverview.data ? new ThemeSearch(this) : undefined
) )
this.recentlySearched = new RecentSearch(this) this.recentlySearched = new RecentSearch(this)

View file

@ -35,7 +35,7 @@
"oauth_token", "oauth_token",
undefined, undefined,
"Used to complete the login" "Used to complete the login"
), )
}) })
const state = new UserRelatedState(osmConnection) const state = new UserRelatedState(osmConnection)
const t = Translations.t.index const t = Translations.t.index
@ -44,7 +44,7 @@
let userLanguages = osmConnection.userDetails.map((ud) => ud.languages) let userLanguages = osmConnection.userDetails.map((ud) => ud.languages)
let themeSearchText: UIEventSource<string | undefined> = new UIEventSource<string>(undefined) let themeSearchText: UIEventSource<string | undefined> = new UIEventSource<string>(undefined)
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", function(event) {
if (event.ctrlKey && event.code === "KeyF") { if (event.ctrlKey && event.code === "KeyF") {
document.getElementById("theme-search")?.focus() document.getElementById("theme-search")?.focus()
event.preventDefault() event.preventDefault()
@ -55,20 +55,10 @@
const hiddenThemes: LayoutInformation[] = const hiddenThemes: LayoutInformation[] =
(themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? [] (themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? []
{ {
const prefix = "mapcomplete-hidden-theme-" visitedHiddenThemes = MoreScreen.knownHiddenThemes(state.osmConnection)
const userPreferences = state.osmConnection.preferencesHandler.preferences .map((knownIds) => hiddenThemes.filter((theme) =>
visitedHiddenThemes = userPreferences.map((preferences) => { knownIds.has(theme.id) || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
const knownIds = new Set<string>( ))
Object.keys(preferences)
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
)
return hiddenThemes.filter(
(theme) =>
knownIds.has(theme.id) ||
state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
)
})
} }
</script> </script>
@ -103,7 +93,7 @@
<form <form
class="flex justify-center" class="flex justify-center"
on:submit|preventDefault={(_) => MoreScreen.applySearch(themeSearchText.data)} on:submit|preventDefault={() => MoreScreen.applySearch(themeSearchText.data)}
> >
<label <label
class="neutral-label my-2 flex w-full items-center rounded-full border-2 border-black sm:w-1/2" class="neutral-label my-2 flex w-full items-center rounded-full border-2 border-black sm:w-1/2"

View file

@ -116,8 +116,8 @@
} }
} }
let suggestions: Store<GeoCodeResult[]> = searchContents.stabilized(250).bindD(search => let suggestions: Store<{success: GeoCodeResult[]} | {error}> = searchContents.stabilized(250).bindD(search =>
UIEventSource.FromPromise(searcher.suggest(search), err => console.error(err)) UIEventSource.FromPromiseWithErr(searcher.suggest(search))
) )
</script> </script>

View file

@ -1,19 +1,27 @@
import { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { ImmutableStore, Store } from "../../Logic/UIEventSource" import { Store } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import themeOverview from "../../assets/generated/theme_overview.json" import themeOverview from "../../assets/generated/theme_overview.json"
import Locale from "../i18n/Locale" import Locale from "../i18n/Locale"
import { Translatable } from "../../Models/ThemeConfig/Json/Translatable"
import { TagRenderingConfigJson } from "../../Models/ThemeConfig/Json/TagRenderingConfigJson"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
export default class MoreScreen { export default class MoreScreen {
public static readonly officialThemes: LayoutInformation[] = themeOverview public static readonly officialThemes: MinimalLayoutInformation[] = themeOverview
public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>()
static {
for (const th of MoreScreen.officialThemes) {
MoreScreen.officialThemesById.set(th.id, th)
}
}
public static applySearch(searchTerm: string) { public static applySearch(searchTerm: string) {
searchTerm = searchTerm.toLowerCase() searchTerm = searchTerm.toLowerCase()
if (!searchTerm) { if (!searchTerm) {
return return
} }
if (searchTerm === "personal") { if (searchTerm === "personal") {
window.location.href = MoreScreen.createUrlFor({ id: "personal" }, false).data window.location.href = MoreScreen.createUrlFor({ id: "personal" }, false)
} }
if (searchTerm === "bugs" || searchTerm === "issues") { if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues" window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
@ -38,22 +46,22 @@ export default class MoreScreen {
MoreScreen.MatchesLayout(th, searchTerm) MoreScreen.MatchesLayout(th, searchTerm)
) )
if (publicTheme !== undefined) { if (publicTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(publicTheme, false).data window.location.href = MoreScreen.createUrlFor(publicTheme, false)
} }
const hiddenTheme = MoreScreen.officialThemes.find( const hiddenTheme = MoreScreen.officialThemes.find(
(th) => th.id !== "personal" && MoreScreen.MatchesLayout(th, searchTerm) (th) => th.id !== "personal" && MoreScreen.MatchesLayout(th, searchTerm)
) )
if (hiddenTheme !== undefined) { if (hiddenTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(hiddenTheme, false).data window.location.href = MoreScreen.createUrlFor(hiddenTheme, false)
} }
} }
public static MatchesLayout( public static MatchesLayout(
layout: { layout: {
id: string id: string
title: any title: Translatable
shortDescription: any shortDescription: Translatable
keywords?: any[] keywords?: (Translatable | TagRenderingConfigJson)[]
}, },
search: string search: string
): boolean { ): boolean {
@ -72,7 +80,7 @@ export default class MoreScreen {
if (entity === undefined) { if (entity === undefined) {
continue continue
} }
const term = entity["*"] ?? entity[Locale.language.data] const term: string = entity["*"] ?? entity[Locale.language.data]
if (Utils.RemoveDiacritics(term?.toLowerCase())?.indexOf(search) >= 0) { if (Utils.RemoveDiacritics(term?.toLowerCase())?.indexOf(search) >= 0) {
return true return true
} }
@ -82,10 +90,10 @@ export default class MoreScreen {
} }
public static createUrlFor( public static createUrlFor(
layout: { id: string; definition?: string }, layout: { id: string },
isCustom: boolean, isCustom: boolean,
state?: { layoutToUse?: { id } } state?: { layoutToUse?: { id } }
): Store<string> { ): string {
if (layout === undefined) { if (layout === undefined) {
return undefined return undefined
} }
@ -115,11 +123,22 @@ export default class MoreScreen {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&` linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
} }
let hash = ""
if (layout.definition !== undefined) {
hash = "#" + btoa(JSON.stringify(layout.definition))
}
return new ImmutableStore<string>(`${linkPrefix}${hash}`) return `${linkPrefix}`
}
/**
* Gives all the IDs of the hidden themes which were previously visited
* @param osmConnection
*/
public static knownHiddenThemes(osmConnection: OsmConnection): Store<Set<string>> {
const prefix = "mapcomplete-hidden-theme-"
const userPreferences = osmConnection.preferencesHandler.preferences
return userPreferences.map((preferences) =>
new Set<string>(
Object.keys(preferences)
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
))
} }
} }

View file

@ -12,11 +12,15 @@
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte" import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import ArrowUp from "@babeard/svelte-heroicons/mini/ArrowUp" import ArrowUp from "@babeard/svelte-heroicons/mini/ArrowUp"
import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import Tr from "../Base/Tr.svelte"
import { Translation } from "../i18n/Translation"
import MoreScreen from "./MoreScreen"
export let entry: GeoCodeResult export let entry: GeoCodeResult
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
let layer: LayerConfig let layer: LayerConfig
let tags : UIEventSource<Record<string, string>> let tags: UIEventSource<Record<string, string>>
if (entry.feature?.properties?.id) { if (entry.feature?.properties?.id) {
layer = state.layout.getMatchingLayer(entry.feature.properties) layer = state.layout.getMatchingLayer(entry.feature.properties)
tags = state.featureProperties.getStore(entry.feature.properties.id) tags = state.featureProperties.getStore(entry.feature.properties.id)
@ -28,6 +32,8 @@
let mapRotation = state.mapProperties.rotation let mapRotation = state.mapProperties.rotation
let inView = state.mapProperties.bounds.mapD(bounds => bounds.contains([entry.lon, entry.lat])) let inView = state.mapProperties.bounds.mapD(bounds => bounds.contains([entry.lon, entry.lat]))
let otherTheme: MinimalLayoutInformation | undefined = <MinimalLayoutInformation>entry.payload
function select() { function select() {
console.log("Selected search entry", entry) console.log("Selected search entry", entry)
if (entry.boundingbox) { if (entry.boundingbox) {
@ -41,44 +47,60 @@
} else { } else {
state.mapProperties.flyTo(entry.lon, entry.lat, GeocodingUtils.categoryToZoomLevel[entry.category] ?? 17) state.mapProperties.flyTo(entry.lon, entry.lat, GeocodingUtils.categoryToZoomLevel[entry.category] ?? 17)
} }
if (entry.feature) { if (entry.feature?.properties?.id) {
state.selectedElement.set(entry.feature) state.selectedElement.set(entry.feature)
} }
state.recentlySearched.addSelected(entry) state.recentlySearched.addSelected(entry)
dispatch("select") dispatch("select")
} }
</script> </script>
<button class="unstyled w-full link-no-underline" on:click={() => select() }>
<div class="p-2 flex items-center w-full gap-y-2 w-full">
{#if layer} {#if otherTheme}
<ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} /> <a href={ MoreScreen.createUrlFor(otherTheme, false)} class="flex items-center p-2 w-full gap-y-2 rounded-xl" >
{:else if entry.category}
<Icon icon={GeocodingUtils.categoryToIcon[entry.category]} clss="w-6 h-6 shrink-0" color="#aaa" /> <Icon icon={otherTheme.icon} clss="w-6 h-6 m-1" />
{/if} <div class="flex flex-col">
<div class="flex flex-col items-start pl-2 w-full"> <b>
<div class="flex flex-wrap gap-x-2 justify-between w-full"> <Tr t={new Translation(otherTheme.title)} />
<b class="nowrap"> </b>
{#if layer && $tags?.id} <!--<Tr t={new Translation(otherTheme.shortDescription)} /> -->
<TagRenderingAnswer config={layer.title} selectedElement={entry.feature} {state} {tags} {layer} />
{:else}
{entry.display_name ?? entry.osm_id}
{/if}
</b>
<div class="flex gap-x-1 items-center">
{#if $bearing && !$inView}
<ArrowUp class="w-4 h-4 shrink-0" style={`transform: rotate(${$bearing - $mapRotation}deg)`} />
{/if}
{#if $distance}
{GeoOperations.distanceToHuman($distance)}
{/if}
</div>
</div>
{#if entry.description}
<div class="subtle flex justify-between w-full">
{entry.description}
</div>
{/if}
</div> </div>
</div> </a>
</button>
{:else}
<button class="unstyled w-full link-no-underline" on:click={() => select() }>
<div class="p-2 flex items-center w-full gap-y-2">
{#if layer}
<ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} />
{:else if entry.category}
<Icon icon={GeocodingUtils.categoryToIcon[entry.category]} clss="w-6 h-6 shrink-0" color="#aaa" />
{/if}
<div class="flex flex-col items-start pl-2 w-full">
<div class="flex flex-wrap gap-x-2 justify-between w-full">
<b class="nowrap">
{#if layer && $tags?.id}
<TagRenderingAnswer config={layer.title} selectedElement={entry.feature} {state} {tags} {layer} />
{:else}
{entry.display_name ?? entry.osm_id}
{/if}
</b>
<div class="flex gap-x-1 items-center">
{#if $bearing && !$inView}
<ArrowUp class="w-4 h-4 shrink-0" style={`transform: rotate(${$bearing - $mapRotation}deg)`} />
{/if}
{#if $distance}
{GeoOperations.distanceToHuman($distance)}
{/if}
</div>
</div>
{#if entry.description}
<div class="subtle flex justify-between w-full">
{entry.description}
</div>
{/if}
</div>
</div>
</button>
{/if}

View file

@ -3,59 +3,81 @@
import SearchResult from "./SearchResult.svelte" import SearchResult from "./SearchResult.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import { XMarkIcon } from "@babeard/svelte-heroicons/solid" import { XMarkIcon } from "@babeard/svelte-heroicons/solid"
import { Store } from "../../Logic/UIEventSource" import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Loading from "../Base/Loading.svelte" import Loading from "../Base/Loading.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import MoreScreen from "./MoreScreen"
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let results: GeoCodeResult[] export let results: { success: GeoCodeResult[] } | { error }
export let searchTerm: Store<string> export let searchTerm: Store<string>
export let isFocused: Store<boolean> export let isFocused: UIEventSource<boolean>
let recentlySeen: Store<GeoCodeResult[]> = state.recentlySearched.seenThisSession let recentlySeen: Store<GeoCodeResult[]> = state.recentlySearched.seenThisSession
let recentThemes = state.userRelatedState.recentlyVisitedThemes.mapD(thms => thms.filter(th => th !== state.layout.id).slice(0, 3))
let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview
</script> </script>
<div class="w-full collapsable" style="height: 50rem;" class:collapsed={!$isFocused}> <div class="w-full collapsable" style="height: 50rem;" class:collapsed={!$isFocused}>
{#if $searchTerm.length > 0 && results === undefined} {#if results?.["error"] !== undefined}
<div class="searchbox normal-background items-center">
An error occured
</div>
{:else if $searchTerm.length > 0 && results === undefined}
<div class="searchbox normal-background items-center"> <div class="searchbox normal-background items-center">
<Loading /> <Loading />
</div> </div>
{:else if results?.length > 0} {:else if results?.["success"]?.length > 0}
<div class="relative w-full h-full"> <div class="relative w-full h-full">
<div class="absolute top-0 right-0 searchbox normal-background" <div class="absolute top-0 right-0 searchbox normal-background"
style="width: 25rem"> style="width: 25rem">
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto"> <div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto">
{#each results as entry (entry)} {#each results["success"] as entry (entry)}
<SearchResult on:select {entry} {state} /> <SearchResult on:select {entry} {state} />
{/each} {/each}
</div> </div>
</div> </div>
<div class="absolute top-2 right-2 cursor-pointer" on:click={() => close()}> <div class="absolute top-2 right-2 cursor-pointer" on:click={() => isFocused.setData(false)}>
<XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" /> <XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" />
</div> </div>
</div> </div>
{:else } {:else if $searchTerm.length > 0 || $recentlySeen?.length > 0 || $recentThemes?.length > 0}
<div class="searchbox normal-background "> <div class="searchbox normal-background overflow-y-auto h-full">
{#if $searchTerm.length > 0} {#if $searchTerm.length > 0}
<!-- TODO add translation --> <b class="flex justify-center p-4">
<b class="flex justify-center p-4">No results found for {$searchTerm}</b> <Tr t={Translations.t.general.search.nothingFor.Subs({term: $searchTerm})} />
{/if} </b>
{/if}
{#if $recentlySeen?.length > 0} {#if $recentlySeen?.length > 0}
<!-- TODO add translation --> <h3 class="mx-2">
<h4>Recent searches</h4> <Tr t={Translations.t.general.search.recents} />
</h3>
{#each $recentlySeen as entry} {#each $recentlySeen as entry}
<SearchResult {entry} {state} on:select /> <SearchResult {entry} {state} on:select />
{/each} {/each}
{/if} {/if}
</div>
{#if $recentThemes?.length > 0 && $allowOtherThemes}
<h3 class="mx-2">
<Tr t={Translations.t.general.search.recentThemes} />
</h3>
{#each $recentThemes as themeId (themeId)}
<SearchResult
entry={{payload: MoreScreen.officialThemesById.get(themeId), display_name: themeId, lat: 0, lon: 0}} {state}
on:select />
{/each}
{/if}
</div>
{/if} {/if}
</div> </div>
<style> <style>
.searchbox { .searchbox {
display: flex; display: flex;
@ -68,7 +90,8 @@
.collapsable { .collapsable {
max-height: 50vh; max-height: 50vh;
transition: max-height 350ms ease-in-out; transition: max-height 400ms linear;
transition-delay: 500ms;
overflow: hidden; overflow: hidden;
padding: 0 !important; padding: 0 !important;
} }

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { Translation } from "../i18n/Translation"
import * as personal from "../../../assets/themes/personal/personal.json" import * as personal from "../../../assets/themes/personal/personal.json"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection" import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"

View file

@ -23,15 +23,16 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
"dragRotate", "dragRotate",
"dragPan", "dragPan",
"keyboard", "keyboard",
"touchZoomRotate", "touchZoomRotate"
] ]
private static maplibre_zoom_handlers = [ private static maplibre_zoom_handlers = [
"scrollZoom", "scrollZoom",
"boxZoom", "boxZoom",
"doubleClickZoom", "doubleClickZoom",
"touchZoomRotate", "touchZoomRotate"
] ]
readonly location: UIEventSource<{ lon: number; lat: number }> readonly location: UIEventSource<{ lon: number; lat: number }>
private readonly isFlying = new UIEventSource(false)
readonly zoom: UIEventSource<number> readonly zoom: UIEventSource<number>
readonly bounds: UIEventSource<BBox> readonly bounds: UIEventSource<BBox>
readonly rasterLayer: UIEventSource<RasterLayerPolygon | undefined> readonly rasterLayer: UIEventSource<RasterLayerPolygon | undefined>
@ -105,6 +106,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
new RasterLayerHandler(this._maplibreMap, this.rasterLayer) new RasterLayerHandler(this._maplibreMap, this.rasterLayer)
const clickmodes = ["left", "middle", "right"] as const const clickmodes = ["left", "middle", "right"] as const
function handleClick(e: maplibregl.MapMouseEvent, mode?: "left" | "right" | "middle") { function handleClick(e: maplibregl.MapMouseEvent, mode?: "left" | "right" | "middle") {
if (e.originalEvent["consumed"]) { if (e.originalEvent["consumed"]) {
// Workaround, 'ShowPointLayer' sets this flag // Workaround, 'ShowPointLayer' sets this flag
@ -144,7 +146,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.SetRotation(self.rotation.data) self.SetRotation(self.rotation.data)
self.setTerrain(self.useTerrain.data) self.setTerrain(self.useTerrain.data)
this.updateStores(true) this.updateStores(true)
map.on("moveend", () => this.updateStores()) map.on("movestart", () => {
this.isFlying.setData(true)
})
map.on("moveend", () => {
this.isFlying.setData(false)
this.updateStores()
})
map.on("click", (e) => { map.on("click", (e) => {
handleClick(e) handleClick(e)
}) })
@ -228,9 +236,9 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
return { return {
map: mlmap, map: mlmap,
ui: new SvelteUIElement(MaplibreMap, { ui: new SvelteUIElement(MaplibreMap, {
map: mlmap, map: mlmap
}), }),
mapproperties: new MapLibreAdaptor(mlmap), mapproperties: new MapLibreAdaptor(mlmap)
} }
} }
@ -298,7 +306,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
) { ) {
const event = { const event = {
date: new Date(), date: new Date(),
key: key, key: key
} }
for (let i = 0; i < this._onKeyNavigation.length; i++) { for (let i = 0; i < this._onKeyNavigation.length; i++) {
@ -487,7 +495,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
const bounds = map.getBounds() const bounds = map.getBounds()
const bbox = new BBox([ const bbox = new BBox([
[bounds.getEast(), bounds.getNorth()], [bounds.getEast(), bounds.getNorth()],
[bounds.getWest(), bounds.getSouth()], [bounds.getWest(), bounds.getSouth()]
]) ])
if (this.bounds.data === undefined || !isSetup) { if (this.bounds.data === undefined || !isSetup) {
this.bounds.setData(bbox) this.bounds.setData(bbox)
@ -501,6 +509,9 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
if (!map || z === undefined) { if (!map || z === undefined) {
return return
} }
if (this.isFlying.data) {
return
}
if (Math.abs(map.getZoom() - z) > 0.01) { if (Math.abs(map.getZoom() - z) > 0.01) {
map.setZoom(z) map.setZoom(z)
} }
@ -648,9 +659,22 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
if (!hasDiff) { if (!hasDiff) {
return return
} }
this.lockZoom()
map.fitBounds(bounds.toLngLat()) map.fitBounds(bounds.toLngLat())
} }
/**
* Should be called before making an animation.
* First, 'isFlying' is set to true. This will disable the zoom control
* Then, zoom is set to '1', which is very low. This will generally disable all layers, after which this function will return
*
* Then, a zoom/pan/... animation can be made; after which a 'moveEnd'-event will trigger the 'isFlying' to be set to false and the zoom to be set correctly
*/
private lockZoom() {
this.isFlying.setData(true)
this.zoom.setData(1)
}
private async setTerrain(useTerrain: boolean) { private async setTerrain(useTerrain: boolean) {
const map = this._maplibreMap.data const map = this._maplibreMap.data
if (!map) { if (!map) {
@ -665,14 +689,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
type: "raster-dem", type: "raster-dem",
url: url:
"https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=" + "https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=" +
Constants.maptilerApiKey, Constants.maptilerApiKey
}) })
try { try {
while (!map?.isStyleLoaded()) { while (!map?.isStyleLoaded()) {
await Utils.waitFor(250) await Utils.waitFor(250)
} }
map.setTerrain({ map.setTerrain({
source: id, source: id
}) })
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -680,10 +704,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
} }
} }
public flyTo(lon: number, lat: number, zoom: number){ public flyTo(lon: number, lat: number, zoom: number) {
this._maplibreMap.data?.flyTo({ this.lockZoom()
zoom, window.requestAnimationFrame(() => {
center: [lon, lat], this._maplibreMap.data?.flyTo({
zoom,
center: [lon, lat]
})
}) })
} }
} }

View file

@ -1,6 +1,6 @@
import { Store, UIEventSource } from "../Logic/UIEventSource" import { Store, UIEventSource } from "../Logic/UIEventSource"
import BaseUIElement from "./BaseUIElement" import BaseUIElement from "./BaseUIElement"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" import LayoutConfig, { MinimalLayoutInformation } from "../Models/ThemeConfig/LayoutConfig"
import { import {
FeatureSource, FeatureSource,
IndexedFeatureSource, IndexedFeatureSource,
@ -87,6 +87,7 @@ export interface SpecialVisualizationState {
readonly showAllQuestionsAtOnce: UIEventSource<boolean> readonly showAllQuestionsAtOnce: UIEventSource<boolean>
readonly preferencesAsTags: UIEventSource<Record<string, string>> readonly preferencesAsTags: UIEventSource<Record<string, string>>
readonly language: UIEventSource<string> readonly language: UIEventSource<string>
readonly recentlyVisitedThemes: Store<string[]>
} }
readonly availableLayers: Store<RasterLayerPolygon[]> readonly availableLayers: Store<RasterLayerPolygon[]>

View file

@ -62,7 +62,7 @@
return "offline" return "offline"
} }
}), }),
message: osmApi, message: osmApi
}) })
} }
@ -90,7 +90,7 @@
} }
const files: string[] = s["success"]["allFiles"] const files: string[] = s["success"]["allFiles"]
return "Contains " + (files.length ?? "no") + " files" return "Contains " + (files.length ?? "no") + " files"
}), })
}) })
} }
{ {
@ -106,7 +106,7 @@
return "degraded" return "degraded"
} }
}), }),
message: simpleMessage(testDownload(Constants.GeoIpServer + "/ip")), message: simpleMessage(testDownload(Constants.GeoIpServer + "/ip"))
}) })
} }
@ -125,7 +125,7 @@
} }
return "degraded" return "degraded"
}), }),
message: simpleMessage(status), message: simpleMessage(status)
}) })
} }
@ -144,7 +144,7 @@
} }
return "online" return "online"
}), }),
message: simpleMessage(status), message: simpleMessage(status)
}) })
} }
@ -183,7 +183,7 @@
const json = JSON.stringify(s["success"], null, " ") const json = JSON.stringify(s["success"], null, " ")
return "Database is " + Math.floor(timediffDays) + " days out of sync\n\n" + json return "Database is " + Math.floor(timediffDays) + " days out of sync\n\n" + json
}), })
}) })
} }
@ -202,7 +202,45 @@
} }
return "degraded" return "degraded"
}), }),
message: status.map((s) => JSON.stringify(s)), message: status.map((s) => JSON.stringify(s))
})
}
{
const s = Constants.nominatimEndpoint
const status = testDownload(s + "/search.php?q=Brugge")
services.push({
name: s,
message: simpleMessage(status),
status: status.mapD(s => {
if (s["error"]) {
return "offline"
}
const data = s["success"]
if (Array.isArray(data)) {
return "online"
}
return "degraded"
})
})
}
{
const s = Constants.photonEndpoint
const status = testDownload(s + "/api/?q=Brugge")
services.push({
name: s,
status: status.mapD(s => {
if (s["error"]) {
return "offline"
}
const data = s["success"]
if (Array.isArray(data.features) && data.features.length > 0) {
return "online"
}
return "degraded"
}),
message: simpleMessage(status)
}) })
} }
@ -228,7 +266,7 @@
return "online" return "online"
}), }),
message: simpleMessage(status), message: simpleMessage(status)
}) })
} }
} }
@ -241,7 +279,7 @@
return "online" return "online"
} }
return "offline" return "offline"
}), })
}) })
} }

View file

@ -263,14 +263,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
return res return res
} }
public static NoNull<T>(array: T[] | undefined): T[] | undefined public static NoNull<T>(array: ReadonlyArray<T> | undefined): T[] | undefined
public static NoNull<T>(array: undefined): undefined public static NoNull<T>(array: undefined): undefined
public static NoNull<T>(array: T[]): T[] public static NoNull<T>(array: ReadonlyArray<T>): T[]
public static NoNull<T>(array: T[]): NonNullable<T>[] { public static NoNull<T>(array: ReadonlyArray<T>): NonNullable<T>[] {
return <any>array?.filter((o) => o !== undefined && o !== null) return <any>array?.filter((o) => o !== undefined && o !== null)
} }
public static Hist(array: string[]): Map<string, number> { public static Hist(array: ReadonlyArray<string>): Map<string, number> {
const hist = new Map<string, number>() const hist = new Map<string, number>()
for (const s of array) { for (const s of array) {
hist.set(s, 1 + (hist.get(s) ?? 0)) hist.set(s, 1 + (hist.get(s) ?? 0))

View file

@ -12,63 +12,63 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
/* /*
* The main colour scheme of mapcomplete is configured here. * The main colour scheme of mapcomplete is configured here.
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these. * For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/ */
/* No support for dark mode yet, we disable it to prevent some elements to suddenly toggle */ /* No support for dark mode yet, we disable it to prevent some elements to suddenly toggle */
color-scheme: only light; color-scheme: only light;
/* Main color of the application: the background and text colours */ /* Main color of the application: the background and text colours */
--background-color: white; --background-color: white;
/* Main text colour. Also styles some elements, such as the 'close popup'-button or 'back-arrow' (in mobile) */ /* Main text colour. Also styles some elements, such as the 'close popup'-button or 'back-arrow' (in mobile) */
--foreground-color: black; --foreground-color: black;
/* A colour scheme to indicate an error or warning */ /* A colour scheme to indicate an error or warning */
--alert-color: #fee4d1; --alert-color: #fee4d1;
--alert-foreground-color: var(--foreground-color); --alert-foreground-color: var(--foreground-color);
--low-interaction-background: #eeeeee; --low-interaction-background: #eeeeee;
--low-interaction-background-50: #eeeeee90; --low-interaction-background-50: #eeeeee90;
--low-interaction-foreground: black; --low-interaction-foreground: black;
--low-interaction-contrast: #ff00ff; --low-interaction-contrast: #ff00ff;
--interactive-background: #dddddd; --interactive-background: #dddddd;
--interactive-foreground: black; --interactive-foreground: black;
--interactive-contrast: #ff00ff; --interactive-contrast: #ff00ff;
--button-background: #282828; --button-background: #282828;
--button-background-hover: #484848; --button-background-hover: #484848;
--button-primary-background-hover: #353535; --button-primary-background-hover: #353535;
--button-foreground: white; --button-foreground: white;
--button-border-color: #F7F7F7; --button-border-color: #F7F7F7;
--disabled: #B8B8B8; --disabled: #B8B8B8;
--disabled-font: #B8B8B8; --disabled-font: #B8B8B8;
/** /**
* Base colour of interactive elements, mainly the 'subtle button' * Base colour of interactive elements, mainly the 'subtle button'
* @deprecated * @deprecated
*/ */
--subtle-detail-color: #dbeafe; --subtle-detail-color: #dbeafe;
--subtle-detail-color-contrast: black; --subtle-detail-color-contrast: black;
--subtle-detail-color-light-contrast: lightgrey; --subtle-detail-color-light-contrast: lightgrey;
--catch-detail-color: black; /*#3a3aeb;*/ --catch-detail-color: black; /*#3a3aeb;*/
--catch-detail-foregroundcolor: white; --catch-detail-foregroundcolor: white;
--catch-detail-color-contrast: #fb3afb; --catch-detail-color-contrast: #fb3afb;
--image-carousel-height: 350px; --image-carousel-height: 350px;
/** Technical value, used by icon.svelte /** Technical value, used by icon.svelte
*/ */
--svg-color: #000000; --svg-color: #000000;
} }
@font-face{ @font-face {
font-family:"Source Sans Pro"; font-family: "Source Sans Pro";
src:url("/assets/source-sans-pro.regular.ttf") format("woff"); src: url("/assets/source-sans-pro.regular.ttf") format("woff");
} }
/***********************************************************************\ /***********************************************************************\
@ -76,13 +76,13 @@
\***********************************************************************/ \***********************************************************************/
html, html,
body { body {
height: 100%; height: 100%;
min-height: 100vh; min-height: 100vh;
min-height: -webkit-fill-available; min-height: -webkit-fill-available;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: var(--background-color); background-color: var(--background-color);
color: var(--foreground-color); color: var(--foreground-color);
font-family: 'Source Sans Pro'; font-family: 'Source Sans Pro';
font-style: normal; font-style: normal;
@ -93,52 +93,52 @@ body {
svg, svg,
img { img {
box-sizing: content-box; box-sizing: content-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
li { li {
margin-left: 0.5em; margin-left: 0.5em;
padding-left: 0.2em; padding-left: 0.2em;
margin-top: 0.1em; margin-top: 0.1em;
} }
li::marker { li::marker {
content: "•"; content: "•";
} }
h1 { h1 {
font-size: xx-large; font-size: xx-large;
margin-top: 0.6em; margin-top: 0.6em;
margin-bottom: 0.4em; margin-bottom: 0.4em;
font-weight: bold; font-weight: bold;
} }
h2 { h2 {
font-size: x-large; font-size: x-large;
margin-top: 0.5em; margin-top: 0.5em;
margin-bottom: 0; /*Disable margin bottom to play nicely with accordeons from flowbite*/ margin-bottom: 0; /*Disable margin bottom to play nicely with accordeons from flowbite*/
font-weight: bold; font-weight: bold;
} }
h3 { h3 {
font-size: larger; font-size: larger;
margin-top: 0.6em; margin-top: 0.6em;
margin-bottom: 0; margin-bottom: 0;
font-weight: bold; font-weight: bold;
} }
p { p {
padding-top: 0.1em; padding-top: 0.1em;
} }
input { input {
color: var(--foreground-color); color: var(--foreground-color);
} }
input[type="text"] { input[type="text"] {
width: 100%; width: 100%;
} }
/************************* BIG CATEGORIES ********************************/ /************************* BIG CATEGORIES ********************************/
@ -149,33 +149,33 @@ input[type="text"] {
*/ */
.subtle-background { .subtle-background {
background: var(--subtle-detail-color); background: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast); color: var(--subtle-detail-color-contrast);
} }
.normal-background { .normal-background {
background: var(--background-color); background: var(--background-color);
color: var(--foreground-color); color: var(--foreground-color);
} }
.low-interaction { .low-interaction {
background: var(--low-interaction-background); background: var(--low-interaction-background);
color: var(--low-interaction-foreground); color: var(--low-interaction-foreground);
} }
.interactive { .interactive {
background: var(--interactive-background); background: var(--interactive-background);
color: var(--interactive-foreground); color: var(--interactive-foreground);
} }
.border-interactive { .border-interactive {
border: 2px dashed var(--catch-detail-color-contrast); border: 2px dashed var(--catch-detail-color-contrast);
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.border-region { .border-region {
border: 2px dashed var(--interactive-background); border: 2px dashed var(--interactive-background);
border-radius: 0.5rem; border-radius: 0.5rem;
} }
/******************* Styling of input elements **********************/ /******************* Styling of input elements **********************/
@ -244,9 +244,11 @@ button.disabled {
color: var(--disabled-font); color: var(--disabled-font);
cursor: unset; cursor: unset;
} }
button.disabled svg path { button.disabled svg path {
transition: all 200ms; transition: all 200ms;
} }
button.disabled svg path { button.disabled svg path {
fill: var(--disabled-font); fill: var(--disabled-font);
stroke: var(--disabled-font); stroke: var(--disabled-font);
@ -294,49 +296,49 @@ button.unstyled {
} }
.links-w-full a:not(.weblate-link), .links-w-full button.as-link { .links-w-full a:not(.weblate-link), .links-w-full button.as-link {
display: flex; display: flex;
column-gap: 0.25rem; column-gap: 0.25rem;
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
width: 100%; width: 100%;
} }
select { select {
border: 2px solid #00000000; border: 2px solid #00000000;
color: var(--foreground-color) !important; color: var(--foreground-color) !important;
background-color: var(--low-interaction-background) !important; background-color: var(--low-interaction-background) !important;
} }
select:hover { select:hover {
border-color: var(--catch-detail-color-contrast); border-color: var(--catch-detail-color-contrast);
} }
.neutral-label { .neutral-label {
/** This label styles as normal text. It's power comes from the many :not(.neutral-label) entries. /** This label styles as normal text. It's power comes from the many :not(.neutral-label) entries.
* Placed here for autocompletion * Placed here for autocompletion
*/ */
} }
label:not(.neutral-label):not(.button) { label:not(.neutral-label):not(.button) {
/** /**
* Label should _contain_ the input element * Label should _contain_ the input element
*/ */
padding: 0.25rem; padding: 0.25rem;
padding-right: 0.5rem; padding-right: 0.5rem;
padding-left: 0.5rem; padding-left: 0.5rem;
margin:0.25rem; margin: 0.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
transition: all 250ms; transition: all 250ms;
} }
label.button { label.button {
width: 100%; width: 100%;
} }
label:hover:not(.neutral-label) { label:hover:not(.neutral-label) {
background-color: var(--low-interaction-background); background-color: var(--low-interaction-background);
} }
@ -346,7 +348,7 @@ label.checked:not(.neutral-label) {
} }
textarea { textarea {
color: black; color: black;
} }
h2.group { h2.group {
@ -368,97 +370,97 @@ h2.group {
*/ */
.thanks { .thanks {
/* The class to indicate 'operation successful' or 'thank you for contributing' */ /* The class to indicate 'operation successful' or 'thank you for contributing' */
font-weight: bold; font-weight: bold;
border-radius: 1em; border-radius: 1em;
margin: 0.25em; margin: 0.25em;
text-align: center; text-align: center;
padding: 0.25rem; padding: 0.25rem;
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
border: 3px dotted #58cd27; border: 3px dotted #58cd27;
background-color: #58cd2722; background-color: #58cd2722;
} }
.alert { .alert {
/* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */ /* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */
background-color: var(--alert-color); background-color: var(--alert-color);
color: var(--alert-foreground-color); color: var(--alert-foreground-color);
font-weight: bold; font-weight: bold;
border-radius: 1em; border-radius: 1em;
margin: 0.25em; margin: 0.25em;
text-align: center; text-align: center;
padding: 0.15em 0.3em; padding: 0.15em 0.3em;
border: 2px dotted #ff9143; border: 2px dotted #ff9143;
} }
.warning { .warning {
/* The class to convey important information, but not as grave as 'alert' */ /* The class to convey important information, but not as grave as 'alert' */
background-color: var(--low-interaction-background); background-color: var(--low-interaction-background);
color: var(--alert-foreground-color); color: var(--alert-foreground-color);
font-weight: bold; font-weight: bold;
border-radius: 1em; border-radius: 1em;
margin: 0.25em; margin: 0.25em;
text-align: center; text-align: center;
padding: 0.15em 0.3em; padding: 0.15em 0.3em;
border: 3px dotted #ff9143; border: 3px dotted #ff9143;
} }
.low-interaction .warning { .low-interaction .warning {
background-color: var(--interactive-background); background-color: var(--interactive-background);
} }
.information { .information {
/* The class to convey important information which does _not_ denote an error... */ /* The class to convey important information which does _not_ denote an error... */
background-color: var(--low-interaction-background); background-color: var(--low-interaction-background);
color: var(--alert-foreground-color); color: var(--alert-foreground-color);
border-radius: 1em; border-radius: 1em;
margin: 0.25em; margin: 0.25em;
text-align: center; text-align: center;
padding: 0.15em 0.3em; padding: 0.15em 0.3em;
border: 3px dotted var(--catch-detail-color-contrast); border: 3px dotted var(--catch-detail-color-contrast);
} }
.low-interaction .interactive { .low-interaction .interactive {
background-color: var(--interactive-background); background-color: var(--interactive-background);
} }
.subtle { .subtle {
/* For all information that is not important for 99% of the users */ /* For all information that is not important for 99% of the users */
color: #666; color: #666;
font-weight: normal; font-weight: normal;
} }
.low-interaction .subtle { .low-interaction .subtle {
color: #444; color: #444;
} }
.interactive .subtle { .interactive .subtle {
color: #333; color: #333;
} }
.link-underline .subtle a { .link-underline .subtle a {
text-decoration: underline 1px #7193bb88; text-decoration: underline 1px #7193bb88;
-webkit-text-decoration: underline; -webkit-text-decoration: underline;
color: #7193bb; color: #7193bb;
} }
.literal-code, .literal-code,
code { code {
/* A codeblock */ /* A codeblock */
display: inline-block; display: inline-block;
background-color: lightgray; background-color: lightgray;
padding: 0.1rem; padding: 0.1rem;
padding-left: 0.35rem; padding-left: 0.35rem;
padding-right: 0.35rem; padding-right: 0.35rem;
word-break: break-word; word-break: break-word;
color: black; color: black;
box-sizing: border-box; box-sizing: border-box;
font-family: monospace; font-family: monospace;
} }
.interactive .literal-code { .interactive .literal-code {
background-color: #b3b3b3; background-color: #b3b3b3;
} }
/************************** UTILITY ************************/ /************************** UTILITY ************************/
@ -468,98 +470,104 @@ code {
*/ */
.text-white a { .text-white a {
/* Used solely in 'imageAttribution' and in many themes*/ /* Used solely in 'imageAttribution' and in many themes*/
color: var(--background-color); color: var(--background-color);
} }
.bg-black-transparent { .bg-black-transparent {
background-color: #00000088; background-color: #00000088;
} }
.block-ruby { .block-ruby {
display: block ruby; display: block ruby;
} }
.rounded-left-full { .rounded-left-full {
border-bottom-left-radius: 999rem; border-bottom-left-radius: 999rem;
border-top-left-radius: 999rem; border-top-left-radius: 999rem;
} }
.rounded-right-full { .rounded-right-full {
border-bottom-right-radius: 999rem; border-bottom-right-radius: 999rem;
border-top-right-radius: 999rem; border-top-right-radius: 999rem;
} }
.no-images img { .no-images img {
/* Used solely in 'imageAttribution' and in many themes for the label*/ /* Used solely in 'imageAttribution' and in many themes for the label*/
display: none; display: none;
} }
.weblate-link { .weblate-link {
display: inline-block; display: inline-block;
flex-shrink: 0; flex-shrink: 0;
margin: 0; margin: 0;
padding: 0.25rem; padding: 0.25rem;
width: 1.2rem; width: 1.2rem;
height: 1.2rem; height: 1.2rem;
border: unset; border: unset;
border-radius: 5rem; border-radius: 5rem;
backdrop-filter: var(--low-interaction-background); backdrop-filter: var(--low-interaction-background);
} }
.no-weblate .weblate-link { .no-weblate .weblate-link {
display: none !important; display: none !important;
} }
.link-underline a { .link-underline a {
text-decoration: underline 1px var(--foreground-color); text-decoration: underline 1px var(--foreground-color);
-webkit-text-decoration: underline; -webkit-text-decoration: underline;
} }
a.link-underline { a.link-underline {
text-decoration: underline 1px var(--foreground-color); text-decoration: underline 1px var(--foreground-color);
-webkit-text-decoration: underline; -webkit-text-decoration: underline;
} }
.link-no-underline a, a.link-no-underline { .link-no-underline a, a.link-no-underline {
text-decoration: none; text-decoration: none;
} }
.disable-links a { .disable-links a {
pointer-events: none; pointer-events: none;
text-decoration: none !important; text-decoration: none !important;
color: var(--subtle-detail-color-contrast) !important; color: var(--subtle-detail-color-contrast) !important;
} }
.enable-links a { .enable-links a {
pointer-events: unset; pointer-events: unset;
text-decoration: underline !important; text-decoration: underline !important;
-webkit-text-decoration: underline !important; -webkit-text-decoration: underline !important;
color: unset !important; color: unset !important;
} }
a:hover {
background-color: var(--low-interaction-background);
}
.disable-links a.must-link, .disable-links a.must-link,
.disable-links .must-link a { .disable-links .must-link a {
/* Hide links if they are disabled */ /* Hide links if they are disabled */
display: none; display: none;
} }
.zebra-table tr:nth-child(even) { .zebra-table tr:nth-child(even) {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
/************************* MISC ELEMENTS *************************/ /************************* MISC ELEMENTS *************************/
.selected svg:not(.noselect *) path.selectable { .selected svg:not(.noselect *) path.selectable {
/* A marker on the map gets the 'selected' class when it's properties are displayed /* A marker on the map gets the 'selected' class when it's properties are displayed
*/ */
stroke: white !important; stroke: white !important;
stroke-width: 20px !important; stroke-width: 20px !important;
overflow: visible !important; overflow: visible !important;
-webkit-animation: glowing-drop-shadow 1s ease-in-out infinite alternate; -webkit-animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
-moz-animation: glowing-drop-shadow 1s ease-in-out infinite alternate; -moz-animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
animation: glowing-drop-shadow 1s ease-in-out infinite alternate; animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
} }
.selected .light-icon svg:not(.noselect *) path.selectable { .selected .light-icon svg:not(.noselect *) path.selectable {
@ -568,76 +576,75 @@ a.link-underline {
} }
.selected svg { .selected svg {
/* A marker on the map gets the 'selected' class when it's properties are displayed /* A marker on the map gets the 'selected' class when it's properties are displayed
*/ */
overflow: visible !important; overflow: visible !important;
} }
svg.apply-fill path { svg.apply-fill path {
fill: var(--svg-color); fill: var(--svg-color);
} }
.compass_arrow { .compass_arrow {
width: calc(2.5rem - 1px); width: calc(2.5rem - 1px);
height: calc(2.5rem - 1px); height: calc(2.5rem - 1px);
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.compass_arrow { .compass_arrow {
width: calc(2.75rem - 1px); width: calc(2.75rem - 1px);
height: calc(2.75rem - 1px); height: calc(2.75rem - 1px);
} }
} }
@-webkit-keyframes glowing-drop-shadow { @-webkit-keyframes glowing-drop-shadow {
from { from {
filter: drop-shadow(5px 5px 60px rgb(128 128 128 / 0.6)); filter: drop-shadow(5px 5px 60px rgb(128 128 128 / 0.6));
} }
to { to {
filter: drop-shadow(5px 5px 80px rgb(0.5 0.5 0.5 / 0.8)); filter: drop-shadow(5px 5px 80px rgb(0.5 0.5 0.5 / 0.8));
} }
} }
@keyframes slide { @keyframes slide {
/* This is the animation on the marker to add a new point - it slides through all the possible presets */ /* This is the animation on the marker to add a new point - it slides through all the possible presets */
from { from {
transform: translateX(0%); transform: translateX(0%);
} }
to { to {
transform: translateX(calc(-100% + 42px)); transform: translateX(calc(-100% + 42px));
} }
} }
/************************* LEGACY MARKER - CLEANUP BELOW ********************************/ /************************* LEGACY MARKER - CLEANUP BELOW ********************************/
.slideshow-item img { .slideshow-item img {
/* Legacy: should be replace when the image element is ported to Svelte*/ /* Legacy: should be replace when the image element is ported to Svelte*/
height: var(--image-carousel-height); height: var(--image-carousel-height);
width: unset; 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;
overflow-y: hidden; overflow-y: hidden;
} }
.min-h-32 { .min-h-32 {
min-height: 8rem; min-height: 8rem;
} }
.max-w-full { .max-w-full {
max-width: 100%; max-width: 100%;
} }
/************************* Experimental support for foldable devices ********************************/ /************************* Experimental support for foldable devices ********************************/
@media (horizontal-viewport-segments: 2) { @media (horizontal-viewport-segments: 2) {
.theme-list { .theme-list {
display: grid; display: grid;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }