diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index 02550e4e6..5b78012d6 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -22,6 +22,10 @@ jobs: run: | docker compose run --rm phpfpm composer install + - name: Copy fixture assets to public/fixtures + run: | + docker compose run --rm phpfpm cp -r fixtures/public/fixtures public/fixtures + - name: Build assets run: | docker compose run --rm node npm install diff --git a/.gitignore b/.gitignore index 21674ccc6..5092954fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Ignore custom templates folder. /assets/shared/custom-templates/* +# Ignore the public/fixtures folder. +/public/fixtures + # Ignore release json file. /public/release.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5ebe481..60bd8aba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file. * Added update command. * Added (Client) online-check to public. * Updated developer documentation. +* Aligned with v. 2.5.2. +* Removed themes. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/README.md b/README.md index 14719d356..b7d9fb752 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,13 @@ For example: booking system you can implement a "FeedSource" that fetches booking data from your source and normalizes it to match the calendar output model. +## Themes + +It is possible to create themes that can apply to select templates. See `/admin/themes` in the Admin. + +The theme css has to follow som rules. See [docs/themes/themes.md](docs/themes/themes.md) for instructions on writing +custom themes. + ## Custom Templates It is possible to include your own templates in your installation. diff --git a/Taskfile.yml b/Taskfile.yml index 1289339aa..011281bc9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -30,6 +30,7 @@ tasks: - task compose-up - task composer-install - task db:migrate --yes + - task fixtures:copy-assets - task site-open silent: true @@ -44,6 +45,7 @@ tasks: - task composer-install - task db:migrate --yes - task fixtures:load --yes + - task fixtures:copy-assets - task site-open silent: true @@ -221,3 +223,8 @@ tasks: desc: "Migrate to latest database schema and update installed templates" cmds: - task compose -- exec phpfpm bin/console app:update --no-interaction + + fixtures:copy-assets: + desc: "Copy the folder from fixtures/public/fixtures to public/fixtures. Rerun if fixtures are changed." + cmds: + - task compose -- exec phpfpm cp -r fixtures/public/fixtures public/fixtures diff --git a/assets/shared/slide-utils/global-styles.css b/assets/shared/slide-utils/global-styles.css index caf55753f..94f2587d3 100644 --- a/assets/shared/slide-utils/global-styles.css +++ b/assets/shared/slide-utils/global-styles.css @@ -47,63 +47,39 @@ --text-light: var(--color-light); --text-dark: var(--color-dark); - --color-red-oklch-ch: 0.25 29; - --color-red-oklch-l: 50%; - --color-red-oklch-c: 0.25; - --color-red-oklch-h: 29; - --color-red-50: oklch( - 95% calc(var(--color-red-oklch-c) - 0.2) var(--color-red-oklch-h) + --color-red-hsl-h: 0deg; + --color-red-hsl-s: 84%; + --color-red-hsl-l: 50%; + --color-red-50: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 95%); + --color-red-100: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 90%); + --color-red-200: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 85%); + --color-red-300: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 80%); + --color-red-400: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 70%); + --color-red-500: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 60%); + --color-red-600: hsl( + var(--color-red-hsl-h) var(--color-red-hsl-s) var(--color-red-hsl-l) ); - --color-red-100: oklch(90% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-200: oklch(85% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-300: oklch(80% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-400: oklch(70% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-500: oklch(60% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-600: oklch( - var(--color-red-oklch-l) var(--color-red-oklch-c) var(--color-red-oklch-h) - ); - --color-red-700: oklch(40% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-800: oklch(30% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-900: oklch(20% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-950: oklch(15% var(--color-red-oklch-c) var(--color-red-oklch-h)); + --color-red-700: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 40%); + --color-red-800: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 30%); + --color-red-900: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 20%); + --color-red-950: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 15%); - --color-green-oklch-l: 50%; - --color-green-oklch-c: 0.17; - --color-green-oklch-h: 142; - --color-green-50: oklch( - 95% calc(var(--color-green-oklch-c) - 0.15) var(--color-green-oklch-h) - ); - --color-green-100: oklch( - 90% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-200: oklch( - 85% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-300: oklch( - 80% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-400: oklch( - 70% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-500: oklch( - 60% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-600: oklch( - var(--color-green-oklch-l) var(--color-green-oklch-c) - var(--color-green-oklch-h) - ); - --color-green-700: oklch( - 40% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-800: oklch( - 30% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-900: oklch( - 20% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-950: oklch( - 15% var(--color-green-oklch-c) var(--color-green-oklch-h) + --color-green-hsl-h: 142deg; + --color-green-hsl-s: 76%; + --color-green-hsl-l: 50%; + --color-green-50: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 95%); + --color-green-100: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 90%); + --color-green-200: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 85%); + --color-green-300: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 80%); + --color-green-400: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 70%); + --color-green-500: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 60%); + --color-green-600: hsl( + var(--color-green-hsl-h) var(--color-green-hsl-s) var(--color-green-hsl-l) ); + --color-green-700: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 40%); + --color-green-800: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 30%); + --color-green-900: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 20%); + --color-green-950: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 15%); /* * Fonts diff --git a/assets/shared/slide-utils/slide-util.jsx b/assets/shared/slide-utils/slide-util.jsx index 3c61e5d6f..db445e847 100644 --- a/assets/shared/slide-utils/slide-util.jsx +++ b/assets/shared/slide-utils/slide-util.jsx @@ -63,6 +63,7 @@ function ThemeStyles({ id, css = null }) { const slideCss = css.replaceAll("#SLIDE_ID", `#${id}`); const ThemeComponent = createGlobalStyle`${slideCss}`; + return ; } diff --git a/assets/shared/templates/calendar.json b/assets/shared/templates/calendar.json index 4e3878f9e..ebee04c1d 100644 --- a/assets/shared/templates/calendar.json +++ b/assets/shared/templates/calendar.json @@ -132,7 +132,7 @@ "name": "resourceAvailableText", "type": "text", "label": "Tekst når resursen er ledig", - "helpText": "Her kan du skrive tekst, som vises når resursen er ledig.", + "helpText": "Her kan du skrive tekst, som vises når resursen er ledig. Dette gælder kun for \"Enkelt lokale\" layoutet", "formGroupClasses": "col-md-6" }, { @@ -163,7 +163,7 @@ { "key": "calendar-form-has-date-and-time", "input": "checkbox", - "label": "Vis dato og tidspunkt", + "label": "Vis dato og tidspunkt. Gælder kun for \"Flere resurser\" layoutet.", "name": "hasDateAndTime", "formGroupClasses": "col-md-6 mb-3" }, @@ -214,6 +214,14 @@ "name": "headerOrder", "formGroupClasses": "col-md-6 mb-3", "helpText": "Dette er kun relevant hvis \"Flere resurser\" er valgt under \"layout\". Standard er \"Hvornår, hvad, hvor.\"" + }, + { + "key": "calendar-form-enable-instant-booking", + "input": "checkbox", + "label": "Aktivér straksbooking", + "helpText": "Aktivér mulighed for straksbooking. Dette kræver at resursen er opsat og godkendt af systemadministrator.", + "name": "instantBookingEnabled", + "formGroupClasses": "mb-3" } ] } diff --git a/assets/shared/templates/calendar/calendar-single-booking-helper.jsx b/assets/shared/templates/calendar/calendar-single-booking-helper.jsx index a1e4acb98..eade82949 100644 --- a/assets/shared/templates/calendar/calendar-single-booking-helper.jsx +++ b/assets/shared/templates/calendar/calendar-single-booking-helper.jsx @@ -24,6 +24,9 @@ const Wrapper = styled.div` const Header = styled.div` /* Header styling */ display: flex; + @media (max-width: 800px) { + flex-wrap: wrap; + } `; const RoomInfo = styled.div` @@ -31,6 +34,10 @@ const RoomInfo = styled.div` padding: calc(var(--padding-size-base) * 2); flex-grow: 2; color: var(--text-light); + @media (max-width: 800px) { + padding-top: var(--padding-size-base); + padding-bottom: var(--padding-size-base); + } `; const Title = styled.div` @@ -45,6 +52,7 @@ const SubTitle = styled.div` const Status = styled.div` /* Status styling */ padding: var(--padding-size-base); + padding-left: calc(var(--padding-size-base) * 2); padding-right: calc(var(--padding-size-base) * 3); display: flex; column-gap: var(--spacer); @@ -70,6 +78,12 @@ const DateTime = styled.div` text-align: right; padding: var(--padding-size-base); color: var(--text-dark); + align-content: center; + @media (max-width: 800px) { + flex-basis: 100%; + padding-left: calc(var(--padding-size-base) * 2); + text-align: left; + } `; const Date = styled.div` diff --git a/assets/shared/templates/calendar/calendar-single-booking.jsx b/assets/shared/templates/calendar/calendar-single-booking.jsx index 34d345ca1..5c484f754 100644 --- a/assets/shared/templates/calendar/calendar-single-booking.jsx +++ b/assets/shared/templates/calendar/calendar-single-booking.jsx @@ -54,7 +54,12 @@ function CalendarSingleBooking({ slide, run, }) { - const { title = "", subTitle = null, mediaContain } = content; + const { + title = "", + subTitle = null, + mediaContain, + instantBookingEnabled = false, + } = content; // Get values from client localstorage. const token = localStorage.getItem("apiToken"); @@ -70,6 +75,10 @@ function CalendarSingleBooking({ const [bookingError, setBookingError] = useState(false); const fetchBookingIntervals = () => { + if (!instantBookingEnabled) { + return; + } + if (!apiUrl || !slide || !token || !tenantKey) { setFetchingIntervals(false); return; @@ -147,15 +156,20 @@ function CalendarSingleBooking({ const instantBooking = getInstantBookingFromLocalStorage(slide["@id"]); + let newBookingResult = null; + // Clean out old instantBookings. - if (instantBooking) { - if (dayjs(instantBooking.interval.to) < dayjs()) { - setInstantBookingFromLocalStorage(slide["@id"], null); - setBookingResult(null); + if (instantBooking !== null) { + const intervalFrom = instantBooking?.interval?.to; + + if (intervalFrom !== null && dayjs(intervalFrom) > dayjs()) { + newBookingResult = instantBooking; } else { - setBookingResult(instantBooking); + setInstantBookingFromLocalStorage(slide["@id"], null); } } + + setBookingResult(newBookingResult); }; const clickInterval = (interval) => { @@ -163,6 +177,10 @@ function CalendarSingleBooking({ return; } + if (!instantBookingEnabled) { + return; + } + setProcessingBooking(true); fetch(`${apiUrl}${slide["@id"]}/action`, { @@ -209,7 +227,9 @@ function CalendarSingleBooking({ }, []); useEffect(() => { - fetchBookingIntervals(); + if (instantBookingEnabled) { + fetchBookingIntervals(); + } }, [run]); const currentEvents = calendarEvents.filter( @@ -218,10 +238,13 @@ function CalendarSingleBooking({ ); const futureEvents = calendarEvents.filter( - (el) => !currentEvents.includes(el), + (el) => + !currentEvents.includes(el) && + el.endTime > dayjs().unix() && + el.endTime <= dayjs().endOf("day").unix(), ); - const roomInUse = currentEvents.length > 0; + const roomInUse = bookingResult !== null || currentEvents.length > 0; const roomAvailableForInstantBooking = !roomInUse && fetchingIntervals ? null : bookableIntervals?.length > 0; @@ -243,15 +266,16 @@ function CalendarSingleBooking({ style={templateRootStyle} >
- + {subTitle && {subTitle}} {title} - + {roomInUse ? ( @@ -268,6 +292,7 @@ function CalendarSingleBooking({
- {roomInUse && - currentEvents.map((event) => ( - - - {renderTimeOfDayFromUnixTimestamp(event.startTime)} - {" - "} - {renderTimeOfDayFromUnixTimestamp(event.endTime)} - -

{getTitle(event.title)}

-
- ))} - {!roomInUse && ( + {roomInUse && ( + <> + {bookingResult && ( + +

+ {" "} + {dayjs(bookingResult.interval.to) + .locale(localeDa) + .format("HH:mm")} +

+
+ )} + {!bookingResult && + currentEvents.map((event) => ( + + + {renderTimeOfDayFromUnixTimestamp(event.startTime)} + {" - "} + {renderTimeOfDayFromUnixTimestamp(event.endTime)} + +

{getTitle(event.title)}

+
+ ))} + + )} + {!roomInUse && instantBookingEnabled && ( <> {!processingBooking && !bookingResult && !bookingError && ( diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index 702a15ea2..d583830d9 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -44,7 +44,6 @@ function ImageText({ slide, content, run, slideDone, executionId }) { const imageTimeoutRef = useRef(); const [images, setImages] = useState([]); const [currentImage, setCurrentImage] = useState(); - const [themeCss, setThemeCss] = useState(null); const logo = slide?.theme?.logo; const { showLogo, @@ -69,15 +68,6 @@ function ImageText({ slide, content, run, slideDone, executionId }) { logoClasses.push(logoPosition); } - // Set theme styles. - useEffect(() => { - if (slide?.theme?.cssStyles) { - setThemeCss( - , - ); - } - }, [slide]); - // Styling from content const { separator, @@ -257,7 +247,9 @@ function ImageText({ slide, content, run, slideDone, executionId }) { )} - {themeCss} + {slide?.theme?.cssStyles && ( + + )} ); } diff --git a/assets/shared/templates/image-text/image-text.scss b/assets/shared/templates/image-text/image-text.scss index f734ad437..2845f4d2e 100644 --- a/assets/shared/templates/image-text/image-text.scss +++ b/assets/shared/templates/image-text/image-text.scss @@ -40,6 +40,10 @@ .text { margin-top: var(--spacer); + + p { + margin-bottom: 1em; + } } &.full-screen { diff --git a/assets/shared/templates/news-feed.jsx b/assets/shared/templates/news-feed.jsx index 8a147d1e1..b478db48f 100644 --- a/assets/shared/templates/news-feed.jsx +++ b/assets/shared/templates/news-feed.jsx @@ -31,6 +31,7 @@ function renderSlide(slide, run, slideDone) { /> ); } + /** * News feed slide. * @@ -49,6 +50,7 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { const [currentPost, setCurrentPost] = useState(null); const [posts, setPosts] = useState([]); const [qr, setQr] = useState(null); + const transitionRef = useRef(null); const timerRef = useRef(); @@ -82,6 +84,7 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { setQr(null); } else { QRCode.toDataURL(currentPost.link, { + margin: 0, color: { dark: "#000000", light: "#ffffff00", @@ -106,8 +109,13 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { }, [posts]); useEffect(() => { - if (feedData && Object.hasOwnProperty.call(feedData, "entries")) { + if (feedData?.entries?.length > 0) { setPosts(feedData.entries); + } else if (!transitionRef.current) { + // If no content, wait 5 seconds and continue to next slide. + transitionRef.current = setTimeout(() => { + slideDone(slide); + }, 5000); } }, [feedData]); @@ -119,6 +127,14 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { } }, [run]); + useEffect(() => { + return () => { + if (transitionRef.current) { + clearInterval(transitionRef.current); + } + }; + }, []); + const getImageUrl = (post) => { let imageUrl = fallbackImageUrl ?? null; diff --git a/assets/shared/templates/news-feed/news-feed.scss b/assets/shared/templates/news-feed/news-feed.scss index 565c92e00..405647c74 100644 --- a/assets/shared/templates/news-feed/news-feed.scss +++ b/assets/shared/templates/news-feed/news-feed.scss @@ -86,8 +86,8 @@ justify-content: end; .qr { - width: 20%; - margin-bottom: 2%; + width: 15%; + margin-bottom: 5%; } .read-more { diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index ded7d7af7..ed1e6bc04 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -6,7 +6,7 @@ const slideFixtures = [ templateData: { id: "01FP2SME0ENTXWF362XHM6Z1B4", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -33,7 +33,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feedData: [ { id: "uniqueEventMinusTwo", @@ -294,7 +294,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/bautavej.css", + themeFile: null, feedData: [ { id: "uniqueEventMinusTwo", @@ -458,7 +458,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feedData: [ { id: "uniqueEvent0", @@ -602,7 +602,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feed: { resources: ["test-lokale@display-templates.local.itkdev.dk"], }, @@ -652,6 +652,7 @@ const slideFixtures = [ darkModeEnabled: false, content: { duration: 60000, + instantBookingEnabled: true, layout: "singleBooking", title: "M2.3", subTitle: "Mødelokale", @@ -667,7 +668,7 @@ const slideFixtures = [ templateData: { id: "01FPZ19YEHX7MQ5Q6ZS0WK0VEA", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -751,7 +752,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -779,7 +780,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -822,7 +823,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -860,7 +861,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -882,6 +883,27 @@ const slideFixtures = [ fontSize: "font-size-lg", }, }, + { + id: "image-text-4-test-theme", + templateData: { + id: "01FP2SNGFN0BZQH03KCBXHKYHG", + }, + themeFile: "/fixtures/example.css", + content: { + duration: 5000, + title: "Overskriften er her", + text: "Dette er brødtekst lorem ipsum dolor sit amet....", + image: [], + boxAlign: "top", + boxMargin: false, + shadow: true, + separator: false, + halfSize: false, + reversed: false, + mediaContain: true, + fontSize: "font-size-xl", + }, + }, { id: "instagram-0", templateData: { @@ -968,7 +990,7 @@ const slideFixtures = [ templateData: { id: "01JEWPAFF93YSF418TH72W1SBA", }, - themeFile: "/themes/aarhus.css", + themeFile: null, // Disable dark mode for slide. darkModeEnabled: false, feed: { @@ -1049,7 +1071,7 @@ const slideFixtures = [ mediaData: { "/v1/media/00000000000000000000000001": { assets: { - uri: "/fixtures/template/images/dokk1-rss-template-bg.jpg", + uri: "/fixtures/template/images/mountain1.jpeg", }, }, }, @@ -1064,7 +1086,7 @@ const slideFixtures = [ templateData: { id: "01FWJZQ25A1868V63CWYYHQFKQ", }, - themeFile: "/themes/aakb.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -1126,7 +1148,7 @@ const slideFixtures = [ templateData: { id: "01FWJZQ25A1868V63CWYYHQFKQ", }, - themeFile: "/themes/aarhus.css", + themeFile: null, feed: { configuration: { overrideTitle: null, @@ -1192,7 +1214,7 @@ const slideFixtures = [ templateData: { id: "01FWJZQ25A1868V63CWYYHQFKQ", }, - themeFile: "/themes/aakb.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -1232,7 +1254,7 @@ const slideFixtures = [ templateData: { id: "01FQC300GGWCA7A8H0SXY6P9FG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feed: { configuration: { numberOfEntries: 5, @@ -1280,7 +1302,7 @@ const slideFixtures = [ mediaData: { "/v1/media/00000000000000000000000001": { assets: { - uri: "/fixtures/template/images/dokk1-shapes-animated.svg", + uri: "/fixtures/template/mountain1.jpeg", }, }, }, @@ -1358,7 +1380,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -1409,7 +1431,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -1460,7 +1482,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -1513,7 +1535,7 @@ const slideFixtures = [ templateData: { id: "01FQBJFKM0YFX1VW5K94VBSNCP", }, - themeFile: "/themes/aarhus.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -1563,7 +1585,7 @@ const slideFixtures = [ templateData: { id: "01FQBJFKM0YFX1VW5K94VBSNCP", }, - themeFile: "/themes/aarhus.css", + themeFile: null, content: { duration: 5000, title: "Overskrift2", @@ -1704,7 +1726,7 @@ const slideFixtures = [ templateData: { id: "01FQBJFKM0YFX1VW5K94VBSNCC", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { diff --git a/assets/template/index.jsx b/assets/template/index.jsx index 75291ea22..43c078c3d 100644 --- a/assets/template/index.jsx +++ b/assets/template/index.jsx @@ -70,7 +70,7 @@ export const Slide = ({ slide: inputSlide }) => { useEffect(() => { if (inputSlide !== null) { const newSlide = { ...inputSlide }; - newSlide.executionId = "" + new Date().getTime(); + newSlide.executionId = "SLIDE_ID"; // Attach theme. if (newSlide?.themeFile) { diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 7b1beac4f..179834b74 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -51,3 +51,22 @@ api_platform: email: itkdev@mkb.aarhus.dk license: name: MIT + + # @see https://api-platform.com/docs/core/errors/#exception-to-status-configuration-using-symfony + exception_to_status: + # The 4 following handlers are registered by default, keep those lines to prevent unexpected side effects + Symfony\Component\Serializer\Exception\ExceptionInterface: 400 # Use a raw status code (recommended) + ApiPlatform\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST + ApiPlatform\ParameterValidator\Exception\ValidationExceptionInterface: 400 + Doctrine\ORM\OptimisticLockException: 409 + + # Validation exception + ApiPlatform\Validator\Exception\ValidationException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY + + # App exception mappings + App\Exceptions\BadRequestException: 400 + App\Exceptions\ForbiddenException: 403 + App\Exceptions\NotFoundException: 404 + App\Exceptions\NotAcceptableException: 406 + App\Exceptions\ConflictException: 409 + App\Exceptions\TooManyRequestsException: 429 diff --git a/public/release-example.json b/docs/release-example.json similarity index 100% rename from public/release-example.json rename to docs/release-example.json diff --git a/public/themes/EXAMPLE.css b/docs/themes/example.css similarity index 100% rename from public/themes/EXAMPLE.css rename to docs/themes/example.css diff --git a/public/themes/README.md b/docs/themes/themes.md similarity index 70% rename from public/themes/README.md rename to docs/themes/themes.md index b8eb08a81..d4d83411d 100644 --- a/public/themes/README.md +++ b/docs/themes/themes.md @@ -1,14 +1,14 @@ # Theme development -For the styles to have effect you will need to append `#SLIDE_ID` to all styling. +For the styles to have effect you will need to use the `#SLIDE_ID` to all styling. -`#SLIDE_ID` will be replaced with an actual id on the client to isolate the styling on the current slide. +`#SLIDE_ID` will be replaced with an actual id in the Client to isolate the styling on the current slide. ## Examples -An example file can be found in this directory. +An example file can be found in this directory: [example.css](example.css). -Below is a example of variables on the slide container. +Below is an example of variables on the slide container. ```css diff --git a/docs/v2-changelogs/admin.md b/docs/v2-changelogs/admin.md index 0d11e15c0..b5b0eb0cb 100644 --- a/docs/v2-changelogs/admin.md +++ b/docs/v2-changelogs/admin.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.5.2] - 2025-09-04 + +- [#290](https://github.com/os2display/display-admin-client/pull/290) + - Added temporary fix that reloads the page after a screen has been saved, to ensure fresh data is fetched. + +## [2.5.1] - 2025-06-23 + - [#287](https://github.com/os2display/display-admin-client/pull/287) - Disable live slide preview for templates with option.disableLivePreview. diff --git a/docs/v2-changelogs/api.md b/docs/v2-changelogs/api.md index 784e7a696..a404ea749 100644 --- a/docs/v2-changelogs/api.md +++ b/docs/v2-changelogs/api.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.5.2] - 2025-09-04 + +- [#260](https://github.com/os2display/display-api-service/pull/260) + - Changed how exceptions are handled in InstantBook. + ## [2.5.1] - 2025-06-23 - [#245](https://github.com/os2display/display-api-service/pull/245) diff --git a/docs/v2-changelogs/template.md b/docs/v2-changelogs/template.md index 4a484e69c..10084ceff 100644 --- a/docs/v2-changelogs/template.md +++ b/docs/v2-changelogs/template.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. ## Unreleased +## [2.5.2] - 2025-09-04 + +- [#189](https://github.com/os2display/display-templates/pull/189) + - Set margin-bottom of p element of rich text in image-text. +- [#188](https://github.com/os2display/display-templates/pull/188) + - Fixed issues with calendar single booking layout. + - Added extra description for resourceAvailableText and hasDateAndTime fields. +- [#185](https://github.com/os2display/display-templates/pull/185) + - Single Clanedar Booking: Fix color issues on older browsers. Add simple vertical view for portrait oriented devices. +- [#184](https://github.com/os2display/display-templates/pull/184) + - Update Dokk1 and Aakb themes. Add news feed. Adapt new identity for Aakb. + +## [2.5.1] - 2025-06-23 + +- [#178](https://github.com/os2display/display-templates/pull/178) + - Fixed news-feed template blocks slide transitions. - [#177](https://github.com/os2display/display-templates/pull/177) - Set options.disableLivePreview for iframe templates diff --git a/fixtures/public/fixtures/example.css b/fixtures/public/fixtures/example.css new file mode 100644 index 000000000..f058fc83f --- /dev/null +++ b/fixtures/public/fixtures/example.css @@ -0,0 +1,19 @@ +/* +* Example theme file +* #SLIDE_ID should always encapsulate all your theme styling +* #SLIDE_ID will be replaced at runtime with the given slide execution id to make sure the theme styling +* only applies to the given slide. +*/ + +#SLIDE_ID { + --bg-light: red; + --bg-dark: blue; + --text-light: purple; + --text-dark: green; + --text-color: yellow; +} + +#SLIDE_ID .text { + background-color: var(--bg-light); + color: var(--text-color); +} diff --git a/public/fixtures/template/images/author.jpg b/fixtures/public/fixtures/template/images/author.jpg similarity index 100% rename from public/fixtures/template/images/author.jpg rename to fixtures/public/fixtures/template/images/author.jpg diff --git a/public/fixtures/template/images/logo.png b/fixtures/public/fixtures/template/images/logo.png similarity index 100% rename from public/fixtures/template/images/logo.png rename to fixtures/public/fixtures/template/images/logo.png diff --git a/public/fixtures/template/images/mountain1.jpeg b/fixtures/public/fixtures/template/images/mountain1.jpeg similarity index 100% rename from public/fixtures/template/images/mountain1.jpeg rename to fixtures/public/fixtures/template/images/mountain1.jpeg diff --git a/public/fixtures/template/images/mountain2.jpeg b/fixtures/public/fixtures/template/images/mountain2.jpeg similarity index 100% rename from public/fixtures/template/images/mountain2.jpeg rename to fixtures/public/fixtures/template/images/mountain2.jpeg diff --git a/public/fixtures/template/images/mountain3.jpeg b/fixtures/public/fixtures/template/images/mountain3.jpeg similarity index 100% rename from public/fixtures/template/images/mountain3.jpeg rename to fixtures/public/fixtures/template/images/mountain3.jpeg diff --git a/public/fixtures/template/images/mountain4.jpeg b/fixtures/public/fixtures/template/images/mountain4.jpeg similarity index 100% rename from public/fixtures/template/images/mountain4.jpeg rename to fixtures/public/fixtures/template/images/mountain4.jpeg diff --git a/public/fixtures/template/images/sunset-full-hd.jpg b/fixtures/public/fixtures/template/images/sunset-full-hd.jpg similarity index 100% rename from public/fixtures/template/images/sunset-full-hd.jpg rename to fixtures/public/fixtures/template/images/sunset-full-hd.jpg diff --git a/public/fixtures/template/images/vertical.jpg b/fixtures/public/fixtures/template/images/vertical.jpg similarity index 100% rename from public/fixtures/template/images/vertical.jpg rename to fixtures/public/fixtures/template/images/vertical.jpg diff --git a/public/fixtures/template/videos/test.mp4 b/fixtures/public/fixtures/template/videos/test.mp4 similarity index 100% rename from public/fixtures/template/videos/test.mp4 rename to fixtures/public/fixtures/template/videos/test.mp4 diff --git a/public/fixtures/template/videos/video.mp4 b/fixtures/public/fixtures/template/videos/video.mp4 similarity index 100% rename from public/fixtures/template/videos/video.mp4 rename to fixtures/public/fixtures/template/videos/video.mp4 diff --git a/migrations/Version20250828084617.php b/migrations/Version20250828084617.php new file mode 100644 index 000000000..6c526f84a --- /dev/null +++ b/migrations/Version20250828084617.php @@ -0,0 +1,28 @@ +addSql('RENAME TABLE interactive_slide TO interactive_slide_config;'); + $this->addSql('ALTER TABLE interactive_slide_config RENAME INDEX idx_138e544d9033212a TO IDX_D30060259033212A'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE interactive_slide_config RENAME INDEX idx_d30060259033212a TO IDX_138E544D9033212A'); + $this->addSql('RENAME TABLE interactive_slide_config TO interactive_slide;'); + } +} diff --git a/public/fixtures/template/images/dokk1-rss-template-bg.jpg b/public/fixtures/template/images/dokk1-rss-template-bg.jpg deleted file mode 100644 index 5b6d725b7..000000000 Binary files a/public/fixtures/template/images/dokk1-rss-template-bg.jpg and /dev/null differ diff --git a/public/fixtures/template/images/dokk1-shapes-animated.svg b/public/fixtures/template/images/dokk1-shapes-animated.svg deleted file mode 100644 index 797ce6c23..000000000 --- a/public/fixtures/template/images/dokk1-shapes-animated.svg +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - diff --git a/public/themes/aakb.css b/public/themes/aakb.css deleted file mode 100644 index 24e2feb86..000000000 --- a/public/themes/aakb.css +++ /dev/null @@ -1,225 +0,0 @@ -/* -* Aarhus Kommunes Biblioteker theme css -*/ - -/* Import FaktPro font */ -@font-face { - font-family: "FaktPro-Normal"; - src: url("https://db.onlinewebfonts.com/t/fdd40b399610a1e015242521427561b1.woff") - format("woff"); - font-display: auto; - font-weight: 400; - font-style: normal; -} - -@font-face { - font-family: "FaktPro-SemiBold"; - src: url("https://db.onlinewebfonts.com/t/2eed23505916e842a2d6b7eeb0e4def0.woff") - format("woff"); - font-weight: 400; - font-style: normal; -} - - -#SLIDE_ID { - /* Default variables */ - /* --bg-light: #f6f6f6; */ - --bg-light: #fff; - --text-dark: #000; - --color-primary: #f26306; - --color-secondary: #ef0043; - --color-success: #008850; - --color-info: #008488; - --color-warning: #fee13d; - --color-danger: #d32f2e; - - --color-blue: var(--color-primary); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --font-family-base: "FaktPro-Normal", sans-serif; - --font-family-bold: "FaktPro-SemiBold", sans-serif; - --font-weight-base: 400; - --font-weight-bold: 400; - - /* Darkmode overrides */ - --bg-dark: #333333; - --text-light: #ffffff; - - font-family: var(--font-family-base); -} - -/* -* -* Customizations for poster template -* -*/ - -#SLIDE_ID .template-poster .header-area { - padding: 10%; -} - -#SLIDE_ID .template-poster .info-area { - padding: 10%; - font-size: calc(var(--font-size-base) * 1.25); - font-weight: 300; -} - -#SLIDE_ID .template-poster .logo-area { - padding: 3% 10% 0 10%; - background-color: var(--bg-light); -} - -#SLIDE_ID .template-poster img { - margin-right: 0; -} - -#SLIDE_ID .template-poster h1 { - font-family: var(--font-family-base); - font-weight: var(--font-weight-base); -} - -#SLIDE_ID .template-poster .lead { - font-family: var(--font-family-bold); - font-weight: var(--font-weight-bold); - font-size: calc(var(--font-size-base) * 1.25); -} - -#SLIDE_ID .template-poster .info-area .date { - margin-bottom: 2%; -} - -#SLIDE_ID .template-poster .look-like-link { - color: var(--color-primary); -} - -/* -* -* Customize Book review template styling -* -*/ - -#SLIDE_ID .template-book-review { - --text-color: var(--color-grey-700); -} - -#SLIDE_ID .template-book-review .author { - --text-color: var(--color-grey-500); -} - -/* -* -* Customize RSS template styling -* -*/ - -#SLIDE_ID .template-rss { - --text-color: var(--text-light, hsl(0deg, 0%, 100%)); - padding: calc(var(--spacer) * 4); - gap: calc(var(--spacer) * 6); - background-color: var(--color-primary); - color: var(--text-color); -} - -.color-scheme-dark #SLIDE_ID .template-rss { - --text-color: var(--text-dark, hsl(0deg, 0%, 0%)); -} - -#SLIDE_ID .template-rss .feed-info { - gap: calc(var(--spacer) * 2); -} - -#SLIDE_ID .template-rss .feed-info--date { - border-right: 3px solid var(--color-white); - padding-right: calc(var(--spacer) * 2); - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .feed-info--title, -#SLIDE_ID .template-rss .feed-info--date, -#SLIDE_ID .template-rss .feed-info--progress { - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .content { - gap: calc(var(--spacer) * 2); -} - -#SLIDE_ID .template-rss .title { - font-size: calc(var(--font-size-base) * 5); - font-weight: var(--font-weight-bold); - line-height: 1.2; -} - -#SLIDE_ID .template-rss .description { - font-size: calc(var(--font-size-base) * 3); - line-height: 1.3; -} - -/* -* -* Customize Image text template -* -*/ - -#SLIDE_ID .template-image-text .box { - background-color: var(--bg-dark); - color: var(--text-light); -} - -#SLIDE_ID .template-image-text.reversed { - color: var(--text-light); - text-shadow: var(--shadow-text-m); -} -#SLIDE_ID .template-image-text.reversed .box { - background-color: transparent; - box-shadow: none; -} - -/* -* -* Customize Instagram template styling -* -*/ -#SLIDE_ID .template-instagram-feed { - --h1-font-size: calc(var(--font-size-base) * 3.5); - --h4-font-size: calc(var(--font-size-base) * 1.75); - --font-size-xl: calc(var(--font-size-base) * 2); - - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section { - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section .description .text { - display: -webkit-box; - max-height: 74%; - line-clamp: 8; - -webkit-line-clamp: 8; - -webkit-box-orient: vertical; - overflow: hidden; -} - -#SLIDE_ID .template-instagram-feed .author-section .date { - color: var(--color-primary); - display: block; -} - -#SLIDE_ID .template-instagram-feed .shape { - display: none; -} - -#SLIDE_ID .template-instagram-feed .brand { - bottom: 0; - padding-top: calc(var(--spacer) * 2); - padding-bottom: calc(var(--spacer) * 2); - color: var(--color-primary); - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed.landscape .brand { - width: var(--percentage-narrow); -} \ No newline at end of file diff --git a/public/themes/aarhus.css b/public/themes/aarhus.css deleted file mode 100644 index e8d62f3e2..000000000 --- a/public/themes/aarhus.css +++ /dev/null @@ -1,31 +0,0 @@ -/* -* Aarhus theme css -*/ - -#SLIDE_ID { - /* Default variables */ - /* --bg-light: #f6f6f6; */ - --bg-light: #f6f6f6; - --text-dark: #333333; - --color-primary: #3761d9; - --color-secondary: #ef0043; - --color-success: #008850; - --color-info: #008488; - --color-warning: #fee13d; - --color-danger: #d32f2e; - - --color-blue: var(--color-primary); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --font-family-base: Arial, "sans-serif"; - --font-weight-light: 300; - --font-weight-bold: 600; - - /* Darkmode overrides */ - --bg-dark: #333333; - --text-light: #ffffff; - - font-family: "Arial", sans-serif; -} diff --git a/public/themes/bautavej.css b/public/themes/bautavej.css deleted file mode 100644 index 589e35fb8..000000000 --- a/public/themes/bautavej.css +++ /dev/null @@ -1,42 +0,0 @@ -/* -* Bautavej theme file -* -* #SLIDE_ID should always encapsulate all your theme styling -* #SLIDE_ID will be replaced at runtime with the given slide execution id to make sure the theme styling -* only applies to the given slide. -*/ - -#SLIDE_ID { - --color-primary: hsla(125, 49%, 42%, 1); - --color-grey-100: hsl(0, 100%, 900%); -} - -/* -* Styling for Calendar multiple -*/ -#SLIDE_ID .calendar-multiple { - /* Remove borders */ - --border: 0px; - /* Reduce space between items */ - --content-item-padding: calc(var(--padding) * 0.5); - padding: 0; - color: black; - background-color: var(--color-grey-100); -} - -#SLIDE_ID .calendar-multiple .header { - color: white; - background-color: var(--color-primary); - align-items: center; -} - -#SLIDE_ID .calendar-multiple .header-title { - font-size: var(--h3-font-size); -} - -#SLIDE_ID .calendar-multiple .content-item, -#SLIDE_ID .calendar-multiple .content-item-time, -#SLIDE_ID .calendar-multiple .content-item-title, -#SLIDE_ID .calendar-multiple .content-item-resource { - padding-bottom: var(--content-item-padding); -} diff --git a/public/themes/blixen.css b/public/themes/blixen.css deleted file mode 100644 index d2f125bd2..000000000 --- a/public/themes/blixen.css +++ /dev/null @@ -1,61 +0,0 @@ -/* -** Blixen theme css -*/ - -/* Import roboto slab font from google - Used with Blixen theme */ -/* NOTE: The fonts are inserted directly instead of through @import, since they failed to load with this @import: */ -/* @import url("https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@300;700&display=swap"); */ - -/* latin */ -@font-face { - font-family: 'Roboto Slab'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url(https://fonts.gstatic.com/s/robotoslab/v24/BngMUXZYTXPIvIBgJJSb6ufN5qWr4xCC.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin */ -@font-face { - font-family: 'Roboto Slab'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(https://fonts.gstatic.com/s/robotoslab/v24/BngMUXZYTXPIvIBgJJSb6ufN5qWr4xCC.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -#SLIDE_ID { - /* Default variabels */ - --color-primary: #673ab7; - --font-family-base: "Roboto Slab", serif; - --background-color: var(--bg-dark); - --text-color: var(--color-light); - --padding-size-base: 60px; - --margin-size-base: 60px; - - font-family: var(--font-family-base); -} - -/* Customize calender single template styling */ -#SLIDE_ID .calendar-single { - --background-color: var(--color-primary); - --text-color: var(--color-light); - --font-size-base: 2rem; - text-align: right; -} - -#SLIDE_ID .calendar-single .title, -#SLIDE_ID .calendar-single .subtitle { - font-size: 4rem; - font-weight: 300; -} -#SLIDE_ID .calendar-single .subtitle { - font-weight: 700; - margin-bottom: 60px; -} - -#SLIDE_ID .calendar-single .content-item { - font-family: Arial, sans-serif; - border-left: 0; -} diff --git a/public/themes/dokk1.css b/public/themes/dokk1.css deleted file mode 100644 index 496ee5eb3..000000000 --- a/public/themes/dokk1.css +++ /dev/null @@ -1,255 +0,0 @@ -/* -** DOKK1 theme css -*/ - -/* Import Gibson font from typekit - Used with Dokk1 theme */ -@import url("https://p.typekit.net/p.css?s=1&k=ilx8ovv&ht=tk&f=24355.24356.43309.43310&a=3352895&app=typekit&e=css"); -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/6c50f4/00000000000000007735a544/30/l?subset_id=2&fvd=n6&v=3") - format("woff2"), - url("https://use.typekit.net/af/6c50f4/00000000000000007735a544/30/d?subset_id=2&fvd=n6&v=3") - format("woff"), - url("https://use.typekit.net/af/6c50f4/00000000000000007735a544/30/a?subset_id=2&fvd=n6&v=3") - format("opentype"); - font-display: auto; - font-style: normal; - font-weight: 600; -} -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/56af16/00000000000000007735a545/30/l?subset_id=2&fvd=i6&v=3") - format("woff2"), - url("https://use.typekit.net/af/56af16/00000000000000007735a545/30/d?subset_id=2&fvd=i6&v=3") - format("woff"), - url("https://use.typekit.net/af/56af16/00000000000000007735a545/30/a?subset_id=2&fvd=i6&v=3") - format("opentype"); - font-display: auto; - font-style: italic; - font-weight: 600; -} -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/37e7f5/00000000000000007735a548/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n3&v=3") - format("woff2"), - url("https://use.typekit.net/af/37e7f5/00000000000000007735a548/30/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n3&v=3") - format("woff"), - url("https://use.typekit.net/af/37e7f5/00000000000000007735a548/30/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n3&v=3") - format("opentype"); - font-display: auto; - font-style: normal; - font-weight: 300; -} -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/e171bf/00000000000000007735a549/30/l?subset_id=2&fvd=i3&v=3") - format("woff2"), - url("https://use.typekit.net/af/e171bf/00000000000000007735a549/30/d?subset_id=2&fvd=i3&v=3") - format("woff"), - url("https://use.typekit.net/af/e171bf/00000000000000007735a549/30/a?subset_id=2&fvd=i3&v=3") - format("opentype"); - font-display: auto; - font-style: italic; - font-weight: 300; -} - -/* Defaults */ -#SLIDE_ID { - --bg-light: #f1f0ef; - --text-dark: #222; - --color-primary: #003764; - --color-primary-opaque: hsla(207, 100%, 20%, 0.9); - --color-secondary: #887c76; - --color-success: #006c3b; - --color-info: #0dcaf0; - --color-warning: #ffb400; - --color-danger: #dc3545; - - --color-blue: var(--color-primary); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --color-grey-100: hsl(0deg 0% 95%); - --color-grey-200: hsl(0deg 0% 85%); - --color-grey-300: hsla(0, 0%, 69%, 1); - --color-grey-400: hsla(0, 0%, 52%, 1); - --color-grey-500: hsla(0, 0%, 35%, 1); - --color-grey-600: hsla(0, 0%, 20%, 1); - --color-grey-700: hsla(0, 0%, 18%, 1); - --color-grey-800: hsla(0, 0%, 13%, 1); - --color-grey-900: hsla(0, 0%, 9%, 1); - - --font-family-base: "canada-type-gibson", Gibson, Arial, "sans-serif"; - --font-weight-light: 300; - --font-weight-bold: 600; - - --bg-primary: var(--color-primary); - - --shadow-text-m: 0px 4px 16px hsla(0, 0%, 0%, 0.4); - - /* Darkmode overrides */ - --bg-dark: #212529; - --text-light: #ffffff; - - font-family: var(--font-family-base); -} - -/* Set seperator default color. */ -#SLIDE_ID .separator { - background-color: white; -} - -/* Customize calender single template styling */ -#SLIDE_ID .calendar-single { - --h1-font-size: 5rem; - --h4-font-size: 3rem; - --font-size-base: 2rem; - --padding-size-base: 4rem; - --background-color: var(--color-primary); - --text-color: var(--color-light); - --border: 3px solid var(--color-light); - background-image: none; -} - -/* -* -* Customize calender multiple template styling -* -*/ -#SLIDE_ID .calendar-multiple, -#SLIDE_ID .calendar-multiple-days { - /* Use same colors for both light and dark */ - --text-light: #ffffff; - --color-grey-100: var(--color-grey-900); - --color-grey-200: var(--color-grey-800); - --color-grey-300: var(--color-grey-700); - --color-grey-400: var(--color-grey-600); - --bg-dark: var(--color-grey-900); - --padding-size-base: 36px; - --background-color: var(--bg-dark); - --border: 1px solid var(--color-grey-900); - --color-primary: var(--color-yellow); - --text-color: var(--color-light); - background-image: none; -} - -#SLIDE_ID .calendar-multiple .header-title, -#SLIDE_ID .calendar-multiple-days .header-title { - color: var(--color-yellow); -} - -#SLIDE_ID .calendar-multiple .content-col, -#SLIDE_ID .calendar-multiple-days .content-col { - background-color: var(--color-grey-700); -} - -#SLIDE_ID .calendar-multiple .col-title, -#SLIDE_ID .calendar-multiple-days .col-title { - background-color: var(--color-grey-800); -} - -/* -* -* Customize Instagram template styling -* -*/ -#SLIDE_ID .template-instagram-feed { - --h1-font-size: calc(var(--font-size-base) * 3.5); - --h4-font-size: calc(var(--font-size-base) * 1.75); - --font-size-xl: calc(var(--font-size-base) * 2); - - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section { - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section .date { - color: var(--color-grey-400); -} - -#SLIDE_ID .template-instagram-feed .shape svg { - fill: var(--color-grey-100); -} - -#SLIDE_ID .template-instagram-feed .brand { - color: var(--color-grey-500); -} - -/* -* -* Customize Book review template styling -* -*/ - -#SLIDE_ID .template-book-review { - --text-color: var(--color-grey-700); -} - -#SLIDE_ID .template-book-review .author { - --text-color: var(--color-grey-500); -} - -/* -* -* Customize RSS template styling -* -*/ - -#SLIDE_ID .template-rss { - --text-color: var(--text-light, hsl(0deg, 0%, 100%)); - padding: calc(var(--spacer) * 4); - gap: calc(var(--spacer) * 6); - background-color: var(--color-primary); - color: var(--text-color); -} - -.color-scheme-dark #SLIDE_ID .template-rss { - --text-color: var(--text-dark, hsl(0deg, 0%, 0%)); -} - -#SLIDE_ID .template-rss .feed-info--date { - border-right: 3px solid var(--color-white); - padding-right: calc(var(--spacer) * 2); - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .feed-info--title, -#SLIDE_ID .template-rss .feed-info--date, -#SLIDE_ID .template-rss .feed-info--progress { - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .title { - font-size: calc(var(--font-size-base) * 5); - font-weight: var(--font-weight-bold); -} - -#SLIDE_ID .template-rss .description { - font-size: calc(var(--font-size-base) * 3); -} - -/* -* -* Customize Image text template -* -*/ - -#SLIDE_ID .template-image-text .box { - background-color: var(--color-primary-opaque); - color: var(--text-light); -} - -#SLIDE_ID .template-image-text.reversed .box { - background-color: transparent; -} -#SLIDE_ID .template-image-text.reversed { - color: var(--text-light); - text-shadow: var(--shadow-text-m); -} - -#SLIDE_ID .template-image-text.reversed h1 { - font-size: calc(var(--font-size-base) * 2); -} diff --git a/public/themes/infostander.css b/public/themes/infostander.css deleted file mode 100644 index 115e3fe1e..000000000 --- a/public/themes/infostander.css +++ /dev/null @@ -1,17 +0,0 @@ -#SLIDE_ID .template-image-text { - background-size: auto; /* We might wish to change this to `contain` instead of auto. Since larger images the will be forced inside the 544/416 container. */ - background-repeat: no-repeat; - background-position: top left; - background-color: transparent; - position: relative; - width: 416px; - height: 544px; -} - -#SLIDE_ID .template-image-text.preview { - background-size: cover; -} - -#SLIDE_ID .template-image-text .box { - display: none !important; -} diff --git a/public/themes/mso.css b/public/themes/mso.css deleted file mode 100644 index ee73e6b51..000000000 --- a/public/themes/mso.css +++ /dev/null @@ -1,28 +0,0 @@ -/* -** MSO theme css -*/ - -#SLIDE_ID { - /* Defaults */ - --bg-light: #f6f6f6; - --text-dark: #333333; - --color-primary: #008488; - --color-secondary: #ff5f31; - --color-success: #008850; - --color-info: #3761d9; - --color-warning: #fee13d; - --color-danger: #d32f2e; - - --color-blue: var(--color-info); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --font-family-base: Arial, "sans-serif"; - --font-weight-light: 300; - --font-weight-bold: 600; - - /* Darkmode overrides */ - --bg-dark: #333333; - --text-light: #ffffff; -} diff --git a/src/Controller/Api/InteractiveController.php b/src/Controller/Api/InteractiveController.php index 4d590f3e4..edd3ee460 100644 --- a/src/Controller/Api/InteractiveController.php +++ b/src/Controller/Api/InteractiveController.php @@ -6,13 +6,16 @@ use App\Entity\ScreenUser; use App\Entity\Tenant\Slide; -use App\Entity\User; -use App\Exceptions\NotFoundException; +use App\Exceptions\BadRequestException; +use App\Exceptions\ConflictException; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\TooManyRequestsException; use App\Service\InteractiveSlideService; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; #[AsController] final readonly class InteractiveController @@ -22,18 +25,28 @@ public function __construct( private Security $security, ) {} + /** + * @throws ConflictException + * @throws BadRequestException + * @throws NotAcceptableException + * @throws TooManyRequestsException + */ public function __invoke(Request $request, Slide $slide): JsonResponse { + $user = $this->security->getUser(); + + if (!($user instanceof ScreenUser)) { + throw new AccessDeniedHttpException('Only screen user can perform action.'); + } + + $tenant = $user->getActiveTenant(); + $requestBody = $request->toArray(); $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); - $user = $this->security->getUser(); - - if (!($user instanceof User || $user instanceof ScreenUser)) { - throw new NotFoundException('User not found'); - } + $actionResult = $this->interactiveSlideService->performAction($tenant, $slide, $interactionRequest); - return new JsonResponse($this->interactiveSlideService->performAction($user, $slide, $interactionRequest)); + return new JsonResponse($actionResult); } } diff --git a/src/Entity/Tenant/InteractiveSlide.php b/src/Entity/Tenant/InteractiveSlideConfig.php similarity index 93% rename from src/Entity/Tenant/InteractiveSlide.php rename to src/Entity/Tenant/InteractiveSlideConfig.php index faf36d679..987455b69 100644 --- a/src/Entity/Tenant/InteractiveSlide.php +++ b/src/Entity/Tenant/InteractiveSlideConfig.php @@ -9,7 +9,7 @@ use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: InteractiveSlideRepository::class)] -class InteractiveSlide extends AbstractTenantScopedEntity +class InteractiveSlideConfig extends AbstractTenantScopedEntity { #[Ignore] #[ORM\Column(nullable: true)] diff --git a/src/Exceptions/InteractiveSlideException.php b/src/Exceptions/ForbiddenException.php similarity index 55% rename from src/Exceptions/InteractiveSlideException.php rename to src/Exceptions/ForbiddenException.php index cd9a844cf..74f7e0caa 100644 --- a/src/Exceptions/InteractiveSlideException.php +++ b/src/Exceptions/ForbiddenException.php @@ -4,6 +4,6 @@ namespace App\Exceptions; -class InteractiveSlideException extends \Exception +class ForbiddenException extends \Exception { } diff --git a/src/Exceptions/NotAcceptableException.php b/src/Exceptions/NotAcceptableException.php new file mode 100644 index 000000000..64f6090e0 --- /dev/null +++ b/src/Exceptions/NotAcceptableException.php @@ -0,0 +1,9 @@ +action) { - self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), - self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new BadRequestHttpException('Action not allowed'), + self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($tenant, $slide, $interactionRequest), + self::ACTION_QUICK_BOOK => $this->quickBook($tenant, $slide, $interactionRequest), + default => throw new NotAcceptableException('Action not supported'), }; } /** * @throws \Throwable + * @throws NotAcceptableException */ private function authenticate(array $configuration): array { @@ -105,7 +111,7 @@ private function authenticate(array $configuration): array $password = $this->keyValueService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { - throw new BadRequestHttpException('tenantId, clientId, username, password must all be set.'); + throw new NotAcceptableException('tenantId, clientId, username, password must all be set.'); } $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; @@ -124,14 +130,15 @@ private function authenticate(array $configuration): array } /** + * @throws NotAcceptableException * @throws InvalidArgumentException */ - private function getToken(Tenant $tenant, InteractiveSlide $interactive): string + private function getToken(Tenant $tenant, InteractiveSlideConfig $interactive): string { $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new BadRequestHttpException('InteractiveSlide has no configuration'); + throw new NotAcceptableException('InteractiveSlide has no configuration'); } return $this->interactiveSlideCache->get( @@ -147,99 +154,116 @@ function (CacheItemInterface $item) use ($configuration): mixed { } /** - * @throws \Throwable + * @throws BadRequestException + * @throws InvalidArgumentException */ - private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $interactionRequest): array + private function getQuickBookOptions(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = $interactionRequest->data['resource'] ?? null; if (null === $resource) { - throw new \Exception('Resource not set.'); + throw new BadRequestException('Resource not set.'); } + $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); + return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, - function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) { + function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $tenant) { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - /** @var User|ScreenUser $activeUser */ - $activeUser = $this->security->getUser(); - $tenant = $activeUser->getActiveTenant(); - - $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); + // If any exceptions are thrown we return an empty options entry. + try { + $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - if (null === $interactive) { - throw new \Exception('InteractiveSlide not found'); - } + if (null === $interactiveSlideConfig) { + throw new NotAcceptableException('InteractiveSlideConfig not found'); + } - // Optional limiting of available resources. - $this->checkPermission($interactive, $resource); + $this->checkPermission($interactiveSlideConfig, $resource); - $feed = $slide->getFeed(); + $feed = $slide->getFeed(); - if (null === $feed) { - throw new \Exception('Slide feed not set.'); - } + if (null === $feed) { + throw new NotAcceptableException('Slide feed not set.'); + } - if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new \Exception('Resource not in feed resources'); - } + if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { + throw new NotAcceptableException('Resource not in feed resources'); + } - $token = $this->getToken($tenant, $interactive); + $token = $this->getToken($tenant, $interactiveSlideConfig); - $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); - $startFormatted = $start->format('c'); + $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); - $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); + // Get resources that are watched for availability. + $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); - // Get resources that are watched for availability. - $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); + // Add resource to watchedResources, if not in list. + if (!in_array($resource, $watchedResources)) { + $watchedResources[] = $resource; + } - // Add resource to watchedResources, if not in list. - if (!in_array($resource, $watchedResources)) { - $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); + $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); - $watchedResources[] = $resource; - $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); - } + $result = []; - $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); + // Refresh entries for all watched resources. + foreach ($watchedResources as $key => $watchResource) { + $schedule = $schedules[$watchResource] ?? null; - $result = []; + if (!isset($schedules[$watchResource])) { + unset($watchedResources[$key]); + } - // Refresh entries for all watched resources. - foreach ($watchedResources as $watchResource) { - $entry = $this->createEntry($watchResource, $schedules[$watchResource], $startFormatted, $start); + $entry = $this->createEntry($watchResource, $start, $schedule); - if ($watchResource == $resource) { - $result = $entry; - } else { - // Refresh cache entry for resources in watch list that are not handled in current request. - $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); - $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, - function (CacheItemInterface $item) use ($entry) { - $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + if ($watchResource == $resource) { + $result = $entry; + } else { + // Refresh cache entry for resources in watch list that are not handled in current request. + $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); + $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, + function (CacheItemInterface $item) use ($entry) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - return $entry; - } - ); + return $entry; + } + ); + } } - } - return $result; + $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); + $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); + + return $result; + } catch (\Throwable) { + // All errors should result in empty options. + return $this->createEntry($resource, $start); + } } ); } - private function createEntry(string $resource, array $schedules, string $startFormatted, \DateTime $start): array + private function createEntry(string $resource, \DateTime $start, ?array $schedules = null): array { + $startFormatted = $start->format('c'); + $entry = [ 'resource' => $resource, 'from' => $startFormatted, 'options' => [], ]; + if (null === $schedules) { + return $entry; + } + foreach (self::DURATIONS as $durationMinutes) { - $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + try { + $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + } catch (\Exception) { + continue; + } if ($this->intervalFree($schedules, $start, $startPlus)) { $entry['options'][] = [ @@ -253,9 +277,15 @@ private function createEntry(string $resource, array $schedules, string $startFo } /** + * @throws TooManyRequestsException + * @throws ConflictException + * @throws BadRequestException + * @throws InvalidArgumentException + * @throws NotAcceptableException + * @throws ForbiddenException * @throws \Throwable */ - private function quickBook(Slide $slide, InteractionSlideRequest $interactionRequest): array + private function quickBook(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = (string) $this->getValueFromInterval('resource', $interactionRequest); $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); @@ -273,38 +303,34 @@ function (CacheItemInterface $item) use ($now): \DateTime { ); if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { - throw new ServiceUnavailableHttpException(60); + throw new TooManyRequestsException('Service unavailable'); } - /** @var User|ScreenUser $activeUser */ - $activeUser = $this->security->getUser(); - $tenant = $activeUser->getActiveTenant(); - - $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); + $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - if (null === $interactive) { - throw new BadRequestHttpException('Interactive not found'); + if (null === $interactiveSlideConfig) { + throw new NotAcceptableException('InteractiveSlideConfig not found'); } // Optional limiting of available resources. - $this->checkPermission($interactive, $resource); + $this->checkPermission($interactiveSlideConfig, $resource); $feed = $slide->getFeed(); if (null === $feed) { - throw new BadRequestHttpException('Slide feed not set.'); + throw new NotAcceptableException('Slide feed not set.'); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new BadRequestHttpException('Resource not in feed resources'); + throw new NotAcceptableException('Resource not in feed resources'); } - $token = $this->getToken($tenant, $interactive); + $token = $this->getToken($tenant, $interactiveSlideConfig); - $configuration = $interactive->getConfiguration(); + $configuration = $interactiveSlideConfig->getConfiguration(); if (null === $configuration) { - throw new BadRequestHttpException('Interactive no configuration'); + throw new NotAcceptableException('InteractiveSlideConfig has no configuration'); } $username = $this->keyValueService->getValue($configuration['username']); @@ -315,7 +341,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { // Make sure interval is free. $busyIntervals = $this->getBusyIntervals($token, [$resource], $start, $startPlusDuration); if (count($busyIntervals[$resource]) > 0) { - throw new ConflictHttpException('Interval booked already'); + throw new ConflictException('Interval booked already'); } $requestBody = [ @@ -351,10 +377,13 @@ function (CacheItemInterface $item) use ($now): \DateTime { $status = $response->getStatusCode(); - return ['status' => $status, 'interval' => [ - 'from' => $start->format('c'), - 'to' => $startPlusDuration->format('c'), - ]]; + return [ + 'status' => $status, + 'interval' => [ + 'from' => $start->format('c'), + 'to' => $startPlusDuration->format('c'), + ], + ]; } /** @@ -362,7 +391,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { * * @throws \Throwable */ - public function getBusyIntervals(string $token, array $resources, \DateTime $startTime, \DateTime $endTime): array + private function getBusyIntervals(string $token, array $resources, \DateTime $startTime, \DateTime $endTime): array { $body = [ 'schedules' => $resources, @@ -389,9 +418,16 @@ public function getBusyIntervals(string $token, array $resources, \DateTime $sta $result = []; foreach ($scheduleData as $schedule) { - $scheduleId = $schedule['scheduleId']; + $scheduleId = $schedule['scheduleId'] ?? null; + $scheduleItems = $schedule['scheduleItems'] ?? null; + + if (null === $scheduleId || null === $scheduleItems) { + continue; + } + $result[$scheduleId] = []; - foreach ($schedule['scheduleItems'] as $scheduleItem) { + + foreach ($scheduleItems as $scheduleItem) { $eventStartArray = $scheduleItem['start']; $eventEndArray = $scheduleItem['end']; @@ -419,18 +455,21 @@ public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): b return true; } + /** + * @throws BadRequestException + */ private function getValueFromInterval(string $key, InteractionSlideRequest $interactionRequest): string|int { $interval = $interactionRequest->data['interval'] ?? null; if (null === $interval) { - throw new BadRequestHttpException('interval not set.'); + throw new BadRequestException('interval not set.'); } $value = $interval[$key] ?? null; if (null === $value) { - throw new BadRequestHttpException("interval.'.$key.' not set."); + throw new BadRequestException("interval.'.$key.' not set."); } return $value; @@ -445,37 +484,47 @@ private function getHeaders(string $token): array ]; } - private function checkPermission(InteractiveSlide $interactive, string $resource): void + /** + * @throws NotAcceptableException + * @throws ForbiddenException + * @throws InvalidArgumentException + */ + private function checkPermission(InteractiveSlideConfig $interactive, string $resource): void { $configuration = $interactive->getConfiguration(); - // Optional limiting of available resources. + + // Will only limit access to resources if list is set up. if (null !== $configuration && !empty($configuration['resourceEndpoint'])) { $allowedResources = $this->getAllowedResources($interactive); if (!in_array($resource, $allowedResources)) { - throw new \Exception('Not allowed'); + throw new ForbiddenException('Not allowed'); } } } - private function getAllowedResources(InteractiveSlide $interactive): array + /** + * @throws NotAcceptableException + * @throws InvalidArgumentException + */ + private function getAllowedResources(InteractiveSlideConfig $interactive): array { - return $this->interactiveSlideCache->get(self::CACHE_ALLOWED_RESOURCES_PREFIX.$interactive->getId(), function (CacheItemInterface $item) use ($interactive) { - $item->expiresAfter(60 * 60); + $configuration = $interactive->getConfiguration(); - $configuration = $interactive->getConfiguration(); + $key = $configuration['resourceEndpoint'] ?? null; - $key = $configuration['resourceEndpoint'] ?? null; + if (null === $key) { + throw new NotAcceptableException('resourceEndpoint not set'); + } - if (null === $key) { - throw new \Exception('resourceEndpoint not set'); - } + $resourceEndpoint = $this->keyValueService->getValue($key); - $resourceEndpoint = $this->keyValueService->getValue($key); + if (null === $resourceEndpoint) { + throw new NotAcceptableException('resourceEndpoint value not set'); + } - if (null === $resourceEndpoint) { - throw new \Exception('resourceEndpoint value not set'); - } + return $this->interactiveSlideCache->get(self::CACHE_ALLOWED_RESOURCES_PREFIX.$interactive->getId(), function (CacheItemInterface $item) use ($resourceEndpoint) { + $item->expiresAfter(60 * 60); $response = $this->client->request('GET', $resourceEndpoint); $content = $response->toArray(); diff --git a/src/InteractiveSlide/InteractiveSlideInterface.php b/src/InteractiveSlide/InteractiveSlideInterface.php index 5ba3343d7..df957842e 100644 --- a/src/InteractiveSlide/InteractiveSlideInterface.php +++ b/src/InteractiveSlide/InteractiveSlideInterface.php @@ -4,9 +4,12 @@ namespace App\InteractiveSlide; +use App\Entity\Tenant; use App\Entity\Tenant\Slide; -use App\Exceptions\InteractiveSlideException; -use Symfony\Component\Security\Core\User\UserInterface; +use App\Exceptions\BadRequestException; +use App\Exceptions\ConflictException; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\TooManyRequestsException; interface InteractiveSlideInterface { @@ -15,7 +18,10 @@ public function getConfigOptions(): array; /** * Perform the given InteractionRequest with the given Slide. * - * @throws InteractiveSlideException + * @throws ConflictException + * @throws BadRequestException + * @throws NotAcceptableException + * @throws TooManyRequestsException */ - public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array; + public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array; } diff --git a/src/Repository/InteractiveSlideRepository.php b/src/Repository/InteractiveSlideRepository.php index a9d52ab90..5e263f4bf 100644 --- a/src/Repository/InteractiveSlideRepository.php +++ b/src/Repository/InteractiveSlideRepository.php @@ -4,22 +4,22 @@ namespace App\Repository; -use App\Entity\Tenant\InteractiveSlide; +use App\Entity\Tenant\InteractiveSlideConfig; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository * - * @method InteractiveSlide|null find($id, $lockMode = null, $lockVersion = null) - * @method InteractiveSlide|null findOneBy(array $criteria, array $orderBy = null) - * @method InteractiveSlide[] findAll() - * @method InteractiveSlide[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @method InteractiveSlideConfig|null find($id, $lockMode = null, $lockVersion = null) + * @method InteractiveSlideConfig|null findOneBy(array $criteria, array $orderBy = null) + * @method InteractiveSlideConfig[] findAll() + * @method InteractiveSlideConfig[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class InteractiveSlideRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, InteractiveSlide::class); + parent::__construct($registry, InteractiveSlideConfig::class); } } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index e4c8a6804..21460e002 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -4,17 +4,17 @@ namespace App\Service; -use App\Entity\ScreenUser; use App\Entity\Tenant; -use App\Entity\Tenant\InteractiveSlide; +use App\Entity\Tenant\InteractiveSlideConfig; use App\Entity\Tenant\Slide; -use App\Entity\User; -use App\Exceptions\InteractiveSlideException; +use App\Exceptions\BadRequestException; +use App\Exceptions\ConflictException; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\TooManyRequestsException; use App\InteractiveSlide\InteractionSlideRequest; use App\InteractiveSlide\InteractiveSlideInterface; use App\Repository\InteractiveSlideRepository; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\Security\Core\User\UserInterface; /** * Service for handling Slide interactions. @@ -33,7 +33,7 @@ public function __construct( * * @param array $requestBody the request body from the http request * - * @throws InteractiveSlideException + * @throws BadRequestException */ public function parseRequestBody(array $requestBody): InteractionSlideRequest { @@ -42,7 +42,7 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest $data = $requestBody['data'] ?? null; if (null === $implementationClass || null === $action || null === $data) { - throw new InteractiveSlideException('implementationClass, action and/or data not set.'); + throw new BadRequestException('implementationClass, action and/or data not set.'); } return new InteractionSlideRequest($implementationClass, $action, $data); @@ -51,27 +51,24 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest /** * Perform an action for an interactive slide. * - * @throws InteractiveSlideException + * @throws ConflictException + * @throws BadRequestException + * @throws NotAcceptableException + * @throws TooManyRequestsException */ - public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array + public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { - if (!$user instanceof ScreenUser && !$user instanceof User) { - throw new InteractiveSlideException('User is not supported'); - } - - $tenant = $user->getActiveTenant(); - $implementationClass = $interactionRequest->implementationClass; - $interactive = $this->getInteractiveSlide($tenant, $implementationClass); + $interactive = $this->getInteractiveSlideConfig($tenant, $implementationClass); if (null === $interactive) { - throw new InteractiveSlideException('Interactive slide not found'); + throw new NotAcceptableException('Interactive slide config not found'); } $interactiveImplementation = $this->getImplementation($interactive->getImplementationClass()); - return $interactiveImplementation->performAction($user, $slide, $interactionRequest); + return $interactiveImplementation->performAction($tenant, $slide, $interactionRequest); } /** @@ -91,7 +88,7 @@ public function getConfigurables(): array /** * Find the implementation class. * - * @throws InteractiveSlideException + * @throws BadRequestException */ public function getImplementation(?string $implementationClass): InteractiveSlideInterface { @@ -99,7 +96,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid $interactiveImplementations = array_filter($asArray, fn ($implementation) => $implementation::class === $implementationClass); if (0 === count($interactiveImplementations)) { - throw new InteractiveSlideException('Interactive implementation class not found'); + throw new BadRequestException('Interactive implementation class not found'); } return $interactiveImplementations[0]; @@ -108,7 +105,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid /** * Get the interactive slide. */ - public function getInteractiveSlide(Tenant $tenant, string $implementationClass): ?InteractiveSlide + public function getInteractiveSlideConfig(Tenant $tenant, string $implementationClass): ?InteractiveSlideConfig { return $this->interactiveSlideRepository->findOneBy([ 'implementationClass' => $implementationClass, @@ -127,7 +124,7 @@ public function saveConfiguration(Tenant $tenant, string $implementationClass, a ]); if (null === $entry) { - $entry = new InteractiveSlide(); + $entry = new InteractiveSlideConfig(); $entry->setTenant($tenant); $entry->setImplementationClass($implementationClass); diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index 5f5fee8b4..9ce070292 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -5,7 +5,8 @@ namespace App\Tests\Service; use App\Entity\Tenant\Slide; -use App\Exceptions\InteractiveSlideException; +use App\Exceptions\BadRequestException; +use App\Exceptions\NotAcceptableException; use App\InteractiveSlide\InstantBook; use App\InteractiveSlide\InteractionSlideRequest; use App\Repository\UserRepository; @@ -41,7 +42,7 @@ public function testParseRequestBody(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); - $this->expectException(InteractiveSlideException::class); + $this->expectException(BadRequestException::class); $interactiveService->parseRequestBody([ 'test' => 'test', @@ -73,19 +74,19 @@ public function testPerformAction(): void 'data' => [], ]); - $this->expectException(InteractiveSlideException::class); - $this->expectExceptionMessage('Interactive slide not found'); + $this->expectException(NotAcceptableException::class); + $this->expectExceptionMessage('Interactive slide config not found'); $tenant = $user->getActiveTenant(); - $interactiveService->performAction($user, $slide, $interactionRequest); + $interactiveService->performAction($tenant, $slide, $interactionRequest); $interactiveService->saveConfiguration($tenant, InstantBook::class, []); - $this->expectException(InteractiveSlideException::class); + $this->expectException(NotAcceptableException::class); $this->expectExceptionMessage('Action not allowed'); - $interactiveService->performAction($user, $slide, $interactionRequest); + $interactiveService->performAction($tenant, $slide, $interactionRequest); } public function testGetConfigurables(): void