diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 988ce5f6b..36ae8e820 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -54,7 +54,6 @@ jobs: docker compose run --rm playwright npx playwright install --with-deps docker compose run --rm playwright npx playwright test --retries 3 - - uses: actions/upload-artifact@v4 if: always() with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e50945fa..b61478b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#271](https://github.com/os2display/display-admin-client/pull/271) + - Added new feed source: Eventdatabasen v2. + ## [2.2.0] - 2025-03-17 - [#273](https://github.com/os2display/display-admin-client/pull/273) diff --git a/src/components/feed-sources/feed-source-form.jsx b/src/components/feed-sources/feed-source-form.jsx index 2cce65aed..1637f48c9 100644 --- a/src/components/feed-sources/feed-source-form.jsx +++ b/src/components/feed-sources/feed-source-form.jsx @@ -13,6 +13,7 @@ import FormInput from "../util/forms/form-input"; import CalendarApiFeedType from "./templates/calendar-api-feed-type"; import NotifiedFeedType from "./templates/notified-feed-type"; import EventDatabaseApiFeedType from "./templates/event-database-feed-type"; +import EventDatabaseApiV2FeedType from "./templates/event-database-v2-feed-type"; /** * The feed-source form component. @@ -97,6 +98,13 @@ function FeedSourceForm({ mode={mode} /> )} + {feedSource?.feedType === "App\\Feed\\EventDatabaseApiV2FeedType" && ( + + )} {feedSource?.feedType === "App\\Feed\\NotifiedFeedType" && ( { + const { t } = useTranslation("common", { + keyPrefix: "event-database-api-v2-feed-type", + }); + return ( + <> + + + + ); +}; + +EventDatabaseApiV2FeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + host: PropTypes.string.isRequired, + apikey: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default EventDatabaseApiV2FeedType; diff --git a/src/components/slide/content/feed-selector.jsx b/src/components/slide/content/feed-selector.jsx index 897fbddf9..f008b6d0a 100644 --- a/src/components/slide/content/feed-selector.jsx +++ b/src/components/slide/content/feed-selector.jsx @@ -12,7 +12,8 @@ import MultiSelectComponent from "../../util/forms/multiselect-dropdown/multi-dr import idFromUrl from "../../util/helpers/id-from-url"; import ContentForm from "./content-form"; import MultiselectFromEndpoint from "./multiselect-from-endpoint"; -import PosterSelector from "./poster-selector"; +import PosterSelectorV1 from "./poster/poster-selector-v1"; +import PosterSelectorV2 from "./poster/poster-selector-v2"; /** * Feed selector. @@ -96,9 +97,19 @@ function FeedSelector({ onChange(newValue); }; - const configurationChange = ({ target }) => { + const configurationChange = ({ target = null, targets = null }) => { const configuration = { ...value.configuration }; - set(configuration, target.id, target.value); + + if (target !== null) { + set(configuration, target.id, target.value); + } + + if (targets !== null) { + targets.forEach(({ id, value: targetValue }) => { + set(configuration, id, targetValue); + }); + } + const newValue = { ...value, configuration }; onChange(newValue); }; @@ -129,10 +140,21 @@ function FeedSelector({ } if (element?.input === "poster-selector") { return ( - + ); + } + if (element?.input === "poster-selector-v2") { + return ( + ); diff --git a/src/components/slide/content/poster/poster-helper.js b/src/components/slide/content/poster/poster-helper.js new file mode 100644 index 000000000..aab9a2d53 --- /dev/null +++ b/src/components/slide/content/poster/poster-helper.js @@ -0,0 +1,89 @@ +import dayjs from "dayjs"; +import localeDa from "dayjs/locale/da"; +import localStorageKeys from "../../../util/local-storage-keys"; + +const capitalize = (s) => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +const formatDate = (date, format) => { + if (!date) return ""; + return capitalize( + dayjs(date) + .locale(localeDa) + .format(format ?? "LLLL") + ); +}; + +const loadDropdownOptions = (url, headers, inputValue, callback, type) => { + const params = { + type, + display: "options", + }; + + if (inputValue) { + params.name = inputValue; + } + + const query = new URLSearchParams(params); + + fetch(`${url}?${query}`, { + headers, + }) + .then((response) => response.json()) + .then((data) => { + callback(data); + }) + .catch(() => { + callback([]); + }); +}; + +const loadDropdownOptionsPromise = (url, headers, inputValue, type) => { + return new Promise((resolve, reject) => { + const params = { + entityType: type, + }; + + if (inputValue) { + params.search = inputValue; + } + + const query = new URLSearchParams(params); + fetch(`${url}?${query}`, { + headers, + }) + .then((response) => response.json()) + .then((data) => { + resolve(data); + }) + .catch((reason) => { + reject(reason); + }); + }); +}; + +const getHeaders = () => { + const apiToken = localStorage.getItem(localStorageKeys.API_TOKEN); + const tenantKey = JSON.parse( + localStorage.getItem(localStorageKeys.SELECTED_TENANT) + ); + + const headers = { + authorization: `Bearer ${apiToken ?? ""}`, + }; + + if (tenantKey) { + headers["Authorization-Tenant-Key"] = tenantKey.tenantKey; + } + + return headers; +}; + +export { + formatDate, + capitalize, + loadDropdownOptions, + getHeaders, + loadDropdownOptionsPromise, +}; diff --git a/src/components/slide/content/poster-selector.jsx b/src/components/slide/content/poster/poster-selector-v1.jsx similarity index 98% rename from src/components/slide/content/poster-selector.jsx rename to src/components/slide/content/poster/poster-selector-v1.jsx index 73875bc77..39db9e629 100644 --- a/src/components/slide/content/poster-selector.jsx +++ b/src/components/slide/content/poster/poster-selector-v1.jsx @@ -6,10 +6,10 @@ import AsyncSelect from "react-select/async"; import Col from "react-bootstrap/Col"; import dayjs from "dayjs"; import localeDa from "dayjs/locale/da"; -import Select from "../../util/forms/select"; -import FormInput from "../../util/forms/form-input"; -import FormCheckbox from "../../util/forms/form-checkbox"; -import localStorageKeys from "../../util/local-storage-keys"; +import Select from "../../../util/forms/select"; +import FormInput from "../../../util/forms/form-input"; +import FormCheckbox from "../../../util/forms/form-checkbox"; +import localStorageKeys from "../../../util/local-storage-keys"; /** * @param {object} props Props. @@ -19,7 +19,7 @@ import localStorageKeys from "../../util/local-storage-keys"; * @param {Function} props.configurationChange Configuration onChange. * @returns {object} PosterSelector component. */ -function PosterSelector({ +function PosterSelectorV1({ feedSource, getValueFromConfiguration, configurationChange, @@ -650,7 +650,7 @@ function PosterSelector({ {t("poster-selector.table-price")} - + @@ -890,7 +890,7 @@ function PosterSelector({ /* eslint-enable jsx-a11y/control-has-associated-label */ } -PosterSelector.propTypes = { +PosterSelectorV1.propTypes = { getValueFromConfiguration: PropTypes.func.isRequired, configurationChange: PropTypes.func.isRequired, feedSource: PropTypes.shape({ @@ -903,4 +903,4 @@ PosterSelector.propTypes = { }).isRequired, }; -export default PosterSelector; +export default PosterSelectorV1; diff --git a/src/components/slide/content/poster/poster-selector-v2.jsx b/src/components/slide/content/poster/poster-selector-v2.jsx new file mode 100644 index 000000000..6f1397572 --- /dev/null +++ b/src/components/slide/content/poster/poster-selector-v2.jsx @@ -0,0 +1,104 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Button, Card, Row } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import PosterSingle from "./poster-single"; +import PosterSubscription from "./poster-subscription"; + +/** + * @param {object} props Props. + * @param {object} props.feedSource Feed source. + * @param {Function} props.getValueFromConfiguration Gets a value from the feed + * configuration. + * @param {Function} props.configurationChange Configuration onChange. + * @param {object} props.configuration Configuration. + * @returns {object} PosterSelector component. + */ +function PosterSelectorV2({ + feedSource, + getValueFromConfiguration, + configuration, + configurationChange, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + const posterType = getValueFromConfiguration("posterType"); + + return ( + + + {!posterType && ( + + +
{t("select-mode")}
+ + + + + + + +
+ )} + {posterType && ( + <> + {posterType === "single" && ( + + )} + {posterType === "subscription" && ( + + )} + + )} +
+
+ ); +} + +PosterSelectorV2.propTypes = { + getValueFromConfiguration: PropTypes.func.isRequired, + configurationChange: PropTypes.func.isRequired, + configuration: PropTypes.shape({}), + feedSource: PropTypes.shape({ + admin: PropTypes.arrayOf( + PropTypes.shape({ + endpointEntity: PropTypes.string, + endpointSearch: PropTypes.string, + }) + ), + }).isRequired, +}; + +export default PosterSelectorV2; diff --git a/src/components/slide/content/poster/poster-single-events.jsx b/src/components/slide/content/poster/poster-single-events.jsx new file mode 100644 index 000000000..c62685f88 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-events.jsx @@ -0,0 +1,80 @@ +import { Button } from "react-bootstrap"; +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { formatDate } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {Array} props.events The events to present. + * @param {Function} props.handleSelectEvent Handle select event. + * @returns {React.JSX.Element} The events list component. + */ +function PosterSingleEvents({ events, handleSelectEvent }) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + return ( + + + + + + + + + + {events?.map( + ({ entityId, title, imageUrls, organizer, occurrences }) => ( + + + + + + + ) + )} + {events?.length === 0 && ( + + + + )} + +
{t("table-image")}{t("table-event")}{t("table-date")} +
+ {imageUrls?.small && ( + {t("search-result-image")} + )} + + {title} +
+ {organizer?.name} +
+ {occurrences?.length > 0 && formatDate(occurrences[0]?.start)} + {occurrences?.length > 1 && , ...} + + +
{t("no-results")}
+ ); +} + +PosterSingleEvents.propTypes = { + events: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + handleSelectEvent: PropTypes.func.isRequired, +}; + +export default PosterSingleEvents; diff --git a/src/components/slide/content/poster/poster-single-occurences.jsx b/src/components/slide/content/poster/poster-single-occurences.jsx new file mode 100644 index 000000000..d3b498913 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-occurences.jsx @@ -0,0 +1,56 @@ +import { Button } from "react-bootstrap"; +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { formatDate } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {Array} props.occurrences The occurrences. + * @param {Function} props.handleSelectOccurrence The select callback. + * @returns {React.JSX.Element} The occurrences list component. + */ +function PosterSingleOccurrences({ occurrences, handleSelectOccurrence }) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + return ( + <> +
{t("choose-an-occurrence")}
+ + + + + + + + + {occurrences.map(({ entityId, start, ticketPriceRange }) => ( + + + + + + ))} + +
{t("table-date")}{t("table-price")} +
{formatDate(start)}{ticketPriceRange} + +
+ + ); +} + +PosterSingleOccurrences.propTypes = { + occurrences: PropTypes.arrayOf( + PropTypes.shape({ + entityId: PropTypes.number.isRequired, + start: PropTypes.string.isRequired, + ticketPriceRange: PropTypes.string.isRequired, + }) + ).isRequired, + handleSelectOccurrence: PropTypes.func.isRequired, +}; + +export default PosterSingleOccurrences; diff --git a/src/components/slide/content/poster/poster-single-override.jsx b/src/components/slide/content/poster/poster-single-override.jsx new file mode 100644 index 000000000..078f27173 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-override.jsx @@ -0,0 +1,92 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../../util/forms/form-input"; +import FormCheckbox from "../../../util/forms/form-checkbox"; + +/** + * @param {object} props The props. + * @param {object} props.configuration The configuration. + * @param {string} props.configuration.overrideTitle Override title text. + * @param {string} props.configuration.overrideSubTitle Override subtitle text. + * @param {string} props.configuration.overrideTicketPrice Override ticket price text. + * @param {string} props.configuration.readMoreText Override read more text. + * @param {string} props.configuration.overrideReadMoreUrl Override read more url. + * @param {boolean} props.configuration.hideTime Hide event time. + * @param {Function} props.onChange On change callback. + * @returns {React.JSX.Element} The override component. + */ +function PosterSingleOverride({ + configuration: { + overrideTitle, + overrideSubTitle, + overrideTicketPrice, + readMoreText, + overrideReadMoreUrl, + hideTime, + }, + onChange, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + return ( + <> + + + + + + + + ); +} + +PosterSingleOverride.propTypes = { + configuration: PropTypes.shape({ + overrideTitle: PropTypes.string, + overrideSubTitle: PropTypes.string, + overrideTicketPrice: PropTypes.string, + readMoreText: PropTypes.string, + overrideReadMoreUrl: PropTypes.string, + hideTime: PropTypes.bool, + }).isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default PosterSingleOverride; diff --git a/src/components/slide/content/poster/poster-single-search.jsx b/src/components/slide/content/poster/poster-single-search.jsx new file mode 100644 index 000000000..f84b900de --- /dev/null +++ b/src/components/slide/content/poster/poster-single-search.jsx @@ -0,0 +1,220 @@ +import { React, useEffect, useRef, useState } from "react"; +import { Button, Row } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import AsyncSelect from "react-select/async"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import FormInput from "../../../util/forms/form-input"; +import Select from "../../../util/forms/select"; +import { getHeaders, loadDropdownOptionsPromise } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {string} props.searchEndpoint The search endpoint. + * @param {string} props.optionsEndpoint The options endpoint + * @param {Function} props.setLoading Set loading status. + * @param {Function} props.setResult Set results of search. + * @returns {React.JSX.Element} The search component. + */ +function PosterSingleSearch({ + searchEndpoint, + optionsEndpoint, + setLoading, + setResult, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + const [singleSearch, setSingleSearch] = useState(""); + const [singleSearchType, setSingleSearchType] = useState("title"); + const [singleSearchTypeValue, setSingleSearchTypeValue] = useState(""); + + const timeoutRef = useRef(null); + + const debounceOptions = (inputValue) => { + // Debounce result to avoid searching while typing. + return new Promise((resolve, reject) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + loadDropdownOptionsPromise( + optionsEndpoint, + getHeaders(), + inputValue, + singleSearchType + ) + .then((data) => resolve(data)) + .catch((reason) => reject(reason)); + }, 500); + }); + }; + + const singleSearchFetch = () => { + const params = { + type: "events", + }; + + const singleSearchTypeValueId = singleSearchTypeValue?.value; + + switch (singleSearchType) { + case "title": + params.title = singleSearch; + break; + case "tags": + params.tag = singleSearchTypeValueId; + break; + case "organizations": + params.organization = singleSearchTypeValueId; + break; + case "locations": + params.location = singleSearchTypeValueId; + break; + default: + } + + setLoading(true); + + const query = new URLSearchParams(params); + + fetch(`${searchEndpoint}?${query}`, { + headers: getHeaders(), + }) + .then((response) => response.json()) + .then((data) => { + setResult(data); + }) + .finally(() => { + setLoading(false); + }); + }; + + const singleSearchTypeOptions = [ + { + key: "singleSearchTypeOptions1", + value: "title", + title: t("single-search-type-title"), + }, + { + key: "singleSearchTypeOptions2", + value: "organizations", + title: t("single-search-type-organization"), + }, + { + key: "singleSearchTypeOptions3", + value: "locations", + title: t("single-search-type-location"), + }, + { + key: "singleSearchTypeOptions4", + value: "tags", + title: t("single-search-type-tag"), + }, + ]; + + useEffect(() => { + setSingleSearchTypeValue(""); + }, [singleSearchType]); + + return ( + + + + handleSelect("subscriptionNumberValue", target.value) + } + > + {numberOptions?.map((i) => ( + + ))} + + + + + + + ); +} + +PosterSubscriptionCriteria.propTypes = { + optionsEndpoint: PropTypes.string.isRequired, + configuration: PropTypes.shape({ + subscriptionPlaceValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionOrganizerValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionTagValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionNumberValue: PropTypes.number, + }).isRequired, + handleSelect: PropTypes.func.isRequired, +}; + +export default PosterSubscriptionCriteria; diff --git a/src/components/slide/content/poster/poster-subscription.jsx b/src/components/slide/content/poster/poster-subscription.jsx new file mode 100644 index 000000000..9f7161bc0 --- /dev/null +++ b/src/components/slide/content/poster/poster-subscription.jsx @@ -0,0 +1,202 @@ +import { React, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Alert, Row, Spinner } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import { formatDate, getHeaders } from "./poster-helper"; +import PosterSubscriptionCriteria from "./poster-subscription-criteria"; + +/** + * @param {object} props Props. + * @param {object} props.feedSource Feed source. + * @param {Function} props.configurationChange Configuration onChange. + * @param {object} props.configuration The configuration object. + * @param {object} props.feedSource.admin The admin configuration. + * @returns {React.JSX.Element} The poster subscription component. + */ +function PosterSubscription({ + configurationChange, + configuration, + feedSource: { admin }, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + const [subscriptionEvents, setSubscriptionEvents] = useState(null); + const [loadingResults, setLoadingResults] = useState(false); + + const [firstAdmin] = admin; + const optionsEndpoint = firstAdmin.endpointOption ?? null; + const searchEndpoint = firstAdmin.endpointSearch ?? null; + + const { + subscriptionNumberValue = [], + subscriptionPlaceValue = [], + subscriptionOrganizerValue = [], + subscriptionTagValue = [], + } = configuration; + + const handleSelect = (id, value = []) => { + if (!id) { + return; + } + + configurationChange({ target: { id, value } }); + }; + + const subscriptionFetch = () => { + const query = new URLSearchParams({ + type: "events", + itemsPerPage: subscriptionNumberValue, + }); + + const places = subscriptionPlaceValue.map(({ value }) => value); + + places.forEach((place) => { + query.append("location", place); + }); + + const organizers = subscriptionOrganizerValue.map(({ value }) => value); + + organizers.forEach((organizer) => { + query.append("organization", organizer); + }); + + const tags = subscriptionTagValue.map(({ value }) => value); + + tags.forEach((tag) => { + query.append("tag", tag); + }); + + setLoadingResults(true); + + fetch(`${searchEndpoint}?${query}`, { + headers: getHeaders(), + }) + .then((response) => response.json()) + .then((data) => { + setSubscriptionEvents(data); + }) + .finally(() => { + setLoadingResults(false); + }); + }; + + useEffect(() => { + if (configuration) { + subscriptionFetch(); + } + }, [configuration]); + + return ( + <> + + +
{t("selected-type-subscription")}
+ {t("subscription-helptext")} + + + + {t("preview-updates-after-save")} + + + + + + + +
+
{t("preview-of-events")}
+ {loadingResults && } + + + + + + + + + + + {subscriptionEvents?.length > 0 && + subscriptionEvents?.map( + ({ + entityId, + occurrences, + imageUrls, + title, + organizer, + }) => { + const firstOccurrence = + occurrences.length > 0 ? occurrences[0] : null; + + return ( + + + + + + + ); + } + )} + +
{t("table-image")}{t("table-event")}{t("table-place")}{t("table-date")}
+ {title} + + {title} +
+ {organizer?.name} +
+ {firstOccurrence && firstOccurrence.place?.name} + + {firstOccurrence && ( + <> + {`${formatDate( + firstOccurrence.start, + "L" + )} - ${formatDate(firstOccurrence.end, "L")}`} + + )} +
+
+
+ +
+ + ); +} + +PosterSubscription.propTypes = { + configuration: PropTypes.shape({ + subscriptionPlaceValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionOrganizerValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionTagValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionNumberValue: PropTypes.number, + }).isRequired, + configurationChange: PropTypes.func.isRequired, + feedSource: PropTypes.shape({ + admin: PropTypes.arrayOf( + PropTypes.shape({ + endpointEntity: PropTypes.string, + endpointOption: PropTypes.string, + endpointSearch: PropTypes.string, + }) + ), + }).isRequired, +}; + +export default PosterSubscription; diff --git a/src/translations/da/common.json b/src/translations/da/common.json index 4bc6340d5..61b2a9374 100644 --- a/src/translations/da/common.json +++ b/src/translations/da/common.json @@ -281,6 +281,9 @@ "rss-feed-type": { "title": "RSS feed" } + }, + "event-database-api-v2-feed-type": { + "title": "Event databasen v.2" } }, "feed-source-form": { @@ -1075,5 +1078,64 @@ "active": "Aktiv", "future": "Fremtidig", "expired": "Udløbet" + }, + "event-database-api-v2-feed-type": { + "title": "Event databasen v.2", + "host": "Host", + "apikey": "Api Key", + "redacted-value-input-placeholder": "Skjult værdi" + }, + "poster-selector-v2": { + "preview-of-events": "Begivenheder der vil vises nu:", + "table-image": "Billede", + "table-event": "Begivenhed", + "table-date": "Dato", + "table-price": "Pris", + "table-place": "Sted", + "table-actions": "Handlinger", + "number-of-slides": "Antal slides", + "number-of-slides-helptext": "Vælg op til 10 begivenheder som vil blive vist på hvert sit slide", + "choose-an-occurrence": "Vælg en forekomst", + "choose-occurrence": "Vælg", + "choose-event": "Vælg", + "chosen-event": "Valgt begivenhed", + "chosen-occurrence": "Valgt forekomst", + "search-for-event": "Søg efter arrangement", + "search-result-image": "Billede på begivenheden", + "single-search-type": "Søgekriterium", + "single-search-title": "Søgetekst", + "single-search-select": "Søgetekst", + "single-search-button": "Søg", + "single-search-type-title": "Titel", + "single-search-type-url": "Url", + "single-search-type-location": "Sted", + "single-search-type-organization": "Arrangør", + "single-search-type-tag": "Tag", + "single-search-placeholder": "Vælg...", + "single-search-loading": "Søger...", + "preview-updates-after-save": "Bemærk! Forhåndsvisningen opdaterer først efter slide bliver gemt.", + "remove": "Afvælg", + "no-results": "0 begivenheder fundet", + "single-override-title": "Overskriv overskrift", + "single-override-subtitle": "Overskriv underoverskrift", + "single-override-ticket-price": "Overskriv pristekst", + "single-read-more-text": "\"Læs mere\" tekst", + "single-read-more-url": "Overskriv \"læs mere\"-url", + "single-hide-time": "Skjul tidspunkt", + "hide-overrides": "Skjul overskrivningsmuligheder", + "display-overrides": "Vis overskrivningsmuligheder", + "selected-type-single": "Valgt type: Enkelt begivenhed", + "selected-type-subscription": "Valgt type: Abonnement", + "poster-feed-type-single": "Enkelt", + "poster-feed-type-subscription": "Abonnement", + "select-mode": "Vælg visningstype", + "subscription-helptext": "Kombiner \"sted\", \"arrangør\", \"tags\" og \"antal slides\" for at opsætte abonnementet på begivenheder.", + "filters": "Vælg filtre", + "filters-place": "Sted", + "filter-place-helptext": "Begynd at skrive navnet på det sted du ønsker begivenheder fra, og vælg det på listen som kommer frem.", + "filters-organizer": "Arrangør", + "filter-organizer-helptext": "Begynd at skrive arrangøren du ønsker begivenheder fra, og vælg det på listen som kommer frem.", + "filters-tag": "Tags", + "filter-tag-helptext": "Begynd at skrive det tag du ønsker begivenheder fra, og vælg det på listen som kommer frem." } }