Skip to content

Commit

Permalink
Refactor for client side
Browse files Browse the repository at this point in the history
  • Loading branch information
RoelLeijser committed Oct 31, 2024
1 parent 9988385 commit 29180be
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
245 changes: 237 additions & 8 deletions browser/create-template/templates/nextjs-site/src/components/Image.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLPictureElement>,
'resource' | 'src'
> {
resource: Resource<Server.File>;
/**
* 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
* <Image
* className='inline-image'
* subject='http://myatomicserver.com/files/1664878581079-hiker.jpg'
* alt='a person standing in front of a mountain'
* sizeIndication={50} // the image is about 50% of the viewport width
* />
* ```
* When the image size changes based on media queries we can give the browser a more detailed indication.
* ```jsx
* <Image
* className='inline-image'
* subject='http://myatomicserver.com/files/1664878581079-hiker.jpg'
* alt='a person standing in front of a mountain'
* sizeIndication={{
* '500px': 100, // On screens smaller than 500px the image is displayed at full width.
* default: 50, // the image is about 50% of the viewport when no media query matches
* }}
* />
* ```
*/
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<ImageInnerProps, 'resource'> {
/** 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
* <Image
* subject='http://myatomicserver.com/files/1664878581079-hiker.jpg'
* alt='a person standing in front of a mountain'
* className='article-inline-image'
* loading='lazy'
* sizeIndication={{
* '500px': 100, // On screens smaller than 500px the image is displayed at full width.
* default: 50, // the image is about 50% of the viewport when no media query matches
* }}
* />
* ```
*/
export const Image: React.FC<ImageProps> = 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 <BasicImage resource={resource} {...props} />;
}

if (!imageFormatsWithFullSupport.has(mimeType)) {
throw new Error('Incompatible or missing mime-type: ' + mimeType);
}

return <ImageInner resource={resource} {...props} />;
};

export const Image = ({ subject, alt }: { subject: string; alt: string }) => {
const ImageInner: React.FC<ImageInnerProps> = ({
resource,
sizeIndication,
quality = 60,
...props
}) => {
// const [downloadUrl] = useString(resource, server.properties.downloadUrl);
const downloadUrl = resource.get(server.properties.downloadUrl);
const toSrcSet = buildSrcSet(downloadUrl ?? '');

return (
<picture>
<source
srcSet={toSrcSet('avif', quality, DEFAULT_SIZES)}
type='image/avif'
sizes={indicationToSizes(sizeIndication)}
height={resource.props.imageHeight}
width={resource.props.imageWidth}
/>
<source
srcSet={toSrcSet('webp', quality, DEFAULT_SIZES)}
type='image/webp'
sizes={indicationToSizes(sizeIndication)}
height={resource.props.imageHeight}
width={resource.props.imageWidth}
/>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
src={downloadUrl}
{...props}
height={resource.props.imageHeight}
width={resource.props.imageWidth}
/>
</picture>
);
};

const BasicImage: React.FC<ImageInnerProps> = ({
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 (
<NoSSR>
<AtomicImage subject={subject} alt={alt} />
</NoSSR>
<NextImage
src={downloadUrl}
{...props}
width={
typeof props.width === 'string' ? parseInt(props.width) : props.width
}
height={
typeof props.height === 'string' ? parseInt(props.height) : props.height
}
/>
);
};

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(', ');
};

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ReactNode>;
}) => {
initOntologies();

return (
<StoreContext.Provider value={store}>
<CurrentSubjectProvider>{children}</CurrentSubjectProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <p>No supported view for {subjectResource.title}.</p>;
const DefaultView = ({ subject }: { subject: string }) => {
const resource = useResource(subject);

return <p>No supported view for {resource.title}.</p>;
};

export default DefaultView;
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
'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<Blogpost> }) => {
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<Blogpost>(subject);
const [title] = useString(resource, core.properties.name);

const date = resource.props.publishedAt
? formatter.format(new Date(resource.props.publishedAt))
: '';
return (
<a className={styles.card} href={resource.props.href}>
<div className={styles.imageWrapper}>
<Image subject={resource.props.coverImage} alt='' />
</div>
<div className={styles.cardContent}>
<div className={styles.publishDate}>{date}</div>
<h2 className={styles.h2}>{resource.title}</h2>
<h2 className={styles.h2}>{title}</h2>
<p className={styles.p}>
{resource.props.description.slice(0, 300)}...
{resource.props.description?.slice(0, 300)}...
</p>
</div>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -13,7 +14,11 @@ const ListItemView = async ({ subject }: { subject: string }) => {
DefaultView,
);

return <Component resource={listItem} />;
return (
<Suspense fallback={<p>loading...</p>}>
<Component subject={subject} />
</Suspense>
);
};

export default ListItemView;

0 comments on commit 29180be

Please sign in to comment.