From 29180beafc28985ae4097d70a15fdc8b64b9c160 Mon Sep 17 00:00:00 2001 From: Roel Date: Thu, 31 Oct 2024 16:46:44 +0100 Subject: [PATCH] Refactor for client side --- .../templates/nextjs-site/src/app/layout.tsx | 2 +- .../nextjs-site/src/components/Image.tsx | 245 +++++++++++++++++- .../nextjs-site/src/components/NoSSR.tsx | 10 - .../src/components/ProviderWrapper.tsx | 3 + .../nextjs-site/src/views/DefaultView.tsx | 11 +- .../src/views/ListItem/BlogListItem.tsx | 17 +- .../src/views/ListItem/ListItemView.tsx | 7 +- 7 files changed, 264 insertions(+), 31 deletions(-) delete mode 100644 browser/create-template/templates/nextjs-site/src/components/NoSSR.tsx diff --git a/browser/create-template/templates/nextjs-site/src/app/layout.tsx b/browser/create-template/templates/nextjs-site/src/app/layout.tsx index 51b756f5..44974df5 100644 --- a/browser/create-template/templates/nextjs-site/src/app/layout.tsx +++ b/browser/create-template/templates/nextjs-site/src/app/layout.tsx @@ -8,7 +8,7 @@ import Footer from '@/components/Footer'; export const metadata: Metadata = { title: 'Next.js Atomic', - description: 'Next.js Atomic template', + description: 'A Next.js template for Atomic Server', }; export default function RootLayout({ diff --git a/browser/create-template/templates/nextjs-site/src/components/Image.tsx b/browser/create-template/templates/nextjs-site/src/components/Image.tsx index 48a84adc..6be48868 100644 --- a/browser/create-template/templates/nextjs-site/src/components/Image.tsx +++ b/browser/create-template/templates/nextjs-site/src/components/Image.tsx @@ -1,13 +1,242 @@ -'use client'; - -import { Image as AtomicImage } from '@tomic/react'; -import NoSSR from './NoSSR'; +import { store } from '@/app/store'; +import { type Resource, type Server, unknownSubject, server } from '@tomic/lib'; import React from 'react'; +import NextImage from 'next/image'; + +const imageFormatsWithBasicSupport = new Set([ + 'image/svg+xml', + 'image/vnd.adobe.photoshop', + 'image/heif', + 'image/heif-sequence', + 'image/heic-sequence', + 'image/avif-sequence', + 'image/gif', + 'image/heic', + 'image/heif', +]); + +const imageFormatsWithFullSupport = new Set([ + 'image/png', + 'image/jpeg', + 'image/vnd.microsoft.icon', + 'image/webp', + 'image/bmp', + 'image/tiff', + 'image/avif', +]); + +const DEFAULT_SIZES = [100, 300, 500, 800, 1200, 1600, 2000]; + +type SizeIndicationKey = `${number}px`; +type Unit = number | `${number}${'px' | 'vw' | 'em' | 'rem' | 'ch'}`; + +export type SizeIndication = + | { + [key: SizeIndicationKey]: Unit; + default: Unit; + } + | Unit; + +interface ImageInnerProps + extends Omit< + React.ImgHTMLAttributes, + 'resource' | 'src' + > { + resource: Resource; + /** + * SizeIndication is used to help the browser choose the right image size to fetch. + * By default, the browser looks at the entire viewport width and chooses the smallest version that still covers this width. + * This is often too big so we should help by giving it an approximation of the size of the image relative to the viewport. + * + * When the unit given is a number it is interpreted as a percentage of the viewport width. If your image is displayed in a static size you can also pass a string like '4rem'. + * Note that percentages don't work as the browser doesn't know the size of the parent element yet. + * + * ```jsx + * a person standing in front of a mountain + * ``` + * When the image size changes based on media queries we can give the browser a more detailed indication. + * ```jsx + * a person standing in front of a mountain + * ``` + */ + sizeIndication?: SizeIndication; + /** Alt text for the image, if you can't add alt text it's best practice to pass an empty string */ + alt: string; + /** Quality setting used by the image encoders, defaults to 60 (more than enough in most cases). Should be between 0 - 100 */ + quality?: number; +} + +export interface ImageProps extends Omit { + /** Subject of the file resource */ + subject: string; +} + +/** + * Takes the subject of a file resource and renders it as an image. + * Uses AtomicServer to automatically generate avif and webp versions of the image and scale them to different sizes. + * To help the browser choose the best size to load use the `sizeIndication` prop. + * + * Throws when the file is not an image. + * @example + * ```jsx + * a person standing in front of a mountain + * ``` + */ +export const Image: React.FC = async ({ subject, ...props }) => { + const resource = await store.getResource(subject); + const mimeType = resource.get(server.properties.mimetype); + // const [mimeType] = useString(resource, server.properties.mimetype); + + if (resource.loading || resource.subject === unknownSubject) { + return null; + } + + if (!resource.hasClasses(server.classes.file)) { + throw new Error('Incompatible resource class, resource is not a file'); + } + + // If the resource does have a file class but mimetype is still undefined, it's still loading so we return null until the value is available + if (mimeType === undefined) { + return null; + } + + if (imageFormatsWithBasicSupport.has(mimeType)) { + return ; + } + + if (!imageFormatsWithFullSupport.has(mimeType)) { + throw new Error('Incompatible or missing mime-type: ' + mimeType); + } + + return ; +}; -export const Image = ({ subject, alt }: { subject: string; alt: string }) => { +const ImageInner: React.FC = ({ + resource, + sizeIndication, + quality = 60, + ...props +}) => { + // const [downloadUrl] = useString(resource, server.properties.downloadUrl); + const downloadUrl = resource.get(server.properties.downloadUrl); + const toSrcSet = buildSrcSet(downloadUrl ?? ''); + + return ( + + + + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + + ); +}; + +const BasicImage: React.FC = ({ + resource, + sizeIndication: _sizeIndication, + quality: quality, + ...props // html image atrributes only +}) => { + // const [downloadUrl] = useString(resource, server.properties.downloadUrl); + const downloadUrl = resource.get(server.properties.downloadUrl); + + // eslint-disable-next-line jsx-a11y/alt-text return ( - - - + ); }; + +const indicationToSizes = (indication: SizeIndication | undefined): string => { + if (indication === undefined) { + return '100vw'; + } + + if (typeof indication === 'number' || typeof indication === 'string') { + return parseUnit(indication); + } + + return Object.entries(indication) + .map(([key, value]) => + key === 'default' + ? parseUnit(value) + : `(max-width: ${key}) ${parseUnit(value)}`, + ) + .join(', '); +}; + +const parseUnit = (unit: Unit): string => + typeof unit === 'number' ? `${unit}vw` : unit; + +const toUrl = ( + base: string, + format?: string, + quality?: number, + width?: number, +) => { + const url = new URL(base); + const queryParams = new URLSearchParams(); + format && queryParams.set('f', format); + width && queryParams.set('w', width.toString()); + quality && queryParams.set('q', quality.toString()); + url.search = queryParams.toString(); + + return url.toString(); +}; + +const buildSrcSet = + (base: string) => + (format: string, quality: number, sizes: number[]): string => { + return sizes + .map(size => { + return `${toUrl(base, format, quality, size)} ${size}w`; + }) + .join(', '); + }; diff --git a/browser/create-template/templates/nextjs-site/src/components/NoSSR.tsx b/browser/create-template/templates/nextjs-site/src/components/NoSSR.tsx deleted file mode 100644 index 06fbf435..00000000 --- a/browser/create-template/templates/nextjs-site/src/components/NoSSR.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import dynamic from 'next/dynamic'; -import React from 'react'; - -const NoSsr = (props: { children: React.ReactNode }) => ( - {props.children} -); - -export default dynamic(() => Promise.resolve(NoSsr), { - ssr: false, -}); diff --git a/browser/create-template/templates/nextjs-site/src/components/ProviderWrapper.tsx b/browser/create-template/templates/nextjs-site/src/components/ProviderWrapper.tsx index a561b50a..16edf855 100644 --- a/browser/create-template/templates/nextjs-site/src/components/ProviderWrapper.tsx +++ b/browser/create-template/templates/nextjs-site/src/components/ProviderWrapper.tsx @@ -3,12 +3,15 @@ import { StoreContext } from '@tomic/react'; import { CurrentSubjectProvider } from '@/app/context/CurrentSubjectContext'; import { store } from '@/app/store'; +import { initOntologies } from '@/ontologies'; const ProviderWrapper = ({ children, }: { children: Readonly; }) => { + initOntologies(); + return ( {children} diff --git a/browser/create-template/templates/nextjs-site/src/views/DefaultView.tsx b/browser/create-template/templates/nextjs-site/src/views/DefaultView.tsx index 686b6226..f53a205b 100644 --- a/browser/create-template/templates/nextjs-site/src/views/DefaultView.tsx +++ b/browser/create-template/templates/nextjs-site/src/views/DefaultView.tsx @@ -1,10 +1,11 @@ -import { store } from '@/app/store'; -import { Resource } from '@tomic/react'; +'use client'; -const DefaultView = async ({ resource }: { resource: Resource }) => { - const subjectResource = await store.getResource(resource.subject); +import { useResource } from '@tomic/react'; - return

No supported view for {subjectResource.title}.

; +const DefaultView = ({ subject }: { subject: string }) => { + const resource = useResource(subject); + + return

No supported view for {resource.title}.

; }; export default DefaultView; diff --git a/browser/create-template/templates/nextjs-site/src/views/ListItem/BlogListItem.tsx b/browser/create-template/templates/nextjs-site/src/views/ListItem/BlogListItem.tsx index c5cce22e..48f72720 100644 --- a/browser/create-template/templates/nextjs-site/src/views/ListItem/BlogListItem.tsx +++ b/browser/create-template/templates/nextjs-site/src/views/ListItem/BlogListItem.tsx @@ -1,17 +1,22 @@ +'use client'; + import { Blogpost } from '@/ontologies/website'; -import type { Resource } from '@tomic/react'; +import { core, useResource, useString, Image } from '@tomic/react'; import styles from './BlogListItem.module.css'; -import { Image } from '@/components/Image'; -const BlogListItem = async ({ resource }: { resource: Resource }) => { +const BlogListItem = ({ subject }: { subject: string }) => { const formatter = new Intl.DateTimeFormat('default', { year: 'numeric', month: 'long', day: 'numeric', }); - const date = formatter.format(new Date(resource.props.publishedAt)); + const resource = useResource(subject); + const [title] = useString(resource, core.properties.name); + const date = resource.props.publishedAt + ? formatter.format(new Date(resource.props.publishedAt)) + : ''; return (
@@ -19,9 +24,9 @@ const BlogListItem = async ({ resource }: { resource: Resource }) => {
{date}
-

{resource.title}

+

{title}

- {resource.props.description.slice(0, 300)}... + {resource.props.description?.slice(0, 300)}...

diff --git a/browser/create-template/templates/nextjs-site/src/views/ListItem/ListItemView.tsx b/browser/create-template/templates/nextjs-site/src/views/ListItem/ListItemView.tsx index 51db199c..932022a0 100644 --- a/browser/create-template/templates/nextjs-site/src/views/ListItem/ListItemView.tsx +++ b/browser/create-template/templates/nextjs-site/src/views/ListItem/ListItemView.tsx @@ -2,6 +2,7 @@ import { website } from '@/ontologies/website'; import BlogListItem from './BlogListItem'; import DefaultView from '@/views/DefaultView'; import { store } from '@/app/store'; +import { Suspense } from 'react'; const ListItemView = async ({ subject }: { subject: string }) => { const listItem = await store.getResource(subject); @@ -13,7 +14,11 @@ const ListItemView = async ({ subject }: { subject: string }) => { DefaultView, ); - return ; + return ( + loading...

}> + +
+ ); }; export default ListItemView;