diff --git a/.gitignore b/.gitignore index 6449fe867..045002927 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,6 @@ src/prisma/migrations .rest -.vscode \ No newline at end of file +.vscode + +.claude \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1f33954a8..91ae66bec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,8 @@ RUN mkdir -p usr/src/app/store/images COPY public public COPY next-env.d.t[s] next.config.ts tsconfig.json ./ +COPY standard_store standard_store + ############################################################ FROM base AS prod @@ -52,5 +54,6 @@ CMD ["npm", "run", "test"] FROM base AS dev ENV NODE_ENV=development +# src is expected to be binded in dev CMD ["npm", "run", "dev-seed"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c345bbd2e..27f080c3f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,6 +12,7 @@ services: - ${PROJECT_ROOT:-.}/logs:/usr/src/app/logs - devstore:/usr/src/app/store - dotnext:/usr/src/app/.next + - dobbelOmegaManifest:/usr/src/app/dobbelOmegaManifest db: extends: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b049f4671..626b9960d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -8,6 +8,7 @@ services: volumes: - store:/usr/src/app/store - logs:/usr/src/app/logs + - dobbelOmegaManifest:/usr/src/app/dobbelOmegaManifest deploy: replicas: 2 restart: always diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 9499208c6..d1928137d 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -9,6 +9,7 @@ services: environment: DOMAIN: omega.ntnu.no MAIL_DOMAIN: sanctus.omega.ntnu.no + CI: ${CI:-} volumes: - ${PROJECT_ROOT:-.}/src:/usr/src/app/src - ${PROJECT_ROOT:-.}/tests:/usr/src/app/tests diff --git a/jest.config.ts b/jest.config.ts index 057246fcf..88c595817 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,6 +12,10 @@ const config: Config = { collectCoverage: true, collectCoverageFrom: ['src/**/*.{ts,tsx}'], coverageReporters: ['text-summary'], + // Each suite's beforeAll re-seeds all standard images through sharp/avif, which is CPU-heavy. + // Running many suites' seeds concurrently starves CI's limited cores and blows past the 30s + // beforeAll timeout in tests/setup.ts, even though a single seed() run only takes a few seconds. + maxWorkers: process.env.CI ? 2 : undefined, moduleNameMapper: { // This is needed becaue jest doesn't handle the this code is inside node_modules '^@/prisma-dobbel-omega/(.*)$': '/node_modules/.prisma-dobbel-omega/$1', diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index ef1e482e6..6737341b2 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,20 +1,12 @@ import styles from './layout.module.scss' -import { ServerSession } from '@/auth/session/ServerSession' -import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' -import { frontpageAuth } from '@/services/frontpage/auth' +import StandardImageServer from '@/components/Image/StandardImageServer' import React from 'react' type PropTypes = { children: React.ReactNode } -export default async function AuthLayout({ children }: PropTypes) { - const session = await ServerSession.fromNextAuth() - const canEditAuthIcon = frontpageAuth.updateSpecialCmsImage.dynamicFields({}).auth( - session - ).toJsObject() - +export default function AuthLayout({ children }: PropTypes) { return (
@@ -22,14 +14,10 @@ export default async function AuthLayout({ children }: PropTypes) { {children}
-
diff --git a/src/app/(auth)/register/RegistrationForm.tsx b/src/app/(auth)/register/RegistrationForm.tsx index cd5bb45f3..ec1e55467 100644 --- a/src/app/(auth)/register/RegistrationForm.tsx +++ b/src/app/(auth)/register/RegistrationForm.tsx @@ -6,10 +6,10 @@ import Checkbox from '@/components/UI/Checkbox' import { SelectString } from '@/components/UI/Select' import TextInput from '@/components/UI/TextInput' import { sexConfig } from '@/services/users/constants' +import { SEX, type User } from '@/prisma-generated-pn-types' import { signIn } from 'next-auth/react' import { useSearchParams } from 'next/navigation' import { useState } from 'react' -import { SEX, type User } from '@/prisma-generated-pn-types' export default function RegistrationForm({ userData, diff --git a/src/app/(frontpage)/LoggedIn.tsx b/src/app/(frontpage)/LoggedIn.tsx index 243fe2bc4..fa4e51e0b 100644 --- a/src/app/(frontpage)/LoggedIn.tsx +++ b/src/app/(frontpage)/LoggedIn.tsx @@ -5,13 +5,11 @@ import EventCard from '@/app/_components/Event/EventCard' import JobAd from '@/app/career/jobads/JobAd' import NewsCard from '@/app/news/NewsCard' import SocialIcons from '@/components/SocialIcons/SocialIcons' -import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' +import StandardImageServer from '@/components/Image/StandardImageServer' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { readNewsCurrentAction } from '@/services/news/actions' import { readActiveJobAdsAction } from '@/services/career/jobAds/actions' import { readCurrentEventsAction } from '@/services/events/actions' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' -import { frontpageAuth } from '@/services/frontpage/auth' import { eventAuth } from '@/services/events/auth' import { ServerSession } from '@/auth/session/ServerSession' import { faAngleDown } from '@fortawesome/free-solid-svg-icons' @@ -28,9 +26,6 @@ export default async function LoggedInLandingPage() { .slice(0, MAX_NUMBER_OF_ELEMENTS) const session = await ServerSession.fromNextAuth() - const canEditFrontpageCmsImage = frontpageAuth.updateSpecialCmsImage.dynamicFields({}).auth( - session - ).toJsObject() const canEditEventCmsImage = eventAuth.updateCmsCoverImage.dynamicFields({}).auth( session @@ -41,12 +36,9 @@ export default async function LoggedInLandingPage() {
-
@@ -74,7 +66,7 @@ export default async function LoggedInLandingPage() { ))} - + Her kan man kanskje vise noen bilder ellerno
diff --git a/src/app/(frontpage)/LoggedOut.tsx b/src/app/(frontpage)/LoggedOut.tsx index ab4380364..6a5aecce5 100644 --- a/src/app/(frontpage)/LoggedOut.tsx +++ b/src/app/(frontpage)/LoggedOut.tsx @@ -3,9 +3,8 @@ import styles from './page.module.scss' import InfoBubbles from './InfoBubbles' import MazeMap from '@/components/MazeMap/MazeMap' import SocialIcons from '@/components/SocialIcons/SocialIcons' -import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' +import StandardImageServer from '@/components/Image/StandardImageServer' import YouTube from '@/components/YouTube/YouTube' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' import { ServerSession } from '@/auth/session/ServerSession' import { frontpageAuth } from '@/services/frontpage/auth' import { faAngleDown } from '@fortawesome/free-solid-svg-icons' @@ -26,12 +25,9 @@ export default async function LoggedOutLandingPage() {
- Logg inn diff --git a/src/app/(frontpage)/Section.tsx b/src/app/(frontpage)/Section.tsx index 9c73d6de1..d4d4d1158 100644 --- a/src/app/(frontpage)/Section.tsx +++ b/src/app/(frontpage)/Section.tsx @@ -7,7 +7,6 @@ import { updateSpecialCmsImageFrontpage, updateSpecialCmsParagraphFrontpageSection } from '@/services/frontpage/actions' -import React from 'react' import Link from 'next/link' import type { SpecialCmsImage as SpecialCmsImageT, diff --git a/src/app/_components/Cms/AddParts.tsx b/src/app/_components/Cms/AddParts.tsx index 192ea112e..c326859e7 100644 --- a/src/app/_components/Cms/AddParts.tsx +++ b/src/app/_components/Cms/AddParts.tsx @@ -51,7 +51,7 @@ export default function AddParts({ onClick(part.part)} - color="secondary" + color="primary" > {part.text} diff --git a/src/app/_components/Cms/CmsImage/ChangeImage.tsx b/src/app/_components/Cms/CmsImage/ChangeImage.tsx index 94a3ebe8b..869585406 100644 --- a/src/app/_components/Cms/CmsImage/ChangeImage.tsx +++ b/src/app/_components/Cms/CmsImage/ChangeImage.tsx @@ -2,26 +2,29 @@ import styles from './ChangeImage.module.scss' import ChangeImageForm from './ChangeImageForm' import Image from '@/components/Image/Image' -import { ImageSelectionContext } from '@/contexts/ImageSelection' import Form from '@/components/Form/Form' import { configureAction } from '@/services/configureAction' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTurnUp } from '@fortawesome/free-solid-svg-icons' -import React, { useContext, useEffect, useEffectEvent, useState } from 'react' +import React, { useEffect, useEffectEvent, useState } from 'react' import type { ImageSize, Image as ImageT } from '@/prisma-generated-pn-types' import type { UpdateCmsImageAction } from '@/cms/images/types' type PropTypes = { - currentImage: ImageT, + currentImage: ImageT | null, + selectedImage: ImageT | null, cmsImageId: number, currentImageSize: ImageSize, updateCmsImageAction: UpdateCmsImageAction } -export default function ChangeImage({ currentImage, cmsImageId, currentImageSize, updateCmsImageAction }: PropTypes) { - const selectedContext = useContext(ImageSelectionContext) - if (!selectedContext) throw new Error('ImageSelectionContext required to use ChangeImage') - +export default function ChangeImage({ + currentImage, + selectedImage, + cmsImageId, + currentImageSize, + updateCmsImageAction, +}: PropTypes) { //What is the next option in quality. The image always cycles up. const [changeToSize, setChangeToSize] = useState(currentImageSize) @@ -46,47 +49,64 @@ export default function ChangeImage({ currentImage, cmsImageId, currentImageSize handleChangeSize() }, [currentImageSize]) + // A selection only counts as "new" if it differs from the current image - or there is no + // current image yet, in which case this is the user's first choice for this slot. + const hasNewSelection = selectedImage !== null && selectedImage.id !== currentImage?.id + const displayImage = selectedImage ?? currentImage + + const renderSubmitControls = () => { + if (hasNewSelection && selectedImage) { + return ( + + ) + } + if (!currentImage) { + return

Velg et bilde for å legge det til

+ } + return ( +
+

Resolution: {currentImageSize.toLowerCase()}

+
+
+ ) + } + return (
{ - selectedContext.selectedImage && selectedContext.selectedImage.id !== currentImage.id ? ( + currentImage && selectedImage && hasNewSelection ? (
- +
) : (
- -
- ) - } - image name: {currentImage.name} - { - selectedContext.selectedImage && selectedContext.selectedImage.id !== currentImage.id ? ( - - ) : ( -
-

Resolution: {currentImageSize.toLowerCase()}

- + {displayImage ? :

Ingen bilde valgt enda

}
) } + {displayImage ? `image name: ${displayImage.name}` : 'ingen bilde valgt enda'} + {renderSubmitControls()}
) } diff --git a/src/app/_components/Cms/CmsImage/ChangeImageForm.tsx b/src/app/_components/Cms/CmsImage/ChangeImageForm.tsx index 43b4cde89..4702d4ca8 100644 --- a/src/app/_components/Cms/CmsImage/ChangeImageForm.tsx +++ b/src/app/_components/Cms/CmsImage/ChangeImageForm.tsx @@ -1,21 +1,17 @@ 'use client' import Form from '@/components/Form/Form' -import { ImageSelectionContext } from '@/contexts/ImageSelection' import { configureAction } from '@/services/configureAction' -import { useContext } from 'react' +import type { Image as ImageT } from '@/prisma-generated-pn-types' import type { UpdateCmsImageAction } from '@/cms/images/types' type PropTypes = { cmsImageId: number + selectedImage: ImageT className?: string updateCmsImageAction: UpdateCmsImageAction } -export default function ChangeImageForm({ cmsImageId, className, updateCmsImageAction }: PropTypes) { - const selection = useContext(ImageSelectionContext) - - if (!selection?.selectedImage) throw new Error('ImageSelectionContext required to use ChangeImage') - +export default function ChangeImageForm({ cmsImageId, selectedImage, className, updateCmsImageAction }: PropTypes) { return ( - image = defaultRes.data - } - return (
{!disableEditor && } -
{children}
+ {cmsImage.image ? ( + + ) : ( + + )}
) } diff --git a/src/app/_components/Cms/CmsImage/CmsImageClient.tsx b/src/app/_components/Cms/CmsImage/CmsImageClient.tsx deleted file mode 100644 index 8ce788dc0..000000000 --- a/src/app/_components/Cms/CmsImage/CmsImageClient.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' -import CmsImageEditor from './CmsImageEditor' -import styles from './CmsImage.module.scss' -import { fallbackImage } from './CmsImage' -import Image, { SrcImage } from '@/components/Image/Image' -import { readSpecialImageAction } from '@/services/images/actions' -import { useState, useEffect } from 'react' -import type { PropTypes } from './CmsImage' -import type { Image as ImageT } from '@/prisma-generated-pn-types' - -/** - * WARNING: This component is only meant for the client - * A function to display a cms image with image relation. - * If the cms image does not have a image it will use the default image - * By calling on special image DEFAULT_IMAGE - * @param cmsImage - the cms image to display with image relation - * @param children - the children to display besides image - * @returns - */ -export default function CmsImageClient({ - cmsImage, - updateCmsImageAction, - canEdit, - children, - className = '', - classNameImage, - disableEditor = false, - ...props -}: PropTypes) { - const [image, setCmsImage] = useState(cmsImage.image || null) - const [fallback, setFallback] = useState(false) - - useEffect(() => { - if (image) return - readSpecialImageAction({ params: { special: 'DEFAULT_IMAGE' } }).then(res => { - if (!res.success) return setFallback(true) - return setCmsImage(res.data) - }) - }, [readSpecialImageAction]) - - return ( -
- {(image && !disableEditor) && } -
{children}
- {image && - - } - {fallback && } -
- ) -} diff --git a/src/app/_components/Cms/CmsImage/CmsImageEditor.module.scss b/src/app/_components/Cms/CmsImage/CmsImageEditor.module.scss index 6ed79d689..e828cef84 100644 --- a/src/app/_components/Cms/CmsImage/CmsImageEditor.module.scss +++ b/src/app/_components/Cms/CmsImage/CmsImageEditor.module.scss @@ -75,25 +75,48 @@ $collectionSize: calc(14vmin + 70px); grid-row: 2 / 3; @include ohma.layer(); display: flex; + flex-direction: column; max-width: 100%; - overflow-x: scroll; - min-height: calc($collectionSize + 5vmin); - align-items: center; padding: 2*ohma.$gap; border-radius: #{ohma.$rounding}; + > .collectionNote { + display: flex; + align-items: flex-start; + gap: ohma.$gap; + margin-bottom: ohma.$gap; + max-width: 90ch; + font-size: ohma.$fonts-s; + color: ohma.$colors-text-muted; + > svg { + margin-top: 0.25em; + flex-shrink: 0; + } + } + + > .collections { + display: flex; + max-width: 100%; + overflow-x: scroll; + min-height: calc($collectionSize + 5vmin); + align-items: center; + } + .collection { position: relative; + flex-shrink: 0; margin: 0 .2em; + width: $collectionSize; + height: $collectionSize; .collectionCard { - width: $collectionSize; - height: $collectionSize; + width: 100%; + height: 100%; margin: 0; z-index: 1; } .selector { - width: $collectionSize; - height: $collectionSize; + width: 100%; + height: 100%; z-index: 2; position: absolute; background-color: transparent; @@ -119,7 +142,7 @@ $collectionSize: calc(14vmin + 70px); } } } - > *:last-child:not(.collection) { + > .collections > *:last-child:not(.collection) { min-width: $collectionSize; height: $collectionSize; border: ohma.$colors-black 1px solid; diff --git a/src/app/_components/Cms/CmsImage/CmsImageEditor.tsx b/src/app/_components/Cms/CmsImage/CmsImageEditor.tsx index 07ef86827..1906e813c 100644 --- a/src/app/_components/Cms/CmsImage/CmsImageEditor.tsx +++ b/src/app/_components/Cms/CmsImage/CmsImageEditor.tsx @@ -6,39 +6,108 @@ import EditOverlay from '@/cms/EditOverlay' import PopUp from '@/components/PopUp/PopUp' import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' import CollectionCard from '@/components/Image/Collection/CollectionCard' -import ImageList from '@/components/Image/ImageList/ImageList' -import { ImageCollectionPagingProvider, ImageCollectionPagingContext } from '@/contexts/paging/ImageCollectionPaging' +import ImagePanel from '@/components/Image/ImagePanel/ImagePanel' +import { + DynamicImageCollectionPagingProvider, + DynamicImageCollectionPagingContext +} from '@/contexts/paging/DynamicImageCollectionPaging' import useEditMode from '@/hooks/useEditMode' -import { ImagePagingProvider } from '@/contexts/paging/ImagePaging' import PopUpProvider from '@/contexts/PopUp' -import ImageSelectionProvider from '@/contexts/ImageSelection' -import { useState } from 'react' +import { useSpecialCollections } from '@/contexts/ClientData' +import { specialImagePanels } from '@/services/images/specialPanels/constants' +import { readImagesPageInDynamicCollectionAction } from '@/services/images/dynamic/actions' +import { useMemo, useState } from 'react' import Link from 'next/link' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faInfo } from '@fortawesome/free-solid-svg-icons' +import type { ReadPageOfImagesInCollectionAction } from '@/components/Image/ImagePanel/ImagePanel' +import type { ExpandedImageCollection } from '@/services/images/subservice/types' import type { CmsImage, Image as ImageT } from '@/prisma-generated-pn-types' import type { UpdateCmsImageAction } from '@/cms/images/types' import type { AuthResultTypeAny } from '@/auth/authorizer/AuthResult' +const collectionPagingDetails = { showOnlyCollectionsSessionAdministrates: true } as const +const imagePageSize = 30 + type PropTypes = { cmsImage: CmsImage & { - image: ImageT + image: ImageT | null }, updateCmsImageAction: UpdateCmsImageAction canEdit: AuthResultTypeAny } /** - * A component to edit a cms image + * A component to edit a cms image. If cmsImage.image is null the user is choosing an image for + * the slot for the first time - no collection is pre-selected until they pick one. * @param cmsImage - the cms image to edit * @returns */ export default function CmsImageEditor({ cmsImage, updateCmsImageAction, canEdit }: PropTypes) { const editable = useEditMode({ authResult: canEdit }) - const [currentCollectionId, setCurrentCollectionId] = useState(cmsImage.image.collectionId) + const [currentCollectionId, setCurrentCollectionId] = useState( + cmsImage.image?.collectionId ?? null + ) + const [selectedImage, setSelectedImage] = useState(cmsImage.image) + + const specialCollectionsResult = useSpecialCollections() + + // The pager for the chosen collection, one stable identity per collection - the panel resets + // and refetches when it changes. Special collections cannot be paged through the dynamic + // action (it refuses them by ownership), so ids matching a special collection get that + // service's own pager instead. Until the special collections have loaded such an id would + // briefly resolve to the dynamic pager and error - the recompute on load corrects it. + const readPageOfImagesInCollectionAction = useMemo( + (): ReadPageOfImagesInCollectionAction | null => { + if (currentCollectionId === null) return null + const specialCollections = specialCollectionsResult.status === 'success' + ? specialCollectionsResult.specialCollections + : [] + const special = specialCollections.find( + collection => collection.id === currentCollectionId + )?.special + if (special) { + const readPageAction = specialImagePanels[special].readPageOfImagesInCollectionAction + return page => readPageAction({ params: { paging: { page } } }) + } + return page => readImagesPageInDynamicCollectionAction({ + params: { + paging: { page }, + collectionId: currentCollectionId, + }, + }) + }, + [currentCollectionId, specialCollectionsResult] + ) const isCollectionActive = (collection: { id: number }) => ( collection.id === currentCollectionId ? styles.selected : '' ) + const renderCollection = (collection: ExpandedImageCollection) => ( +
+
+ ) + + const renderSpecialCollections = () => { + if (specialCollectionsResult.status === 'loading') return Laster inn... + if (specialCollectionsResult.status === 'error') return

Noe gikk galt

+ return specialCollectionsResult.specialCollections.map(renderCollection) + } + + const hasNewSelection = selectedImage !== null && selectedImage.id !== cmsImage.image?.id + if (!editable) return null return ( } showButtonClass={styles.showBtn} > - - - -
-
-
-

Edit image link

-
-

name: {cmsImage.name}

- id: {cmsImage.id} -
-
- + +
+
+
+

Edit image link

+
+

name: {cmsImage.name}

+ id: {cmsImage.id}
- + +
+ {hasNewSelection && selectedImage && ( + + )} +
+ {readPageOfImagesInCollectionAction ? ( + -
- -
-
- - ( -
-
- )} - /> -
-
- - Go to images - + ) : ( +

Velg en samling for å se bildene i den

+ )} +
+
+

+ + + Du ser bare bildesamlingene du administrerer. Merk at bildet du + velger blir synlig for alle som kan se siden det brukes på - også + for de som ikke har tilgang til samlingen bildet ligger i. + +

+
+ {renderSpecialCollections()} + + +
- - - +
+ + Gå til bilder + +
+ ) } diff --git a/src/app/_components/Cms/CmsImage/SpecialCmsImage.tsx b/src/app/_components/Cms/CmsImage/SpecialCmsImage.tsx index 62c7911a3..b191827b6 100644 --- a/src/app/_components/Cms/CmsImage/SpecialCmsImage.tsx +++ b/src/app/_components/Cms/CmsImage/SpecialCmsImage.tsx @@ -9,7 +9,7 @@ export type PropTypes = Omit & { readSpecialCmsImageAction: ReadSpecialCmsImageAction } /** - * WARNING: This component is only meant for the server - use SpecialCmsImageClient for the client + * WARNING: This component is only meant for the server * A component that fetches a special cms image and displays it * @param special - the special cms image to display * @returns diff --git a/src/app/_components/Cms/CmsImage/SpecialCmsImageClient.tsx b/src/app/_components/Cms/CmsImage/SpecialCmsImageClient.tsx deleted file mode 100644 index 17323252f..000000000 --- a/src/app/_components/Cms/CmsImage/SpecialCmsImageClient.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client' -import CmsImageClient from './CmsImageClient' -import useActionCall from '@/hooks/useActionCall' -import { configureAction } from '@/services/configureAction' -import { useCallback } from 'react' -import type { PropTypes } from './SpecialCmsImage' - -/** - * WARNING: This component is only meant for the client - use SpecialCmsImageClient for the server - * A component that fetches a special cms image and displays it - * @param special - the special cms image to display - * @returns - */ -export default function SpecialCmsImageClient({ - special, - updateCmsImageAction, - readSpecialCmsImageAction, - ...props -}: PropTypes) { - const action = useCallback(() => configureAction( - readSpecialCmsImageAction, - { params: { special } } - )(), [readSpecialCmsImageAction, special]) - const { data: cmsImage, error } = useActionCall(action) - if (error) throw new Error(`No special cms image found for ${special}`) - - return ( - cmsImage && ( - - ) - ) -} diff --git a/src/app/_components/Cms/PublicArticle/PublicArticle.tsx b/src/app/_components/Cms/PublicArticle/PublicArticle.tsx index a9ac75005..a2b3d1f6a 100644 --- a/src/app/_components/Cms/PublicArticle/PublicArticle.tsx +++ b/src/app/_components/Cms/PublicArticle/PublicArticle.tsx @@ -12,7 +12,6 @@ import { updatePublicArticleSectionsAddPartAction, updatePublicArticleSectionsRemovePartAction } from '@/services/publicArticles/actions' -import React from 'react' function PublicArticle(props: Omit) { return ( diff --git a/src/app/_components/Company/Company.tsx b/src/app/_components/Company/Company.tsx index d4a793f1a..5abc3b5c5 100644 --- a/src/app/_components/Company/Company.tsx +++ b/src/app/_components/Company/Company.tsx @@ -3,7 +3,6 @@ import SelectCompany from './SelectCompany' import { SettingsHeaderItemPopUp } from '@/components/HeaderItems/HeaderItemPopUp' import TextInput from '@/UI/TextInput' import CmsImage from '@/cms/CmsImage/CmsImage' -import CmsImageClient from '@/cms/CmsImage/CmsImageClient' import Form from '@/components/Form/Form' import { companyAuth } from '@/services/career/companies/auth' import { @@ -17,7 +16,6 @@ import type { SessionMaybeUser } from '@/auth/session/Session' type PropTypes = { company: CompanyExpanded, - asClient: boolean, session: SessionMaybeUser, disableEdit?: boolean, logoWidth?: number, @@ -27,7 +25,6 @@ type PropTypes = { /** * * @param company - The company to display - * @param asClient - If the component is rendered clinet side (uses CmsImageClient) * @param session - The session of the user * @param disableEdit - If the edit buttons should be disabled even if the user has the rights * @param logoWidth - The width of the logo @@ -36,7 +33,6 @@ type PropTypes = { */ export default function Company({ company, - asClient, session, disableEdit = false, logoWidth = 300, @@ -51,25 +47,14 @@ export default function Company({ ) return (
- {asClient ? - : - - } +

{company.name}

{company.description}

diff --git a/src/app/_components/Company/CompanyList.tsx b/src/app/_components/Company/CompanyList.tsx index 1b560aa6d..8499c34df 100644 --- a/src/app/_components/Company/CompanyList.tsx +++ b/src/app/_components/Company/CompanyList.tsx @@ -21,7 +21,7 @@ export default function CompanyList({ serverRenderedData, disableEditing }: Prop {serverRenderedData} companyListRenderer({ asClient: true, session: session.session, disableEditing })(data)} + renderer={data => companyListRenderer({ session: session.session, disableEditing })(data)} />
) diff --git a/src/app/_components/Company/CompanyListRenderer.tsx b/src/app/_components/Company/CompanyListRenderer.tsx index 7096aef7b..f628f452e 100644 --- a/src/app/_components/Company/CompanyListRenderer.tsx +++ b/src/app/_components/Company/CompanyListRenderer.tsx @@ -4,18 +4,15 @@ import type { CompanyExpanded } from '@/services/career/companies/types' /** * Used to render schools server side and client side in consistent way - * @param asClient - If the company is rendered as a client * @param session - The session of the user used to determine if the user is an admin of the company * @returns A function that takes a company and returns a Company component */ export const companyListRenderer = ({ - asClient, session, disableEditing = false }: { - asClient: boolean, session: SessionMaybeUser, disableEditing?: boolean // eslint-disable-next-line react/display-name }) => (company: CompanyExpanded) => - + diff --git a/src/app/_components/Flair/Flair.module.scss b/src/app/_components/Flair/Flair.module.scss new file mode 100644 index 000000000..ad087cd36 --- /dev/null +++ b/src/app/_components/Flair/Flair.module.scss @@ -0,0 +1,5 @@ +.Flair { + display: inline-flex; + align-items: center; + gap: 0.5em; +} diff --git a/src/app/_components/Flair/Flair.tsx b/src/app/_components/Flair/Flair.tsx index 083eca5e6..9dd9e2080 100644 --- a/src/app/_components/Flair/Flair.tsx +++ b/src/app/_components/Flair/Flair.tsx @@ -1,56 +1,16 @@ -import CmsImage from '@/cms/CmsImage/CmsImage' -import { flairAuth } from '@/services/flairs/auth' -import { updateFlairCmsImageAction } from '@/services/flairs/actions' -import { configureAction } from '@/services/configureAction' -import CmsImageClient from '@/cms/CmsImage/CmsImageClient' -import { Session, type SessionMaybeUser } from '@/auth/session/Session' +import styles from './Flair.module.scss' +import Image from '@/components/Image/Image' import type { FlairWithImage } from '@/services/flairs/types' type PropTypes = { flair: FlairWithImage, width?: number, - asClient: boolean, -} & ( - { - session: SessionMaybeUser, - disableEditor?: false - } | { - session?: SessionMaybeUser, - disableEditor: true, - } - ) +} -/** - * WARNING: May only be used server-side as it uses which is server-only. - */ -export default function Flair({ flair, width = 50, session, asClient, disableEditor }: PropTypes) { - const maybeSession = session ? session : Session.empty() - if (asClient) { - return - } +export default function Flair({ flair, width = 50 }: PropTypes) { return ( - +
+ +
) } diff --git a/src/app/_components/Footer/Footer.tsx b/src/app/_components/Footer/Footer.tsx index eeed59024..57727dfbc 100644 --- a/src/app/_components/Footer/Footer.tsx +++ b/src/app/_components/Footer/Footer.tsx @@ -1,6 +1,7 @@ import styles from './Footer.module.scss' import SocialIcons from '@/components/SocialIcons/SocialIcons' import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' +import StandardImageServer from '@/components/Image/StandardImageServer' import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' import Link from 'next/link' import type { AuthResultTypeAny } from '@/auth/authorizer/AuthResult' @@ -13,12 +14,9 @@ async function Footer({ canEditSpecialCmsImage }: PropTypes) { return (
-

Linjeforeningen for Elektronisk Systemdesign @@ -27,7 +25,7 @@ async function Footer({ canEditSpecialCmsImage }: PropTypes) {

Org. Nr. 890 384 692

- {/* Uncomment when PWA an SVG + {/* TODO: Uncomment when PWA an SVG .specialTag { + position: absolute; + top: 0; + left: 0; + padding: 0.3em; + color: ohma.$colors-white; + background-color: ohma.$colors-purple; + border-radius: 0 0 1em 0; + z-index: 1; + text-align: center; + font-size: 0.8em; + letter-spacing: 0.05em; + text-transform: uppercase; + } + &::after { + background: radial-gradient(ellipse at center bottom, transparent 20%, ohma.$colors-purple 70%, ohma.$colors-purple 100%); + } + } } \ No newline at end of file diff --git a/src/app/_components/Image/Collection/CollectionCard.tsx b/src/app/_components/Image/Collection/CollectionCard.tsx index c0b6b6d8d..4bfc233de 100644 --- a/src/app/_components/Image/Collection/CollectionCard.tsx +++ b/src/app/_components/Image/Collection/CollectionCard.tsx @@ -1,23 +1,15 @@ import styles from './CollectionCard.module.scss' import Image from '@/components/Image/Image' -import Link from 'next/link' -import type { Image as ImageT, ImageCollection } from '@/prisma-generated-pn-types' +import type { ExpandedImageCollection } from '@/services/images/subservice/types' type PropTypes = { - collection: ImageCollection & { - coverImage: ImageT | null, - numberOfImages: number, - }, + collection: ExpandedImageCollection, className?: string, } export default function CollectionCard({ collection, className }: PropTypes) { return ( - +
{ collection.coverImage ? ( @@ -25,12 +17,13 @@ export default function CollectionCard({ collection, className }: PropTypes) {

Something went wrong

) } + {collection.special &&

Spesiell

}

{collection.name}

{collection.description}

{collection.createdAt.toUTCString().split(' ').slice(0, 4).join(' ')}

{collection.numberOfImages}

- +
) } diff --git a/src/app/_components/Image/Collection/CollectionCardLink.tsx b/src/app/_components/Image/Collection/CollectionCardLink.tsx new file mode 100644 index 000000000..bfaba1cf1 --- /dev/null +++ b/src/app/_components/Image/Collection/CollectionCardLink.tsx @@ -0,0 +1,27 @@ +import CollectionCard from './CollectionCard' +import Link from 'next/link' +import type { ExpandedImageCollection } from '@/services/images/subservice/types' + +type PropTypes = { + collection: ExpandedImageCollection, + className?: string, +} + +function collectionHref(collection: ExpandedImageCollection): string { + return collection.special + ? `/image-collections/special/${encodeURIComponent(collection.special)}` + : `/image-collections/dynamic/${encodeURIComponent(collection.name)}` +} + +/** + * A CollectionCard that navigates to the collection on click - for listing pages. CmsImageEditor + * does not navigate on click, so it wraps the plain CollectionCard itself instead of using this. + * The className, if given, sizes the link (the actual grid item) - CollectionCard fills it. + */ +export default function CollectionCardLink({ collection, className }: PropTypes) { + return ( + + + + ) +} diff --git a/src/app/_components/Image/Collection/ImageCollectionList.module.scss b/src/app/_components/Image/Collection/ImageCollectionList.module.scss deleted file mode 100644 index ba41cd216..000000000 --- a/src/app/_components/Image/Collection/ImageCollectionList.module.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use '@/styles/ohma'; - -$collection-size: 250px; - -.ImageCollectionList { - width: 100%; - display: grid; - grid-template-columns: repeat(4, 1fr); - - //selects all collection card but not the button for loading more in Endless scroll - > *:not(:last-child) { - width: $collection-size; - height: $collection-size; - margin: ohma.$gap; - overflow: hidden; - @include ohma.round; - position: relative; - place-self: center; - > .imageCount { - position: absolute; - top: 0; - right: 0; - padding: 0.3em; - color: ohma.$colors-white; - background-color: ohma.$colors-black; - border-radius: 0 0 0 1em; - min-width: 30px; - z-index: 1; - text-align: center; - } - &::after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: radial-gradient(ellipse at center bottom, transparent 20%, ohma.$colors-secondary 70%, ohma.$colors-secondary 100%); - rotate: 180deg; - scale: 1.5; - z-index: 0; - } - > *:first-child { - position: absolute; - top: 0; - left: 0; - width: 100% !important; - height: 100%; - > img { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: cover; - transition: scale 0.5s; - } - } - > .info { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 0.3em; - color: ohma.$colors-black; - text-decoration: none; - transition: min-height 0.5s; - padding: ohma.$gap; - z-index: 1; - i { - margin-top: ohma.$gap; - transition: opacity 0.5s; - } - } - &:hover img { - scale: 1.2; - } - } - - //button for loading more in Endless scroll - > *:last-child { - grid-column: 1/-1; - } -} - -@media screen and (max-width: (4 * ($collection-size + 20px))) { - .ImageCollectionList { - grid-template-columns: repeat(3, 1fr); - } -} - -@media screen and (max-width: (3 * ($collection-size + 20px))) { - .ImageCollectionList { - grid-template-columns: repeat(2, 1fr); - } -} - -@media screen and (max-width: (2 * ($collection-size + 20px))) { - .ImageCollectionList { - grid-template-columns: repeat(1, 1fr); - > *:not(:last-child) { - place-self: initial; - width: min(90vw, 350px); - height: min(90vw, 350px); - } - } -} \ No newline at end of file diff --git a/src/app/_components/Image/Collection/ImageCollectionList.tsx b/src/app/_components/Image/Collection/ImageCollectionList.tsx deleted file mode 100644 index 0160f34a2..000000000 --- a/src/app/_components/Image/Collection/ImageCollectionList.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' -import styles from './ImageCollectionList.module.scss' -import CollectionCard from './CollectionCard' -import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' -import { ImageCollectionPagingContext } from '@/contexts/paging/ImageCollectionPaging' -import React from 'react' - -type PropTypes = { - serverRendered: React.ReactNode, -} - -// Note that this component may take iniitial imagecollections as props fetched on server -/** - * WARNING: The server rendered data should be CollectioCards to make it consistent with the endless scroll - * @param serverRendered - Make sure to pass the server rendered collections here in the correct format - * @returns - */ -export default function ImageCollectionList({ serverRendered }: PropTypes) { - return ( -
- {serverRendered} {/* Rendered on server homefully in the right way*/} - ( - - ) - } - /> -
- ) -} diff --git a/src/app/_components/Image/ImageList/ImageDisplay.tsx b/src/app/_components/Image/ImageList/ImageDisplay.tsx deleted file mode 100644 index 33f89694a..000000000 --- a/src/app/_components/Image/ImageList/ImageDisplay.tsx +++ /dev/null @@ -1,252 +0,0 @@ -'use client' -import styles from './ImageDisplay.module.scss' -import { SelectString } from '@/components/UI/Select' -import PopUp from '@/components/PopUp/PopUp' -import Image from '@/components/Image/Image' -import useKeyPress from '@/hooks/useKeyPress' -import Form from '@/components/Form/Form' -import TextInput from '@/components/UI/TextInput' -import { ImagePagingContext } from '@/contexts/paging/ImagePaging' -import { ImageDisplayContext } from '@/contexts/ImageDisplayProvider' -import LicenseChooser from '@/components/LicenseChooser/LicenseChooser' -import { updateImageCollectionAction } from '@/services/images/collections/actions' -import { destroyImageAction, updateImageAction } from '@/services/images/actions' -import { configureAction } from '@/services/configureAction' -import { useRouter } from 'next/navigation' -import { faChevronRight, faChevronLeft, faX, faCog } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useContext } from 'react' -import Link from 'next/link' -import type { ImageSizeOptions } from '@/components/Image/Image' -import type { Image as ImageT } from '@/prisma-generated-pn-types' - -const mimeTypes: { [key: string]: string } = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - bmp: 'image/bmp', - webp: 'image/webp', - avif: 'image/avif', - tiff: 'image/tiff', - svg: 'image/svg+xml', -} -const getCurrentType = (image: ImageT, size: ImageSizeOptions) => { - let src = image.fsLocationOriginal - switch (size) { - case 'SMALL': - src = image.fsLocationSmallSize - break - case 'MEDIUM': - src = image.fsLocationMediumSize - break - case 'LARGE': - src = image.fsLocationLargeSize - break - case 'ORIGINAL': - src = image.fsLocationOriginal - break - default: - return 'unknown' - } - const ext = src.split('.').pop() - if (!ext) return 'unknown' - return mimeTypes[ext] -} - -export default function ImageDisplay() { - const pagingContext = useContext(ImagePagingContext) - const displayContext = useContext(ImageDisplayContext) - const canEdit = true //TODO: Auth - - if (!pagingContext || !displayContext) throw new Error('No context') - - const getCurrentIndex = () => pagingContext.state.data.findIndex(x => x.id === displayContext.currentImage?.id) - - const goLeft = () => { - const currentIndex = getCurrentIndex() - const nextIndex = currentIndex === 0 ? pagingContext.state.data.length - 1 : currentIndex - 1 - displayContext.setImage(pagingContext.state.data[nextIndex]) - } - - const naiveGoRight = () => { - const currentIndex = getCurrentIndex() - const nextIndex = currentIndex === pagingContext.state.data.length - 1 ? 0 : currentIndex + 1 - displayContext.setImage(pagingContext.state.data[nextIndex]) - } - - const goRight = async () => { - if (!pagingContext.state.data.length) return - if (!displayContext.currentImage) { - if (pagingContext.state.data.length) displayContext.setImage(pagingContext.state.data[0]) - return - } - if (displayContext.currentImage.id !== pagingContext.state.data[pagingContext.state.data.length - 1].id) { - naiveGoRight() - return - } - if (pagingContext.state.allLoaded) { - naiveGoRight() - return - } - const newImages = await pagingContext.loadMore() - if (!newImages.length) { - naiveGoRight() - return - } - displayContext.setImage(newImages[0]) - } - - useKeyPress('ArrowRight', goRight) - useKeyPress('ArrowLeft', goLeft) - - const { refresh } = useRouter() - - const reload = async () => { - pagingContext.refetch() - refresh() - } - - const image = displayContext.currentImage - - const close = () => { - displayContext.setImage(null) - } - useKeyPress('Escape', close) - - const handleSizeChange = (size: string) => { - switch (size) { - case 'SMALL': - displayContext.setImageSize('SMALL') - break - case 'MEDIUM': - displayContext.setImageSize('MEDIUM') - break - case 'LARGE': - displayContext.setImageSize('LARGE') - break - case 'ORIGINAL': - displayContext.setImageSize('ORIGINAL') - break - default: - break - } - } - - if (!image) return <> - - return ( -
-
- -
- -
-

{image.name}

- Alt-tekst: {image.alt} - Type: {getCurrentType(image, displayContext.imageSize)} - Kreditert: {image.credit ?? 'ingen'} - Lisens: { - image.licenseLink ? - - {image.licenseName} - - : 'ingen' - } - - { - pagingContext.loading ? ( -
- ) : ( - - ) - } -
- -
- - -
- { - canEdit && ( - - }> -
- - - - - - -
-
-
-
-
- ) - } -
- ) -} diff --git a/src/app/_components/Image/ImageList/ImageList.module.scss b/src/app/_components/Image/ImageList/ImageList.module.scss deleted file mode 100644 index 774293032..000000000 --- a/src/app/_components/Image/ImageList/ImageList.module.scss +++ /dev/null @@ -1,19 +0,0 @@ -@use '@/styles/ohma'; - -.ListImagesInCollection { - display: flex; - flex-flow: row wrap; - position: relative; - &.paddingTop { - padding-top: 4em; - } - .uploadImage { - @include ohma.btn(ohma.$colors-secondary); - height: 3em; - margin: 0; - max-width: 150px; - position: absolute; - top: 0; - left: 0; - } -} \ No newline at end of file diff --git a/src/app/_components/Image/ImageList/ImageList.tsx b/src/app/_components/Image/ImageList/ImageList.tsx deleted file mode 100644 index 4e71b51b9..000000000 --- a/src/app/_components/Image/ImageList/ImageList.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' -import styles from './ImageList.module.scss' -import ImageListImage from './ImageListImage' -import { ImagePagingContext } from '@/contexts/paging/ImagePaging' -import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' -import ImageUploader from '@/components/Image/ImageUploader' -import PopUp from '@/components/PopUp/PopUp' -import React, { useCallback, useContext } from 'react' -import { useRouter } from 'next/navigation' -import { v4 as uuid } from 'uuid' - -type PropTypes = { - serverRendered?: React.ReactNode, - withUpload?: boolean, -} - -/** - * WARNING: This component must be rendered inside a ImagePagingContextProvider - * This is a component that renders a list of images. It uses the ImagePagingContext to fetch more images - * The component is designed to use ImageSelectionProvider to select images as well - * @param serverRendered - server rendered data, should be rendered before the endless scroll i.e a list - * of ImageListImage components - * @param disableEditing - if true, the ImageListImage components will not be able to edit the image - * @param withUpload - if true, the ImageUploader component will be rendered to make it possible to upload images to - * the current collection (false by default) - * @returns - */ -export default function ImageList({ - serverRendered, - withUpload = false -}: PropTypes) { - const context = useContext(ImagePagingContext) - const { refresh } = useRouter() - - //This component must be rendered inside a ImagePagingContextProvider - if (!context) throw new Error('No context') - - const handleUpload = useCallback(() => { - refresh() - context.refetch() - }, [context, refresh]) - - return ( -
- { - withUpload && ( - Legg til bilde - }> - - - ) - } - {serverRendered} {/* Rendered on server homefully in the right way*/} - } - /> -
- ) -} diff --git a/src/app/_components/Image/ImageList/ImageListImage.tsx b/src/app/_components/Image/ImageList/ImageListImage.tsx deleted file mode 100644 index 5653a70fa..000000000 --- a/src/app/_components/Image/ImageList/ImageListImage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import styles from './ImageListImage.module.scss' -import ImageSelectionButton from './ImageSelectionButton' -import SelectImageDisplay from './SelectImageDisplay' -import { default as ImageComponent } from '@/components/Image/Image' -import type { Image } from '@/prisma-generated-pn-types' - -type PropTypes = { - image: Image -} - -export default function ImageListImage({ image }: PropTypes) { - return ( -
- - - -
- ) -} diff --git a/src/app/_components/Image/ImageList/ImageSelectionButton.module.scss b/src/app/_components/Image/ImageList/ImageSelectionButton.module.scss deleted file mode 100644 index 24b688b35..000000000 --- a/src/app/_components/Image/ImageList/ImageSelectionButton.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use '@/styles/ohma'; - -.selectBtn { - width: 2em; - height: 2em; - background-color: ohma.$colors-gray-300; - border-radius: 50%; - margin: 0.3em; - border: none; - position: relative; - > svg { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 80%; - height: 80%; - } - &:hover { - cursor: pointer; - background-color: ohma.$colors-gray-500; - > svg { - opacity: 0.5; - } - } - &.selected { - background-color: ohma.$colors-primary; - } -} \ No newline at end of file diff --git a/src/app/_components/Image/ImageList/ImageSelectionButton.tsx b/src/app/_components/Image/ImageList/ImageSelectionButton.tsx deleted file mode 100644 index 347290120..000000000 --- a/src/app/_components/Image/ImageList/ImageSelectionButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client' -import styles from './ImageSelectionButton.module.scss' -import { ImageSelectionContext } from '@/contexts/ImageSelection' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCheck } from '@fortawesome/free-solid-svg-icons' -import { useContext } from 'react' -import type { Image } from '@/prisma-generated-pn-types' - -type PropTypes = { - image: Image, -} - -export default function ImageSelectionButton({ image }: PropTypes) { - const selection = useContext(ImageSelectionContext) - const imageIsSelected = selection?.selectedImage?.id === image.id - - return ( - selection?.selectionMode && ( -
- -
- ) - ) -} diff --git a/src/app/_components/Image/ImageList/SelectImageDisplay.tsx b/src/app/_components/Image/ImageList/SelectImageDisplay.tsx deleted file mode 100644 index 4d076ab6c..000000000 --- a/src/app/_components/Image/ImageList/SelectImageDisplay.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client' -import { ImageDisplayContext } from '@/contexts/ImageDisplayProvider' -import { ImageSelectionContext } from '@/contexts/ImageSelection' -import { useContext } from 'react' -import type { Image } from '@/prisma-generated-pn-types' - -type PropTypes = { - image: Image -} - -/** - * Sets a image to display in the ImageDisplayContext. If this component is not rendered in a ImageDisplayProvider, - * it will intead use the imageSelectionContext. - * @param image - the image to display - * @returns - */ -export default function SelectImageDisplay({ image }: PropTypes) { - const imageDisplayContext = useContext(ImageDisplayContext) - const imageSelectionContext = useContext(ImageSelectionContext) - if (!imageDisplayContext) { - if (!imageSelectionContext) return <> - return ( - +
+

{image.name}

+ Alt-tekst: {image.alt} + Type: {getCurrentType(image, imageSize)} + Kreditert: {image.credit ?? 'ingen'} + Lisens: { + image.licenseLink ? + + {image.licenseName} + + : 'ingen' + } + + { + loading ? ( +
+ ) : ( + + ) + } +
+ +
+ + +
+
+ ) +} diff --git a/src/app/_components/Image/ImagePanel/ImagePanel.module.scss b/src/app/_components/Image/ImagePanel/ImagePanel.module.scss new file mode 100644 index 000000000..886627e4a --- /dev/null +++ b/src/app/_components/Image/ImagePanel/ImagePanel.module.scss @@ -0,0 +1,15 @@ +@use '@/styles/ohma'; + +.ImagePanel { + display: flex; + flex-flow: row wrap; + position: relative; + + > .loadControl { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin: ohma.$gap 0; + } +} diff --git a/src/app/_components/Image/ImagePanel/ImagePanel.tsx b/src/app/_components/Image/ImagePanel/ImagePanel.tsx new file mode 100644 index 000000000..4caeacd35 --- /dev/null +++ b/src/app/_components/Image/ImagePanel/ImagePanel.tsx @@ -0,0 +1,233 @@ +'use client' +import styles from './ImagePanel.module.scss' +import ImagePanelImage from './ImagePanelImage' +import ImageDisplay from './ImageDisplay' +import Button from '@/components/UI/Button' +import { useEffect, useState } from 'react' +import { useInView } from 'react-intersection-observer' +import type { Page } from '@/lib/paging/types' +import type { ActionReturn } from '@/services/actionTypes' +import type { ErrorCode } from '@/services/error' +import type { Image } from '@/prisma-generated-pn-types' + +export type ImagePanelCursor = { + imageId: number, +} + +/** + * The pager the panel is built around. Dynamic collections bind their collectionId into it, + * special collections need nothing bound - either way the panel itself only ever sees "give me the + * page after this cursor". + * + * WARNING: The panel treats a new function identity as "this is a different collection" and resets + * itself, so the injected action must be referentially stable (module-level, or wrapped in + * useCallback with the collection as dependency) - an inline arrow recreated every render would + * reset the panel every render. + */ +export type ReadPageOfImagesInCollectionAction = ( + page: Page +) => Promise> + +type SelectionPropTypes = { + selectionActive: true, + onSelectedImageChange: (image: Image | null) => void, + defaultSelectedImage?: Image, +} | { + selectionActive?: false, + onSelectedImageChange?: never, + defaultSelectedImage?: never, +} + +type PropTypes = { + readPageOfImagesInCollectionAction: ReadPageOfImagesInCollectionAction, + pageSize: PageSize, + withImageDisplay?: boolean, +} & SelectionPropTypes + +/** + * The pages loaded so far, stamped with the action that loaded them. The stamp is what makes an + * action change safe: state loaded through another action - whether read directly or written late + * by a fetch that resolved after the action changed - is simply not "current" for the new action, + * so it is ignored and eventually overwritten instead of needing explicit invalidation. + */ +type PagingState = { + forAction: ReadPageOfImagesInCollectionAction, + images: Image[], + nextPageNumber: number, + allLoaded: boolean, +} + +/** + * An explicitly requested page load (as opposed to the loads the panel starts by itself for the + * first page and the endless scroll). advanceDisplay makes the large display move onto the first + * image of the new page once it arrives - the "navigate right past the last loaded image" case. + */ +type LoadRequest = { + advanceDisplay: boolean, +} + +/** + * A self-contained panel of the images in one collection: paging (endless scroll), selection and + * the large image display in one component, driven entirely by the injected pager action - no + * contexts and no server rendered data. + * + * @param readPageOfImagesInCollectionAction - the pager to read image pages through. When its + * identity changes the panel treats it as a different collection: all loaded pages, errors and the + * open display are dropped and the first page is refetched. The selection is kept, since the + * selected image (e.g. the image a cms image currently links to) does not have to belong to the + * collection being browsed. + * @param pageSize - the page size the pager is called with + * @param withImageDisplay - if true, clicking an image opens the large ImageDisplay + * @param selectionActive - if true, images can be selected (checkmark button on each image) + * @param onSelectedImageChange - called with the new selection when the user selects/deselects + * @param defaultSelectedImage - the image selected before the user has made a choice + */ +export default function ImagePanel({ + readPageOfImagesInCollectionAction, + pageSize, + withImageDisplay = false, + selectionActive = false, + onSelectedImageChange, + defaultSelectedImage, +}: PropTypes) { + const [pagingState, setPagingState] = useState | null>(null) + const [errorCode, setErrorCode] = useState(null) + const [selectedImage, setSelectedImage] = useState(defaultSelectedImage ?? null) + const [displayedImage, setDisplayedImage] = useState(null) + const [loadRequest, setLoadRequest] = useState(null) + + // State loaded through a previous action is stale, never rendered and never built upon. + const currentPagingState = + pagingState !== null && pagingState.forAction === readPageOfImagesInCollectionAction + ? pagingState + : null + + // A new action identity means a different collection - drop everything from the old one during + // render (before children render with stale state). The paging state needs no explicit reset + // since it is stamped, and the fetch effect refetches once the current state derives to null. + const [previousAction, setPreviousAction] = useState(() => readPageOfImagesInCollectionAction) + if (previousAction !== readPageOfImagesInCollectionAction) { + setPreviousAction(() => readPageOfImagesInCollectionAction) + setErrorCode(null) + setDisplayedImage(null) + setLoadRequest(null) + } + + const [loadControlRef, loadControlInView] = useInView({ threshold: 0 }) + + const allLoaded = currentPagingState?.allLoaded ?? false + + // Fetching is declarative: a fetch should be running exactly when there is something to load + // and a reason to load it. The effect below starts it and writes the result back, which either + // satisfies the reason or (via the scroll sentinel still being in view) chains the next page. + const fetchPending = errorCode === null && !allLoaded && ( + currentPagingState === null + || loadControlInView + || loadRequest !== null + ) + + useEffect(() => { + if (!fetchPending) return undefined + let cancelled = false + + const page: Page = + currentPagingState && currentPagingState.images.length > 0 ? { + pageSize, + page: currentPagingState.nextPageNumber, + cursor: { imageId: currentPagingState.images[currentPagingState.images.length - 1].id }, + } : { + pageSize, + page: 0, + cursor: null, + } + + readPageOfImagesInCollectionAction(page).then(result => { + if (cancelled) return + if (!result.success) { + setErrorCode(result.errorCode) + setLoadRequest(null) + return + } + const previousImages = currentPagingState?.images ?? [] + setPagingState({ + forAction: readPageOfImagesInCollectionAction, + images: [...previousImages, ...result.data], + nextPageNumber: (currentPagingState?.nextPageNumber ?? 0) + 1, + allLoaded: result.data.length < pageSize, + }) + if (loadRequest?.advanceDisplay) { + // Move onto the new page, wrapping to the start if it turned out to be empty - but + // only if the display is still open. + const nextDisplayed = result.data[0] ?? previousImages[0] ?? null + setDisplayedImage(current => (current === null ? null : nextDisplayed)) + } + setLoadRequest(null) + }) + + return () => { + cancelled = true + } + }, [fetchPending, currentPagingState, loadRequest, pageSize, readPageOfImagesInCollectionAction]) + + const toggleSelected = (image: Image) => { + const newSelected = selectedImage?.id === image.id ? null : image + setSelectedImage(newSelected) + onSelectedImageChange?.(newSelected) + } + + const navigateLeft = () => { + if (!currentPagingState || !displayedImage) return + const currentIndex = currentPagingState.images.findIndex(image => image.id === displayedImage.id) + const nextIndex = currentIndex <= 0 ? currentPagingState.images.length - 1 : currentIndex - 1 + setDisplayedImage(currentPagingState.images[nextIndex]) + } + + const navigateRight = () => { + if (!currentPagingState || !displayedImage) return + const currentIndex = currentPagingState.images.findIndex(image => image.id === displayedImage.id) + if (currentIndex < currentPagingState.images.length - 1) { + setDisplayedImage(currentPagingState.images[currentIndex + 1]) + return + } + if (!currentPagingState.allLoaded) { + setLoadRequest({ advanceDisplay: true }) + return + } + setDisplayedImage(currentPagingState.images[0]) + } + + const images = currentPagingState?.images ?? [] + + const renderLoadControlContent = () => { + if (errorCode !== null) return

Noe gikk galt

+ if (fetchPending) return {images.length > 0 ? 'Laster inn flere...' : 'Laster inn...'} + if (allLoaded) return Ingen flere å laste inn + return + } + + return ( +
+ {images.map(image => ( + setDisplayedImage(image) : undefined} + onToggleSelect={selectionActive ? () => toggleSelected(image) : undefined} + /> + ))} + + {renderLoadControlContent()} + + {withImageDisplay && displayedImage && ( + setDisplayedImage(null)} + onNavigateLeft={navigateLeft} + onNavigateRight={navigateRight} + /> + )} +
+ ) +} diff --git a/src/app/_components/Image/ImageList/ImageListImage.module.scss b/src/app/_components/Image/ImagePanel/ImagePanelImage.module.scss similarity index 58% rename from src/app/_components/Image/ImageList/ImageListImage.module.scss rename to src/app/_components/Image/ImagePanel/ImagePanelImage.module.scss index 834851d12..d7f0a71bc 100644 --- a/src/app/_components/Image/ImageList/ImageListImage.module.scss +++ b/src/app/_components/Image/ImagePanel/ImagePanelImage.module.scss @@ -2,7 +2,7 @@ $image-size: 200px; -.ImageListImage { +.ImagePanelImage { width: $image-size; height: $image-size; margin: ohma.$gap; @@ -12,14 +12,14 @@ $image-size: 200px; position: relative; border: 3px solid ohma.$colors-primary; padding: 0; - + // img container (component): > *:first-child { position: absolute; top: 0; left: 0; width: 100% !important; - height: 100%; + height: 100%; > img { transition: transform 0.5s ease-in-out; position: absolute; @@ -28,7 +28,7 @@ $image-size: 200px; object-fit: cover; width: 100%; height: 100%; - } + } } &:hover { border: solid 10px ohma.$colors-primary; @@ -36,10 +36,8 @@ $image-size: 200px; transform: scale(1.1); } } - > button { - &:hover { - cursor: pointer; - } + + .tileButton { z-index: 1; position: absolute; top: 0; @@ -47,13 +45,41 @@ $image-size: 200px; width: 100%; height: 100%; opacity: 0; + border: none; + background-color: transparent; + &:hover { + cursor: pointer; + } } - //selection button - > div:last-child { + .selectButton { z-index: 1; position: absolute; top: 0; right: 0; + width: 2em; + height: 2em; + background-color: ohma.$colors-gray-300; + border-radius: 50%; + margin: 0.3em; + border: none; + > svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80%; + height: 80%; + } + &:hover { + cursor: pointer; + background-color: ohma.$colors-gray-500; + > svg { + opacity: 0.5; + } + } + &.selected { + background-color: ohma.$colors-primary; + } } -} \ No newline at end of file +} diff --git a/src/app/_components/Image/ImagePanel/ImagePanelImage.tsx b/src/app/_components/Image/ImagePanel/ImagePanelImage.tsx new file mode 100644 index 000000000..df931ae44 --- /dev/null +++ b/src/app/_components/Image/ImagePanel/ImagePanelImage.tsx @@ -0,0 +1,36 @@ +import styles from './ImagePanelImage.module.scss' +import { default as ImageComponent } from '@/components/Image/Image' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCheck } from '@fortawesome/free-solid-svg-icons' +import type { Image } from '@/prisma-generated-pn-types' + +type PropTypes = { + image: Image, + selected: boolean, + onOpenDisplay?: () => void, + onToggleSelect?: () => void, +} + +/** + * One image tile in an ImagePanel. Clicking the tile opens the large display when the panel has + * one, otherwise it toggles selection. The checkmark button always toggles selection when + * selection is active. + */ +export default function ImagePanelImage({ image, selected, onOpenDisplay, onToggleSelect }: PropTypes) { + const handleTileClick = onOpenDisplay ?? onToggleSelect + + return ( +
+ + {handleTileClick && + )} +
+ ) +} diff --git a/src/app/_components/Image/ImageUploader.tsx b/src/app/_components/Image/ImageUploader.tsx index b8737125b..17bc9e4ab 100644 --- a/src/app/_components/Image/ImageUploader.tsx +++ b/src/app/_components/Image/ImageUploader.tsx @@ -2,37 +2,45 @@ import Form from '@/components/Form/Form' import TextInput from '@/components/UI/TextInput' import FileInput from '@/components/UI/FileInput' import LicenseChooser from '@/components/LicenseChooser/LicenseChooser' -import { createImageAction } from '@/services/images/actions' -import { configureAction } from '@/services/configureAction' -import type { PropTypes as FormPropTypes } from '@/components/Form/Form' +import type { UploadSpecialCollectionImageAction } from '@/services/images/subservice/types' +import type { PopUpKeyType } from '@/contexts/PopUp' -type ResponseType = Awaited>; -type T = Pick['data'] - -type PropTypes = Omit, 'action' | 'submitText' | 'title'> & { - collectionId: number, +type PropTypes = { + uploadImageAction: UploadSpecialCollectionImageAction, + title: string, + className?: string, + successCallback?: (data?: unknown) => void, + refreshOnSuccess?: boolean, + closePopUpOnSuccess?: PopUpKeyType, } /** - * A component to upload one image to a collection - * @param collectionId - The id of the collection to upload the image to - * @param formProps - The props to pass to the form - * @returns + * uplaod form for an image. Uses the provided uploader action, eiteher an upload action + * to a special collection or a dynamic one. */ -export default function ImageUploader({ collectionId, ...formProps }: PropTypes) { +export default function ImageUploader({ + uploadImageAction, + title, + className, + successCallback, + refreshOnSuccess, + closePopUpOnSuccess, +}: PropTypes) { return ( - - - - - + + + + + ) } diff --git a/src/app/_components/Image/StandardImage.module.scss b/src/app/_components/Image/StandardImage.module.scss new file mode 100644 index 000000000..ebc714dcb --- /dev/null +++ b/src/app/_components/Image/StandardImage.module.scss @@ -0,0 +1,13 @@ +.StandardImage { + position: relative; + isolation: isolate; + display: table; + .children { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + } +} diff --git a/src/app/_components/Image/StandardImageClient.tsx b/src/app/_components/Image/StandardImageClient.tsx new file mode 100644 index 000000000..1f7dc1709 --- /dev/null +++ b/src/app/_components/Image/StandardImageClient.tsx @@ -0,0 +1,32 @@ +'use client' +import styles from './StandardImage.module.scss' +import Image, { SrcImage } from './Image' +import { useStandardImages } from '@/contexts/ClientData' +import type { PropTypes } from './StandardImageServer' + +const fallbackImage = '/images/fallback.jpg' + +/** + * WARNING: This component is only meant for the client - use StandardImage for the server + * A component that displays a standard image, read from the ClientData cache (seeded by layout, + * so no client fetch is needed). Falls back to a static placeholder if the standard image cannot + * be resolved at all (should essentially never happen, as it self-heals from static config). + * @param standardImage - the standard image to display + * @returns + */ +export default function StandardImageClient({ standardImage, children, className = '', ...props }: PropTypes) { + const result = useStandardImages() + + const renderImage = () => { + if (result.status === 'loading') return null + if (result.status === 'error') return + return + } + + return ( +
+ {renderImage()} + {children &&
{children}
} +
+ ) +} diff --git a/src/app/_components/Image/StandardImageServer.tsx b/src/app/_components/Image/StandardImageServer.tsx new file mode 100644 index 000000000..3c917700e --- /dev/null +++ b/src/app/_components/Image/StandardImageServer.tsx @@ -0,0 +1,37 @@ +import styles from './StandardImage.module.scss' +import Image, { SrcImage } from './Image' +import { readStandardImageAction } from '@/services/images/standard/actions' +import type { PropTypes as ImagePropTypes } from './Image' +import type { StandardImage as StandardImageT } from '@/prisma-generated-pn-types' +import type React from 'react' + +export type PropTypes = Omit & { + standardImage: StandardImageT, + children?: React.ReactNode, +} + +const fallbackImage = '/images/fallback.jpg' + +/** + * WARNING: This component is only meant for the server - use StandardImageClient for the client + * A component that fetches a standard image and displays it. Unlike SpecialCmsImage, standard + * images are resolved/generated from static config rather than being admin-editable, so this + * component is read-only. Falls back to a static placeholder if the standard image cannot be + * resolved at all (should essentially never happen, as it self-heals from static config). + * @param standardImage - the standard image to display + * @returns + */ +export default async function StandardImageServer({ standardImage, children, className = '', ...props }: PropTypes) { + const imageRes = await readStandardImageAction({ params: { standardImage } }) + + return ( +
+ {imageRes.success ? ( + + ) : ( + + )} + {children &&
{children}
} +
+ ) +} diff --git a/src/app/_components/LicenseChooser/LicenseChooser.tsx b/src/app/_components/LicenseChooser/LicenseChooser.tsx index bbeac4e7d..9353021fd 100644 --- a/src/app/_components/LicenseChooser/LicenseChooser.tsx +++ b/src/app/_components/LicenseChooser/LicenseChooser.tsx @@ -7,14 +7,15 @@ import { useCallback, useEffect, useState, useEffectEvent } from 'react' type PropTypes = { defaultLicenseName?: string | null + name?: string } /** * A component to choose a license. It makes a CLIENT SIDE request to the server to get the licenses - * The selection has the name "licenseId" + * @param name - the form field name of the selection (defaults to "licenseId") * @returns A component to choose a license */ -export default function LicenseChooser({ defaultLicenseName }: PropTypes) { +export default function LicenseChooser({ defaultLicenseName, name = 'licenseId' }: PropTypes) { const action = useCallback(() => readAllLicensesAction(), []) const { data } = useActionCall(action) @@ -34,7 +35,7 @@ export default function LicenseChooser({ defaultLicenseName }: PropTypes) { return ( -
) diff --git a/src/app/_components/NavBar/MobileNavBar.tsx b/src/app/_components/NavBar/MobileNavBar.tsx index 7c87bd5ce..01cf27f58 100644 --- a/src/app/_components/NavBar/MobileNavBar.tsx +++ b/src/app/_components/NavBar/MobileNavBar.tsx @@ -2,14 +2,13 @@ import getNavItems from './navDef' import styles from './MobileNavBar.module.scss' import Menu from './Menu' import UserNavigation from './UserNavigation' -import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' +import StandardImageServer from '@/components/Image/StandardImageServer' import EditModeSwitch from '@/components/EditModeSwitch/EditModeSwitch' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' import Link from 'next/link' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import type { PropTypes } from './NavBar' -export default async function MobileNavBar({ profile, canEditSpecialCmsImage }: PropTypes) { +export default async function MobileNavBar({ profile }: PropTypes) { const user = profile?.user ?? null const isLoggedIn = user !== null const applicationPeriod = false //TODO @@ -30,26 +29,20 @@ export default async function MobileNavBar({ profile, canEditSpecialCmsImage }: )) }
- - +
-
diff --git a/src/app/_components/NavBar/NavBar.tsx b/src/app/_components/NavBar/NavBar.tsx index 4b5c3f1b4..12d655f67 100644 --- a/src/app/_components/NavBar/NavBar.tsx +++ b/src/app/_components/NavBar/NavBar.tsx @@ -5,18 +5,15 @@ import getNavItems from './navDef' import UserNavigation from './UserNavigation' import ReportButton from './ReportButton' import EditModeSwitch from '@/components/EditModeSwitch/EditModeSwitch' -import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' +import StandardImageServer from '@/components/Image/StandardImageServer' import Link from 'next/link' -import type { AuthResultTypeAny } from '@/auth/authorizer/AuthResult' import type { Profile } from '@/services/users/types' export type PropTypes = { profile: Profile | null - canEditSpecialCmsImage: AuthResultTypeAny } -export default async function NavBar({ profile, canEditSpecialCmsImage }: PropTypes) { +export default async function NavBar({ profile }: PropTypes) { const user = profile?.user ?? null const isLoggedIn = user !== null // TODO: Actual application period check @@ -33,16 +30,13 @@ export default async function NavBar({ profile, canEditSpecialCmsImage }: PropTy
diff --git a/src/app/committees/page.tsx b/src/app/committees/page.tsx index 901b0edb2..7c8c898c6 100644 --- a/src/app/committees/page.tsx +++ b/src/app/committees/page.tsx @@ -1,18 +1,12 @@ import styles from './page.module.scss' import CommitteeCard from '@/components/CommitteeCard/CommitteeCard' import { readAllCommitteesAction } from '@/services/groups/committees/actions' -import { readSpecialImageAction } from '@/services/images/actions' export default async function Committees() { const res = await readAllCommitteesAction() if (!res.success) throw new Error(`Kunne ikke hente komiteer - ${res.errorCode}`) const committees = res.data - const strandardCommitteeLogoRes = await readSpecialImageAction.bind( - null, { params: { special: 'DAFAULT_COMMITTEE_LOGO' } } - )() - const standardCommitteeLogo = strandardCommitteeLogoRes.success ? strandardCommitteeLogoRes.data : null - return (

Komitéer

@@ -25,7 +19,7 @@ export default async function Committees() { key={committee.id} title={committee.name} href={`/committees/${committee.shortName}`} - image={committee.logoImage.image || standardCommitteeLogo} + image={committee.logoImage} /> )) } diff --git a/src/app/education/page.tsx b/src/app/education/page.tsx index d9c024d46..d9084510b 100644 --- a/src/app/education/page.tsx +++ b/src/app/education/page.tsx @@ -1,17 +1,18 @@ import styles from './page.module.scss' -import { readSpecialImageAction } from '@/services/images/actions' +import { readStandardImageAction } from '@/services/images/standard/actions' import PageWrapper from '@/components/PageWrapper/PageWrapper' import ImageCard from '@/components/ImageCard/ImageCard' +import { configureAction } from '@/services/configureAction' export default async function education() { - const hovedbyggningenRes = await readSpecialImageAction.bind(null, { + const hovedbyggningenRes = await configureAction(readStandardImageAction, { params: { - special: 'HOVEDBYGGNINGEN' + standardImage: 'HOVEDBYGGNINGEN' } })() - const BooksRes = await readSpecialImageAction.bind(null, { + const BooksRes = await configureAction(readStandardImageAction, { params: { - special: 'BOOKS' + standardImage: 'BOOKS' } })() diff --git a/src/app/education/schools/page.tsx b/src/app/education/schools/page.tsx index c9babaf9d..00b153087 100644 --- a/src/app/education/schools/page.tsx +++ b/src/app/education/schools/page.tsx @@ -36,7 +36,7 @@ export default async function Schools() { startPage={{ pageSize: pageSizeSchool, page: 1 }} >
- +
diff --git a/src/app/error.tsx b/src/app/error.tsx index 1e60fd5e2..f0d6a4121 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,11 +1,7 @@ 'use client' import styles from './error.module.scss' import Button from '@/components/UI/Button' -import SpecialCmsImageClient from '@/components/Cms/CmsImage/SpecialCmsImageClient' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' -import { frontpageAuth } from '@/services/frontpage/auth' -import { useSession } from '@/auth/session/useSession' -import { Session } from '@/auth/session/Session' +import StandardImageClient from '@/components/Image/StandardImageClient' /** * note that passing custom error type to next error boundary is not supported @@ -13,24 +9,13 @@ import { Session } from '@/auth/session/Session' * Look at redirectToErrorPage to how it is implemented. */ export default function ErrorBoundary({ error, reset }: {error: unknown, reset: () => void}) { - const session = useSession() - return (
-
{ diff --git a/src/app/events/[nameAndId]/RegistrationsList.tsx b/src/app/events/[nameAndId]/RegistrationsList.tsx index 252b568a1..0c58e6601 100644 --- a/src/app/events/[nameAndId]/RegistrationsList.tsx +++ b/src/app/events/[nameAndId]/RegistrationsList.tsx @@ -52,7 +52,7 @@ function DetailedTable({ pagingContext={EventRegistrationDetailedPagingContext} renderer={row => { const name = row.user ? - + : row.contact?.name return {name} @@ -108,7 +108,7 @@ function DefaultList({ return + }} className={styles.userCard} /> } return }} diff --git a/src/app/image-collections/ImageCollectionList.module.scss b/src/app/image-collections/ImageCollectionList.module.scss new file mode 100644 index 000000000..5e251eb05 --- /dev/null +++ b/src/app/image-collections/ImageCollectionList.module.scss @@ -0,0 +1,63 @@ +@use '@/styles/ohma'; + +$collection-size: 250px; + +.ImageCollectionList { + width: 100%; +} + +.modeSwitch { + width: 100%; + display: flex; + gap: ohma.$gap; + margin-bottom: ohma.$gap; + > button { + opacity: 0.5; + &.active { + opacity: 1; + } + } +} + +.grid { + width: 100%; + display: grid; + grid-template-columns: repeat(4, 1fr); + + > *:not(.loadingControl) { + width: $collection-size; + height: $collection-size; + margin: ohma.$gap; + overflow: hidden; + @include ohma.round; + position: relative; + place-self: center; + } + + > .loadingControl { + grid-column: 1/-1; + } +} + +@media screen and (max-width: (4 * ($collection-size + 20px))) { + .grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media screen and (max-width: (3 * ($collection-size + 20px))) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media screen and (max-width: (2 * ($collection-size + 20px))) { + .grid { + grid-template-columns: repeat(1, 1fr); + > *:not(.loadingControl) { + place-self: initial; + width: min(90vw, 350px); + height: min(90vw, 350px); + } + } +} \ No newline at end of file diff --git a/src/app/image-collections/ImageCollectionList.tsx b/src/app/image-collections/ImageCollectionList.tsx new file mode 100644 index 000000000..e1396e6d4 --- /dev/null +++ b/src/app/image-collections/ImageCollectionList.tsx @@ -0,0 +1,67 @@ +'use client' +import styles from './ImageCollectionList.module.scss' +import CollectionCardLink from '@/components/Image/Collection/CollectionCardLink' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import { DynamicImageCollectionPagingContext } from '@/contexts/paging/DynamicImageCollectionPaging' +import { useSpecialCollections } from '@/contexts/ClientData' +import { useState, type ReactNode } from 'react' + +type PropTypes = { + serverRendered: ReactNode, +} + +/** + * WARNING: The server rendered data should be CollectioCards to make it consistent with the endless scroll + * @param serverRendered - Make sure to pass the server rendered collections here in the correct format + * @returns + */ +export default function ImageCollectionList({ serverRendered }: PropTypes) { + const [mode, setMode] = useState<'special' | 'dynamic'>('dynamic') + + const specialCollectionsResult = useSpecialCollections() + + const renderSpecialCollections = () => { + if (specialCollectionsResult.status === 'loading') return Laster inn... + if (specialCollectionsResult.status === 'error') return

Noe gikk galt

+ return specialCollectionsResult.specialCollections.map(collection => ( + + )) + } + + return ( +
+
+ + +
+ {mode === 'dynamic' ? ( +
+ {serverRendered} {/* Rendered on server (page.tsx) hopefully in the right way*/} + ( + + ) + } + /> +
+ ) : ( +
+ {renderSpecialCollections()} +
+ )} +
+ ) +} diff --git a/src/app/images/MakeNewCollection.module.scss b/src/app/image-collections/MakeNewCollection.module.scss similarity index 100% rename from src/app/images/MakeNewCollection.module.scss rename to src/app/image-collections/MakeNewCollection.module.scss diff --git a/src/app/image-collections/MakeNewCollection.tsx b/src/app/image-collections/MakeNewCollection.tsx new file mode 100644 index 000000000..0eab05d26 --- /dev/null +++ b/src/app/image-collections/MakeNewCollection.tsx @@ -0,0 +1,33 @@ +'use client' +import styles from './MakeNewCollection.module.scss' +import Form from '@/components/Form/Form' +import PopUp from '@/components/PopUp/PopUp' +import TextInput from '@/components/UI/TextInput' +import { createDynamicImageCollectionAction } from '@/services/images/dynamic/actions' +import { faPlus } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +export default function MakeNewCollection() { + const popUpKey = 'MakeNewCollection' + + return ( + }> +
+
+ (collection ? `/image-collections/${collection.id}` : '/image-collections') + } + title="Lag et album" + submitText="Lag album" + action={createDynamicImageCollectionAction} + > + + + +
+
+ ) +} diff --git a/src/app/image-collections/ToggleShowAdminCollections.module.scss b/src/app/image-collections/ToggleShowAdminCollections.module.scss new file mode 100644 index 000000000..40b42469d --- /dev/null +++ b/src/app/image-collections/ToggleShowAdminCollections.module.scss @@ -0,0 +1,8 @@ +@use '@/styles/ohma'; + +.ToggleShowAdminCollections { + display: flex; + align-items: center; + padding: 0 3*ohma.$gap; + font-size: ohma.$fonts-m; +} diff --git a/src/app/image-collections/ToggleShowAdminCollections.tsx b/src/app/image-collections/ToggleShowAdminCollections.tsx new file mode 100644 index 000000000..5c573adf4 --- /dev/null +++ b/src/app/image-collections/ToggleShowAdminCollections.tsx @@ -0,0 +1,41 @@ +'use client' +import styles from './ToggleShowAdminCollections.module.scss' +import Checkbox from '@/UI/Checkbox' +import { QueryParams } from '@/lib/queryParams/queryParams' +import { useRouter } from 'next/navigation' +import type { ChangeEvent } from 'react' + +type PropTypes = { + showOnlyCollectionsSessionAdministrates: boolean, +} + +/** + * The switch deciding whether the collection listing shows every collection the session may see, or + * only the ones it administrates. + * + * The value is kept in the url rather than only in the paging context, because the first page of + * collections is rendered on the server - setting the details client side alone would leave those + * server rendered cards unfiltered and page on from a cursor outside the filtered set. + * @param showOnlyCollectionsSessionAdministrates - the value currently in effect + * @returns + */ +export default function ToggleShowAdminCollections({ + showOnlyCollectionsSessionAdministrates +}: PropTypes) { + const { replace } = useRouter() + + const handleChange = (event: ChangeEvent) => { + replace(`/image-collections/?${QueryParams.onlyAdministratedCollections.encodeUrl(event.target.checked)}`) + } + + return ( + + + + ) +} diff --git a/src/app/images/collections/[id]/CollectionAdmin.module.scss b/src/app/image-collections/dynamic/[name]/CollectionAdmin.module.scss similarity index 100% rename from src/app/images/collections/[id]/CollectionAdmin.module.scss rename to src/app/image-collections/dynamic/[name]/CollectionAdmin.module.scss diff --git a/src/app/image-collections/dynamic/[name]/CollectionAdmin.tsx b/src/app/image-collections/dynamic/[name]/CollectionAdmin.tsx new file mode 100644 index 000000000..2144dbc49 --- /dev/null +++ b/src/app/image-collections/dynamic/[name]/CollectionAdmin.tsx @@ -0,0 +1,224 @@ +'use client' +import styles from './CollectionAdmin.module.scss' +import CollectionAdminUpload from './CollectionAdminUpload' +import Form from '@/components/Form/Form' +import TextInput from '@/components/UI/TextInput' +import ImageUploader from '@/components/Image/ImageUploader' +import PopUp from '@/components/PopUp/PopUp' +import VisibilityAdmin from '@/components/Visibility/VisibilityAdmin/VisibilityAdmin' +import useEditMode from '@/hooks/useEditMode' +import { dynamicImageAuth } from '@/services/images/dynamic/auth' +import Button from '@/components/UI/Button' +import { configureAction } from '@/services/configureAction' +import { + updateDynamicImageCollectionAction, + destroyDynamicImageCollectionAction, + uploadImageToDynamicCollectionAction, + updateDynamicImageCollectionRegularLevelVisibilityAction, + updateDynamicImageCollectionAdminLevelVisibilityAction, +} from '@/services/images/dynamic/actions' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCog, faEye, faUpload } from '@fortawesome/free-solid-svg-icons' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import type { ExpandedImageCollection } from '@/services/images/subservice/types' +import type { DoubleLevelVisibilityMatrix } from '@/services/visibility/types' + +type PropTypes = { + collection: ExpandedImageCollection, + doubleLevelVisibility: DoubleLevelVisibilityMatrix | null, + refreshImages: () => void, +} +/** + * Fallback in case one was not able to read visibility. In this case, + * the auths might be wrong, but this allows the admin to at least still render. + */ +const UNREADABLE_VISIBILITY: DoubleLevelVisibilityMatrix = { + regularLevel: { requirements: [{ conditions: [] }] }, + adminLevel: { requirements: [{ conditions: [] }] }, +} + +export default function CollectionAdmin({ collection, doubleLevelVisibility, refreshImages }: PropTypes) { + const { id: collectionId } = collection + const router = useRouter() + const doubleLevelMatrix = doubleLevelVisibility ?? UNREADABLE_VISIBILITY + + // One authorizer check per action - each button/form below is gated by the exact same + // authorizer its own action uses server-side, not a single blanket "can edit collection" check. + const canUploadOne = useEditMode({ + authorizer: dynamicImageAuth.uploadImage.dynamicFields({ doubleLevelMatrix }) + }) + const canUploadMany = useEditMode({ + authorizer: dynamicImageAuth.uploadManyImages.dynamicFields({ doubleLevelMatrix }) + }) + const canUpdateCollection = useEditMode({ + authorizer: dynamicImageAuth.updateCollection.dynamicFields({ doubleLevelMatrix }) + }) + const canDestroyCollection = useEditMode({ + authorizer: dynamicImageAuth.destroyCollection.dynamicFields({ doubleLevelMatrix }) + }) + const canUpdateRegularVisibility = useEditMode({ + authorizer: dynamicImageAuth.updateRegularLevel.dynamicFields({ doubleLevelMatrix }) + }) + const canUpdateAdminVisibility = useEditMode({ + authorizer: dynamicImageAuth.updateAdminLevel.dynamicFields({ doubleLevelMatrix }) + }) + + const [uploadOption, setUploadOption] = useState<'MANY' | 'ONE'>(canUploadMany ? 'MANY' : 'ONE') + + const canUpload = canUploadOne || canUploadMany + const canOpenEditPopUp = canUpdateCollection || canDestroyCollection + const canOpenVisibilityPopUp = canUpdateRegularVisibility || canUpdateAdminVisibility + + if (!canUpload && !canOpenEditPopUp && !canOpenVisibilityPopUp) return null + + return ( +
+ { + canUpload && ( + + }> +
+ { + uploadOption === 'MANY' ? canUploadMany && ( + <> + + { + canUploadOne && ( + + ) + } + + ) : canUploadOne && ( + <> + + { + canUploadMany && ( + + ) + } + + ) + } +
+
+ ) + } + { + canOpenEditPopUp && ( + + }> + { + canUpdateCollection && ( +
+ + + + ) + } + { + canDestroyCollection && ( +
router.push('/image-collections')} + action={configureAction( + destroyDynamicImageCollectionAction, + { params: { collectionId } } + )} + submitColor="red" + confirmation={{ + confirm: true, + text: + 'Er du sikker på at du vil slette samlingen. ' + + 'Dette vil også slette alle bilder i salingen.' + }} + /> + ) + } + + ) + } + { + doubleLevelVisibility && canOpenVisibilityPopUp && ( + + }> +
+ { + canUpdateRegularVisibility && ( +
+

Vanlig visning

+ +
+ ) + } + { + canUpdateAdminVisibility && ( +
+

Adminvisning

+ +
+ ) + } +
+
+ ) + } +
+ ) +} diff --git a/src/app/images/collections/[id]/CollectionAdminUpload.module.scss b/src/app/image-collections/dynamic/[name]/CollectionAdminUpload.module.scss similarity index 100% rename from src/app/images/collections/[id]/CollectionAdminUpload.module.scss rename to src/app/image-collections/dynamic/[name]/CollectionAdminUpload.module.scss diff --git a/src/app/images/collections/[id]/CollectionAdminUpload.tsx b/src/app/image-collections/dynamic/[name]/CollectionAdminUpload.tsx similarity index 76% rename from src/app/images/collections/[id]/CollectionAdminUpload.tsx rename to src/app/image-collections/dynamic/[name]/CollectionAdminUpload.tsx index 9469a2a1c..fad762132 100644 --- a/src/app/images/collections/[id]/CollectionAdminUpload.tsx +++ b/src/app/image-collections/dynamic/[name]/CollectionAdminUpload.tsx @@ -4,10 +4,10 @@ import Dropzone from '@/components/UI/Dropzone' import Form from '@/components/Form/Form' import Slider from '@/components/UI/Slider' import ProgressBar from '@/components/ProgressBar/ProgressBar' -import TextInput from '@/app/_components/UI/TextInput' -import LicenseChooser from '@/app/_components/LicenseChooser/LicenseChooser' -import { createImagesAction } from '@/services/images/actions' -import { maxImageCountInOneBatch } from '@/services/images/constants' +import TextInput from '@/components/UI/TextInput' +import LicenseChooser from '@/components/LicenseChooser/LicenseChooser' +import { uploadManyImagesToDynamicCollectionAction } from '@/services/images/dynamic/actions' +import { maxImageCountInOneBatch } from '@/services/images/subservice/constants' import { useCallback, useState } from 'react' import type { FileWithStatus } from '@/components/UI/Dropzone' import type { ActionReturn } from '@/services/actionTypes' @@ -33,18 +33,18 @@ export default function CollectionAdminUpload({ collectionId, refreshImages }: P const doneFiles: FileWithStatus[] = [] const useFileName = data.get('useFileName') === 'on' - const credit = typeof data.get('credit') === 'string' ? data.get('credit') : undefined - const licenseId = typeof data.get('licenseId') === 'string' ? data.get('licenseId') : undefined + const credit = typeof data.get('imageCredit') === 'string' ? data.get('imageCredit') : undefined + const licenseId = typeof data.get('imageLicenseId') === 'string' ? data.get('imageLicenseId') : undefined let res: ActionReturn = { success: true, data: undefined } setProgress(0) const progressIncrement = 1 / batches.length for (const batch of batches) { const formData = new FormData() - if (credit) formData.append('credit', credit) - if (licenseId) formData.append('licenseId', licenseId) + if (credit) formData.append('imageCredit', credit) + if (licenseId) formData.append('imageLicenseId', licenseId) batch.forEach(file => { - formData.append('files', file.file) + formData.append('imageFiles', file.file) }) setFiles(prev => prev.map(file => { if (batch.includes(file)) { @@ -52,7 +52,7 @@ export default function CollectionAdminUpload({ collectionId, refreshImages }: P } return file })) - res = await createImagesAction({ params: { useFileName, collectionId } }, formData) + res = await uploadManyImagesToDynamicCollectionAction({ params: { useFileName, collectionId } }, formData) if (res.success) { doneFiles.push(...batch) setFiles(files.map(file => { @@ -76,7 +76,7 @@ export default function CollectionAdminUpload({ collectionId, refreshImages }: P setProgress(null) setFiles([]) return res - }, [files, progress, collectionId]) + }, [files, collectionId]) return ( - - + + { progress ? : <> diff --git a/src/app/image-collections/dynamic/[name]/DynamicCollectionPanel.tsx b/src/app/image-collections/dynamic/[name]/DynamicCollectionPanel.tsx new file mode 100644 index 000000000..bfff011fe --- /dev/null +++ b/src/app/image-collections/dynamic/[name]/DynamicCollectionPanel.tsx @@ -0,0 +1,55 @@ +'use client' +import CollectionAdmin from './CollectionAdmin' +import ImagePanel from '@/components/Image/ImagePanel/ImagePanel' +import { readImagesPageInDynamicCollectionAction } from '@/services/images/dynamic/actions' +import { useCallback, useState } from 'react' +import type { ImagePanelCursor } from '@/components/Image/ImagePanel/ImagePanel' +import type { Page } from '@/lib/paging/types' +import type { ExpandedImageCollection } from '@/services/images/subservice/types' +import type { DoubleLevelVisibilityMatrix } from '@/services/visibility/types' + +const pageSize = 30 + +type PropTypes = { + collection: ExpandedImageCollection, + doubleLevelVisibility: DoubleLevelVisibilityMatrix | null, +} + +/** + * The client bridge between the server rendered collection page and the ImagePanel: binds the + * collection into the dynamic pager (a lambda the server page cannot create itself) with a stable + * identity per collection, and renders the collection admin beside the panel. Uploads remount the + * panel through its key, since new images invalidate every loaded page. + */ +export default function DynamicCollectionPanel({ collection, doubleLevelVisibility }: PropTypes) { + const [panelGeneration, setPanelGeneration] = useState(0) + + const readPageOfImagesInCollectionAction = useCallback( + (page: Page) => + readImagesPageInDynamicCollectionAction({ + params: { + paging: { page }, + collectionId: collection.id, + }, + }), + [collection.id] + ) + + return ( + <> +
+ +
+ setPanelGeneration(generation => generation + 1)} + /> + + ) +} diff --git a/src/app/images/collections/[id]/page.module.scss b/src/app/image-collections/dynamic/[name]/page.module.scss similarity index 100% rename from src/app/images/collections/[id]/page.module.scss rename to src/app/image-collections/dynamic/[name]/page.module.scss diff --git a/src/app/image-collections/dynamic/[name]/page.tsx b/src/app/image-collections/dynamic/[name]/page.tsx new file mode 100644 index 000000000..f5eb70530 --- /dev/null +++ b/src/app/image-collections/dynamic/[name]/page.tsx @@ -0,0 +1,41 @@ +import styles from './page.module.scss' +import DynamicCollectionPanel from './DynamicCollectionPanel' +import DoubleLevelVisibilityDescription + from '@/components/Visibility/DoubleLevelVisibilityDescription/DoubleLevelVisibilityDescription' +import { + readDynamicImageCollectionAction, + readDynamicImageCollectionDoubleLevelVisibilityAction, +} from '@/services/images/dynamic/actions' +import { notFound } from 'next/navigation' + +type PropTypes = { + params: Promise<{ + name: string + }> +} + +export default async function Collection({ params }: PropTypes) { + const collectionName = decodeURIComponent((await params).name) + + const readCollection = await readDynamicImageCollectionAction({ params: { collectionName } }) + if (!readCollection.success) notFound() //TODO: replace with better error page if error is UNAUTHORIZED. + const collection = readCollection.data + + const readDoubleLevelVisibility = await readDynamicImageCollectionDoubleLevelVisibilityAction({ + params: { collectionId: collection.id } + }) + const doubleLevelVisibility = readDoubleLevelVisibility.success ? readDoubleLevelVisibility.data : null + + return ( +
+

{collection.name}

+ {collection.description} + { + doubleLevelVisibility && ( + + ) + } + +
+ ) +} diff --git a/src/app/images/page.module.scss b/src/app/image-collections/page.module.scss similarity index 100% rename from src/app/images/page.module.scss rename to src/app/image-collections/page.module.scss diff --git a/src/app/image-collections/page.tsx b/src/app/image-collections/page.tsx new file mode 100644 index 000000000..ba0e2712b --- /dev/null +++ b/src/app/image-collections/page.tsx @@ -0,0 +1,71 @@ +import styles from './page.module.scss' +import MakeNewCollection from './MakeNewCollection' +import ImageCollectionList from './ImageCollectionList' +import ToggleShowAdminCollections from './ToggleShowAdminCollections' +import { DynamicImageCollectionPagingProvider } from '@/contexts/paging/DynamicImageCollectionPaging' +import CollectionCardLink from '@/components/Image/Collection/CollectionCardLink' +import { ServerSession } from '@/auth/session/ServerSession' +import { dynamicImageAuth } from '@/services/images/dynamic/auth' +import { readDynamicImageCollectionsPageAction } from '@/services/images/dynamic/actions' +import { QueryParams } from '@/lib/queryParams/queryParams' +import type { PageSizeDynamicImageCollection } from '@/contexts/paging/DynamicImageCollectionPaging' +import type { SearchParamsServerSide } from '@/lib/queryParams/types' + +type PropTypes = SearchParamsServerSide + +export default async function Images({ searchParams }: PropTypes) { + const session = await ServerSession.fromNextAuth() + const canCreateCollection = dynamicImageAuth.createCollection.dynamicFields({ }).auth(session) + const pageSize: PageSizeDynamicImageCollection = 12 + + const showOnlyCollectionsSessionAdministrates = + QueryParams.onlyAdministratedCollections.decode(await searchParams) ?? false + const details = { showOnlyCollectionsSessionAdministrates } + + const collectionPage = await readDynamicImageCollectionsPageAction({ + params: { + paging: { + page: { + pageSize, + page: 0, + cursor: null, + }, + details, + }, + }, + }) + + if (!collectionPage.success) { + throw collectionPage.error ? collectionPage.error[0].message : new Error('Unknown error') + } + + const collections = collectionPage.data + + return ( +
+
+ + +

Fotogalleri

+ {canCreateCollection.authorized && } +
+ + ( + + ))} + /> +
+
+
+ ) +} diff --git a/src/app/image-collections/special/[specialName]/SpecialCollectionPanel.tsx b/src/app/image-collections/special/[specialName]/SpecialCollectionPanel.tsx new file mode 100644 index 000000000..0b2dfa262 --- /dev/null +++ b/src/app/image-collections/special/[specialName]/SpecialCollectionPanel.tsx @@ -0,0 +1,34 @@ +'use client' +import ImagePanel from '@/components/Image/ImagePanel/ImagePanel' +import { specialImagePanels } from '@/services/images/specialPanels/constants' +import { useCallback } from 'react' +import type { ImagePanelCursor } from '@/components/Image/ImagePanel/ImagePanel' +import type { Page } from '@/lib/paging/types' +import type { SpecialCollection } from '@/prisma-generated-pn-types' + +const pageSize = 30 + +type PropTypes = { + special: SpecialCollection, +} + +/** + * The client bridge between the server rendered special collection page and the ImagePanel: binds + * the special collection into its own service's pager with a stable identity. Special collections + * are browsed only - no selection and no administration here. + */ +export default function SpecialCollectionPanel({ special }: PropTypes) { + const readPageOfImagesInCollectionAction = useCallback( + (page: Page) => + specialImagePanels[special].readPageOfImagesInCollectionAction({ params: { paging: { page } } }), + [special] + ) + + return ( + + ) +} diff --git a/src/app/image-collections/special/[specialName]/page.module.scss b/src/app/image-collections/special/[specialName]/page.module.scss new file mode 100644 index 000000000..02c2545d5 --- /dev/null +++ b/src/app/image-collections/special/[specialName]/page.module.scss @@ -0,0 +1,8 @@ +@use '@/styles/ohma'; + +.wrapper { + padding: 2em ohma.$minimalPagePadding; + > h1 { + font-size: ohma.$fonts-xxl; + } +} diff --git a/src/app/image-collections/special/[specialName]/page.tsx b/src/app/image-collections/special/[specialName]/page.tsx new file mode 100644 index 000000000..23c34f5b3 --- /dev/null +++ b/src/app/image-collections/special/[specialName]/page.tsx @@ -0,0 +1,31 @@ +import styles from './page.module.scss' +import SpecialCollectionPanel from './SpecialCollectionPanel' +import { specialImagePanels } from '@/services/images/specialPanels/constants' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { notFound } from 'next/navigation' +import type { SpecialCollection } from '@/prisma-generated-pn-types' + +type PropTypes = { + params: Promise<{ + specialName: string + }> +} + +const isSpecialCollection = (value: string): value is SpecialCollection => value in specialImagePanels + +export default async function SpecialImageCollection({ params }: PropTypes) { + const specialName = decodeURIComponent((await params).specialName) + if (!isSpecialCollection(specialName)) notFound() + + const collection = unwrapActionReturn(await specialImagePanels[specialName].readCollectionAction()) + + return ( +
+

{collection.name}

+ {collection.description} +
+ +
+
+ ) +} diff --git a/src/app/images/MakeNewCollection.tsx b/src/app/images/MakeNewCollection.tsx deleted file mode 100644 index fa85c962f..000000000 --- a/src/app/images/MakeNewCollection.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' -import styles from './MakeNewCollection.module.scss' -import Form from '@/components/Form/Form' -import PopUp from '@/components/PopUp/PopUp' -import TextInput from '@/components/UI/TextInput' -import { createImageCollectionAction } from '@/services/images/collections/actions' -import { faPlus } from '@fortawesome/free-solid-svg-icons' -import { useRouter } from 'next/navigation' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { v4 as uuid } from 'uuid' -import type { ImageCollection } from '@/prisma-generated-pn-types' - -export default function MakeNewCollection() { - const router = useRouter() - const collectionCreatedCallback = (collection?: ImageCollection) => { - if (collection) router.push(`/images/collections/${collection.id}`) - router.refresh() - } - return ( - }> -
- - - - -
-
- ) -} diff --git a/src/app/images/collections/[id]/CollectionAdmin.tsx b/src/app/images/collections/[id]/CollectionAdmin.tsx deleted file mode 100644 index a4d1617fd..000000000 --- a/src/app/images/collections/[id]/CollectionAdmin.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client' -import styles from './CollectionAdmin.module.scss' -import CollectionAdminUpload from './CollectionAdminUpload' -import { updateImageCollectionAction, destroyImageCollectionAction } from '@/services/images/collections/actions' -import Form from '@/components/Form/Form' -import TextInput from '@/components/UI/TextInput' -import { ImagePagingContext } from '@/contexts/paging/ImagePaging' -import ImageUploader from '@/components/Image/ImageUploader' -import PopUp from '@/components/PopUp/PopUp' -import useEditMode from '@/hooks/useEditMode' -import { RequireNothing } from '@/auth/authorizer/RequireNothing' -import Button from '@/components/UI/Button' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCog, faEye, faUpload } from '@fortawesome/free-solid-svg-icons' -import { useRouter } from 'next/navigation' -import { useContext, useState } from 'react' -import type { VisibilityMatrix } from '@/services/visibility/types' -import type { ExpandedImageCollection } from '@/services/images/collections/types' - -type PropTypes = { - collection: ExpandedImageCollection - visibilityAdmin: VisibilityMatrix - visibilityRead: VisibilityMatrix -} - -export default function CollectionAdmin({ collection, visibilityAdmin, visibilityRead }: PropTypes) { - console.log(visibilityAdmin, visibilityRead) - const { id: collectionId } = collection - const router = useRouter() - const pagingContext = useContext(ImagePagingContext) - //TODO: Use correct authorizer. - const canEdit = useEditMode({ - authorizer: RequireNothing.staticFields({}).dynamicFields({}) - }) - const [uploadOption, setUploadOption] = useState<'MANY' | 'ONE'>('MANY') - if (!canEdit) return null - - const refreshImages = () => { - if (pagingContext && pagingContext?.startPage.pageSize > pagingContext.state.data.length) { - pagingContext?.refetch() - } else { - router.refresh() - } - } - - return ( - <> -
- - }> -
- { - uploadOption === 'MANY' ? ( - <> - - - - ) : ( - <> - - - - ) - } -
-
- - }> -
- - - -
router.push('/images')} - action={destroyImageCollectionAction.bind(null, collectionId)} - submitColor="red" - confirmation={{ - confirm: true, - text: 'Er du sikker på at du vil slette samlingen. Dette vil også slette alle bilder i salingen.' - }} - /> - - - }> -
- {/* VisibilityAdmin... */} -
-
-
- - ) -} diff --git a/src/app/images/collections/[id]/page.tsx b/src/app/images/collections/[id]/page.tsx deleted file mode 100644 index de75a2d0f..000000000 --- a/src/app/images/collections/[id]/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import styles from './page.module.scss' -import CollectionAdmin from './CollectionAdmin' -import ImageList from '@/components/Image/ImageList/ImageList' -import { ImagePagingProvider } from '@/contexts/paging/ImagePaging' -import ImageListImage from '@/components/Image/ImageList/ImageListImage' -import ImageDisplayProvider from '@/contexts/ImageDisplayProvider' -import { readImageCollectionAction } from '@/services/images/collections/actions' -import { readImagesPageAction } from '@/services/images/actions' -import { notFound } from 'next/navigation' -import type { PageSizeImage } from '@/contexts/paging/ImagePaging' -import type { VisibilityMatrix } from '@/services/visibility/types' - -type PropTypes = { - params: Promise<{ - id: string - }> -} - -export default async function Collection({ params }: PropTypes) { - const pageSize: PageSizeImage = 30 - - const readCollection = await readImageCollectionAction(Number((await params).id)) - if (!readCollection.success) notFound() //TODO: replace with better error page if error is UNAUTHORIZED. - const collection = readCollection.data - - const readImages = await readImagesPageAction.bind(null, { - params: { - paging: { - page: { pageSize, page: 0, cursor: null }, - details: { collectionId: collection.id } - } - } - })() - if (!readImages.success) notFound() - const images = readImages.data - - return ( - - -
-

{collection.name}

- {collection.description} -
- ) - } /> -
- -
-
-
- ) -} diff --git a/src/app/images/page.tsx b/src/app/images/page.tsx deleted file mode 100644 index faa571a78..000000000 --- a/src/app/images/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import styles from './page.module.scss' -import MakeNewCollection from './MakeNewCollection' -import ImageCollectionList from '@/components/Image/Collection/ImageCollectionList' -import { ImageCollectionPagingProvider } from '@/contexts/paging/ImageCollectionPaging' -import CollectionCard from '@/components/Image/Collection/CollectionCard' -import { ServerSession } from '@/auth/session/ServerSession' -import { readImageCollectionsPageAction } from '@/services/images/collections/actions' -import type { PageSizeImageCollection } from '@/contexts/paging/ImageCollectionPaging' - -export default async function Images() { - const { user } = await ServerSession.fromNextAuth() - - const isAdmin = user?.username === 'harambe' //TODO: temp - const pageSize: PageSizeImageCollection = 12 - - const collectionPage = await readImageCollectionsPageAction({ - page: { - pageSize, - page: 0, - cursor: null - }, - details: undefined, - }) - - if (!collectionPage.success) { - throw collectionPage.error ? collectionPage.error[0].message : new Error('Unknown error') - } - - const collections = collectionPage.data - - return ( -
-
- - -

Fotogalleri

- {isAdmin && } -
- ( - - ))} - /> -
-
-
- ) -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2fb96f7a1..dbefed55a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,8 +6,9 @@ import Footer from '@/components/Footer/Footer' import { authOptions } from '@/auth/nextAuth/authOptions' import EditModeProvider from '@/contexts/EditMode' import PopUpProvider from '@/contexts/PopUp' -import DefaultPermissionsProvider from '@/contexts/DefaultPermissions' +import ClientDataProvider from '@/contexts/ClientData' import { readDefaultPermissionsAction } from '@/services/permissions/actions' +import { readAllStandardImagesAction } from '@/services/images/standard/actions' import { Inter } from 'next/font/google' import '@/styles/globals.scss' import { config } from '@fortawesome/fontawesome-svg-core' @@ -38,26 +39,34 @@ type PropTypes = { } export default async function RootLayout({ children }: PropTypes) { - const session = await getServerSession(authOptions) + const nextAuthSession = await getServerSession(authOptions) + const serverSession = await ServerSession.fromNextAuth() + const defaultPermissionsRes = await readDefaultPermissionsAction() - const defaultPermissions = defaultPermissionsRes.success ? defaultPermissionsRes.data : [] - const profile = session?.user ? - unwrapActionReturn(await readUserProfileAction({ params: { username: session.user.username } })) : null + const defaultPermissions = defaultPermissionsRes.success ? defaultPermissionsRes.data : undefined + const standardImagesRes = await readAllStandardImagesAction() + const standardImages = standardImagesRes.success ? standardImagesRes.data : undefined + const profile = serverSession?.user ? + unwrapActionReturn(await readUserProfileAction({ params: { username: serverSession.user.username } })) : null const canEditSpecialCmsImage = frontpageAuth.updateSpecialCmsImage.dynamicFields({}).auth( - await ServerSession.fromNextAuth() + serverSession ).toJsObject() return ( - - + +
- +
{children} @@ -66,12 +75,12 @@ export default async function RootLayout({ children }: PropTypes) {
- +
-
+
diff --git a/src/app/loading.tsx b/src/app/loading.tsx index 5a1c1e1e8..2abef1b6a 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -1,13 +1,10 @@ import styles from './loading.module.scss' -import { ServerSession } from '@/auth/session/ServerSession' import Loader from '@/components/Loader/Loader' -export default async function loading() { - const session = await ServerSession.fromNextAuth() - +export default function loading() { return (
- +
) } diff --git a/src/app/lockers/[id]/page.tsx b/src/app/lockers/[id]/page.tsx index 26060dabc..51e1e9cd1 100644 --- a/src/app/lockers/[id]/page.tsx +++ b/src/app/lockers/[id]/page.tsx @@ -4,7 +4,8 @@ import CreateLockerReservationForm from './CreateLockerReservationForm' import UpdateLockerReservationForm from './UpdateLockerReservationForm' import PageWrapper from '@/components/PageWrapper/PageWrapper' import { readLockerAction } from '@/services/lockers/actions' -import { checkGroupValidity, groupOperations, inferGroupName } from '@/services/groups/operations' +import { assertGroupValidity, groupOperations } from '@/services/groups/operations' +import { inferGroupName } from '@/lib/groups/inferGroupName' import { RequireUser } from '@/auth/authorizer/RequireUser' import { ServerSession } from '@/auth/session/ServerSession' @@ -24,7 +25,7 @@ export default async function Locker({ params }: PropTypes) { const isReserved = locker.data.LockerReservation.length > 0 const reservation = locker.data.LockerReservation[0] - const groupName = (isReserved && reservation.group) ? inferGroupName(checkGroupValidity(reservation.group)) : '' + const groupName = (isReserved && reservation.group) ? inferGroupName(assertGroupValidity(reservation.group)) : '' const user = RequireUser.staticFields({}).dynamicFields({}).auth( await ServerSession.fromNextAuth() diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 9ec5417a2..26d049b05 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,27 +1,14 @@ import styles from './not-found.module.scss' -import { readSpecialCmsImageFrontpage, updateSpecialCmsImageFrontpage } from '@/services/frontpage/actions' -import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' -import { ServerSession } from '@/auth/session/ServerSession' -import { frontpageAuth } from '@/services/frontpage/auth' - -export default async function Error404() { - const session = await ServerSession.fromNextAuth() +import StandardImageServer from '@/components/Image/StandardImageServer' +export default function Error404() { return (
-

404 - Page not found

diff --git a/src/app/ombul/CreateOmbul.tsx b/src/app/ombul/CreateOmbul.tsx index c499fb968..4b200efcf 100644 --- a/src/app/ombul/CreateOmbul.tsx +++ b/src/app/ombul/CreateOmbul.tsx @@ -20,9 +20,7 @@ type PropTypes = { type PreviewKey = keyof PropTypesPreview /** - * This component is for creating ombul issues. Since it needs to be able to choose a image - * it must be able to consume ImageSelectionContext, so it **must** be rendered inside - * ImageSelectionProvider. + * This component is for creating ombul issues. * @param latestOmbul - The latest ombul issue, used to set default values for year and issueNumber of next ombul */ export default function CreateOmbul({ latestOmbul }: PropTypes) { diff --git a/src/app/ombul/OmbulCover.tsx b/src/app/ombul/OmbulCover.tsx index 8a6cf1c32..4e8e56bc5 100644 --- a/src/app/ombul/OmbulCover.tsx +++ b/src/app/ombul/OmbulCover.tsx @@ -45,9 +45,7 @@ export default function OmbulCover(props: PropTypes) { coverImage instanceof File ? ( placeholderCover ) : ( - coverImage.image && ( - - ) + ) }
diff --git a/src/app/ombul/[...yearAndName]/OmbulAdmin.module.scss b/src/app/ombul/[...yearAndName]/OmbulAdmin.module.scss index 1b56698bb..3f973b87b 100644 --- a/src/app/ombul/[...yearAndName]/OmbulAdmin.module.scss +++ b/src/app/ombul/[...yearAndName]/OmbulAdmin.module.scss @@ -13,8 +13,14 @@ color: ohma.$colors-white; } .coverImage { - border-radius: ohma.$rounding; - overflow: hidden; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1em; + > *:first-child { + border-radius: ohma.$rounding; + overflow: hidden; + } } > .left { > *:not(:last-child) { diff --git a/src/app/ombul/[...yearAndName]/OmbulAdmin.tsx b/src/app/ombul/[...yearAndName]/OmbulAdmin.tsx index 54cb6b589..804430816 100644 --- a/src/app/ombul/[...yearAndName]/OmbulAdmin.tsx +++ b/src/app/ombul/[...yearAndName]/OmbulAdmin.tsx @@ -2,36 +2,43 @@ import styles from './OmbulAdmin.module.scss' import Form from '@/components/Form/Form' -import { updateOmbulAction, updateOmbulFileAction, destroyOmbulAction } from '@/services/ombul/actions' +import Textarea from '@/components/UI/Textarea' +import Image from '@/components/Image/Image' +import ImageUploader from '@/components/Image/ImageUploader' +import { + updateOmbulAction, + updateOmbulFileAction, + destroyOmbulAction, + updateOmbulCoverImageAction +} from '@/services/ombul/actions' import NumberInput from '@/components/UI/NumberInput' import FileInput from '@/components/UI/FileInput' import useEditMode from '@/hooks/useEditMode' +import useAuthorizer from '@/hooks/useAuthorizer' import { ombulAuth } from '@/services/ombul/auth' import { configureAction } from '@/services/configureAction' import { useRouter } from 'next/navigation' -import type { ReactNode } from 'react' import type { ExpandedOmbul } from '@/services/ombul/types' type PropTypes = { ombul: ExpandedOmbul - children: ReactNode } /** - * The admin panel for the ombul to change cover image (using cms image) anf update year, number and file. + * The admin panel for the ombul to change cover image and update year, number, description and file. * The component is only shown if editmode is enabled. * @param ombul - The obul (expanded) to be edited - * @param children - The cmsimage cover. Rendered on server side. * @returns */ -export default function OmbulAdmin({ - ombul, - children, -}: PropTypes) { +export default function OmbulAdmin({ ombul }: PropTypes) { const { push, refresh } = useRouter() const canUpdate = useEditMode({ authorizer: ombulAuth.update.dynamicFields({}) }) + const canUpdateCoverAuthResult = useAuthorizer({ + authorizer: ombulAuth.updateCoverImage.dynamicFields({}) + }) + const canUpdateCover = useEditMode({ authResult: canUpdateCoverAuthResult }) const canDestroy = useEditMode({ authorizer: ombulAuth.destroy.dynamicFields({}) }) @@ -55,7 +62,7 @@ export default function OmbulAdmin({ push('/ombul') refresh() } - if (!canUpdate && !canDestroy) return null + if (!canUpdate && !canDestroy && !canUpdateCover) return null return (
@@ -79,6 +86,11 @@ export default function OmbulAdmin({ label="Nummer" defaultValue={ombul.issueNumber} /> +