@sjolystinnovation/app-kit 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -660,6 +660,70 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
660
660
  | `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
661
661
  | `children` | `ReactNode` | | Content. |
662
662
 
663
+ ### `ContentImage`
664
+
665
+ From `@sjolystinnovation/app-kit/ui`. An uploaded image from the content API, as a plain `<img>`
666
+ with a `srcset` of the widths the API serves. It goes through the content images rewrite from
667
+ `defineAppConfig({ contentImages: true })`, so the browser never contacts the API host. It works in
668
+ a server component.
669
+
670
+ ```tsx
671
+ <ContentImage imageId={post.imageId} alt={post.title} sizes="(min-width: 768px) 50vw, 100vw" />
672
+ <ContentImage imageId={hero.imageId} fallbackSrc="/hero.jpg" alt="" sizes="100vw" priority aria-hidden />
673
+ ```
674
+
675
+ | Prop | Type | Default | Description |
676
+ | --- | --- | --- | --- |
677
+ | `imageId` | `string \| null` | | Uploaded image id. Null or empty shows `fallbackSrc` as it is, with no `srcset`. |
678
+ | `alt` | `string` | | Empty for a decorative image. |
679
+ | `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
680
+ | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
681
+ | `priority` | `boolean` | `false` | For the one image visible without scrolling. It loads eagerly, at high priority. |
682
+ | `width`, `height` | `number` | | Intrinsic size, to reserve space where the layout fixes no ratio. |
683
+ | `base` | `string` | `"/content-images"` | Path the content images rewrite serves. |
684
+ | `widths` | `number[]` | `CONTENT_IMAGE_WIDTHS` | Widths in the `srcset`. |
685
+ | `className` | `string` | | Classes on the `<img>`. |
686
+
687
+ `contentImagePath(id, base?)` and `contentImageSrcSet(id, widths?, base?)` build the same URLs for
688
+ other uses, such as a CSS background or an Open Graph image.
689
+
690
+ ### `ErrorState`
691
+
692
+ From `@sjolystinnovation/app-kit/ui`. What an `error.tsx` shows. It is a client component, as an
693
+ error boundary has to be.
694
+
695
+ ```tsx
696
+ "use client";
697
+
698
+ export default function Error({ reset }: { error: Error; reset: () => void }) {
699
+ return <ErrorState reset={reset} homeHref="/" />;
700
+ }
701
+ ```
702
+
703
+ | Prop | Type | Description |
704
+ | --- | --- | --- |
705
+ | `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
706
+ | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
707
+ | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain` and `home`. |
708
+ | `className` | `string` | Classes for placing it, on the outer element. |
709
+
710
+ The error itself is never shown.
711
+
712
+ ### `NotFoundState`
713
+
714
+ From `@sjolystinnovation/app-kit/ui`. What a `not-found.tsx` shows: a large code, the message as
715
+ the page's heading, and a link home. It works in a server component.
716
+
717
+ ```tsx
718
+ <NotFoundState homeHref="/no" strings={{ body: dict.notFound, home: dict.toFrontPage }} />
719
+ ```
720
+
721
+ | Prop | Type | Description |
722
+ | --- | --- | --- |
723
+ | `homeHref` | `string` | Where the home link goes. |
724
+ | `strings` | `TextOverrides<NotFoundStrings>` | `code`, `body` and `home`. |
725
+ | `className` | `string` | Classes for placing it, on the outer element. |
726
+
663
727
  ### `PasswordInput`
664
728
 
665
729
  A labelled password field with a button that shows and hides the value. It also takes the other
@@ -767,6 +831,11 @@ selector such as `.dark` for a scoped theme:
767
831
  | `--color-dialog-bg` | white |
768
832
  | `--color-dialog-title` | gray 900 |
769
833
  | `--color-dialog-text` | gray 600 |
834
+ | `--color-state-heading` | gray 900, the heading of an `ErrorState` |
835
+ | `--color-state-text` | gray 600, the text of an `ErrorState` or `NotFoundState` |
836
+ | `--color-state-code` | gray 900, the code of a `NotFoundState` |
837
+ | `--font-state-code` | `inherit`, the font of that code |
838
+ | `--color-state-link`, `-hover` | gray 900, the home link |
770
839
 
771
840
  A default that points at another variable is read once for the whole page. The focus ring and the
772
841
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
package/src/styles.css CHANGED
@@ -45,6 +45,13 @@
45
45
  --color-toggle-off: var(--color-gray-200);
46
46
  --color-toggle-thumb: var(--color-white);
47
47
 
48
+ --color-state-heading: var(--color-gray-900);
49
+ --color-state-text: var(--color-gray-600);
50
+ --color-state-code: var(--color-gray-900);
51
+ --font-state-code: inherit;
52
+ --color-state-link: var(--color-gray-900);
53
+ --color-state-link-hover: var(--color-gray-900);
54
+
48
55
  --radius-button: var(--radius-lg);
49
56
  --color-button-primary-bg: var(--color-primary);
50
57
  --color-button-primary-text: var(--color-primary-foreground);
@@ -0,0 +1,73 @@
1
+ /** The widths the API serves an uploaded image at, as webp. */
2
+ export const CONTENT_IMAGE_WIDTHS: readonly number[] = [384, 640, 768, 1024, 1366, 1600];
3
+
4
+ /** Where the browser fetches an uploaded image. Site-relative, through the app's content images rewrite. */
5
+ export function contentImagePath(id: string, base = "/content-images"): string {
6
+ return `${base}/${encodeURIComponent(id)}`;
7
+ }
8
+
9
+ /**
10
+ * The `srcset` for an uploaded image. The API serves the nearest webp at or above the requested
11
+ * width, or the original when the browser does not accept webp.
12
+ */
13
+ export function contentImageSrcSet(id: string, widths: readonly number[] = CONTENT_IMAGE_WIDTHS, base?: string): string {
14
+ return widths.map(w => `${contentImagePath(id, base)}?w=${w} ${w}w`).join(", ");
15
+ }
16
+
17
+ export interface ContentImageProps {
18
+ /** Uploaded image id. Null falls back to `fallbackSrc`, which is served as it is. */
19
+ imageId: string | null;
20
+ /** Empty for an image that carries no meaning of its own, such as a decorative backdrop. */
21
+ alt: string;
22
+ /** Widths the image is shown at. Required, since a browser assumes 100vw without it. */
23
+ sizes: string;
24
+ /** A static path for when there is no uploaded image. */
25
+ fallbackSrc?: string | undefined;
26
+ className?: string | undefined;
27
+ /** Intrinsic size, to reserve space. Only needed where the layout fixes no ratio. */
28
+ width?: number | undefined;
29
+ height?: number | undefined;
30
+ /** Set on the one image visible without scrolling. It loads eagerly, at high priority. */
31
+ priority?: boolean | undefined;
32
+ "aria-hidden"?: boolean | undefined;
33
+ /** Path the content images rewrite serves, `/content-images` by default. */
34
+ base?: string | undefined;
35
+ /** Widths in the `srcset`. Defaults to the widths the API serves. */
36
+ widths?: readonly number[] | undefined;
37
+ }
38
+
39
+ /** An image from the content API, served responsively from the `sizes` a call site declares. */
40
+ export function ContentImage({
41
+ imageId,
42
+ alt,
43
+ sizes,
44
+ fallbackSrc,
45
+ className,
46
+ width,
47
+ height,
48
+ priority = false,
49
+ "aria-hidden": ariaHidden,
50
+ base,
51
+ widths,
52
+ }: ContentImageProps) {
53
+ const uploaded = imageId != null && imageId !== "";
54
+ const src = uploaded ? contentImagePath(imageId, base) : fallbackSrc;
55
+ if (!src) return null;
56
+
57
+ return (
58
+ // A plain img, because the API already serves the sizes. next/image would resize them again.
59
+ <img
60
+ src={src}
61
+ srcSet={uploaded ? contentImageSrcSet(imageId, widths, base) : undefined}
62
+ sizes={uploaded ? sizes : undefined}
63
+ alt={alt}
64
+ className={className}
65
+ width={width}
66
+ height={height}
67
+ loading={priority ? "eager" : "lazy"}
68
+ fetchPriority={priority ? "high" : undefined}
69
+ decoding={priority ? "sync" : "async"}
70
+ aria-hidden={ariaHidden}
71
+ />
72
+ );
73
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { cx } from "./cx";
5
+ import { withDefaults, type TextOverrides } from "./withDefaults";
6
+
7
+ export interface ErrorStateStrings {
8
+ title: string;
9
+ body: string;
10
+ tryAgain: string;
11
+ home: string;
12
+ }
13
+
14
+ export const defaultErrorStateStrings: ErrorStateStrings = {
15
+ title: "Temporarily unavailable",
16
+ body: "We can't reach the service right now. Please try again shortly.",
17
+ tryAgain: "Try again",
18
+ home: "Go to the front page",
19
+ };
20
+
21
+ export interface ErrorStateProps {
22
+ /** The `reset` Next passes to an `error.tsx`. It renders the page again. */
23
+ reset: () => void;
24
+ /** Where the front page link goes, such as `/` or `/en`. */
25
+ homeHref: string;
26
+ strings?: TextOverrides<ErrorStateStrings> | undefined;
27
+ /** Classes for placing it, on the outer element. */
28
+ className?: string | undefined;
29
+ }
30
+
31
+ /** What an `error.tsx` shows when a page cannot be rendered. The response is still a 500. */
32
+ export function ErrorState({ reset, homeHref, strings, className }: ErrorStateProps) {
33
+ const text = withDefaults(defaultErrorStateStrings, strings);
34
+ return (
35
+ <div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
36
+ <h1 className="text-3xl font-black text-state-heading">{text.title}</h1>
37
+ <p className="text-lg text-state-text max-w-md">{text.body}</p>
38
+ <div className="mt-2 flex flex-wrap items-center justify-center gap-4">
39
+ <button
40
+ type="button"
41
+ onClick={reset}
42
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-6 py-3 hover:brightness-95 transition"
43
+ >
44
+ {text.tryAgain}
45
+ </button>
46
+ <Link href={homeHref} className="font-semibold text-state-link hover:text-state-link-hover underline">
47
+ {text.home}
48
+ </Link>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,37 @@
1
+ import Link from "next/link";
2
+ import { cx } from "./cx";
3
+ import { withDefaults, type TextOverrides } from "./withDefaults";
4
+
5
+ export interface NotFoundStrings {
6
+ code: string;
7
+ body: string;
8
+ home: string;
9
+ }
10
+
11
+ export const defaultNotFoundStrings: NotFoundStrings = {
12
+ code: "404",
13
+ body: "This page does not exist.",
14
+ home: "Go home",
15
+ };
16
+
17
+ export interface NotFoundStateProps {
18
+ /** Where the home link goes, such as `/` or `/en`. */
19
+ homeHref: string;
20
+ strings?: TextOverrides<NotFoundStrings> | undefined;
21
+ /** Classes for placing it, on the outer element. */
22
+ className?: string | undefined;
23
+ }
24
+
25
+ /** What a `not-found.tsx` shows. The message is the page's heading. */
26
+ export function NotFoundState({ homeHref, strings, className }: NotFoundStateProps) {
27
+ const text = withDefaults(defaultNotFoundStrings, strings);
28
+ return (
29
+ <div className={cx("min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4 text-center", className)}>
30
+ <span className="text-6xl font-black font-state-code text-state-code">{text.code}</span>
31
+ <h1 className="text-lg text-state-text">{text.body}</h1>
32
+ <Link href={homeHref} className="font-semibold text-state-link hover:text-state-link-hover underline">
33
+ {text.home}
34
+ </Link>
35
+ </div>
36
+ );
37
+ }
package/src/ui/index.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  export { Alert } from "./Alert";
2
2
  export type { AlertProps } from "./Alert";
3
+ export { ContentImage, CONTENT_IMAGE_WIDTHS, contentImagePath, contentImageSrcSet } from "./ContentImage";
4
+ export type { ContentImageProps } from "./ContentImage";
3
5
  export { CredentialsForm } from "./CredentialsForm";
4
6
  export type { CredentialsFormProps } from "./CredentialsForm";
5
7
  export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
6
8
  export type { CredentialsFormStrings } from "./credentialsFormStrings";
9
+ export { ErrorState, defaultErrorStateStrings } from "./ErrorState";
10
+ export type { ErrorStateProps, ErrorStateStrings } from "./ErrorState";
11
+ export { NotFoundState, defaultNotFoundStrings } from "./NotFoundState";
12
+ export type { NotFoundStateProps, NotFoundStrings } from "./NotFoundState";
7
13
  export { PasswordInput } from "./PasswordInput";
8
14
  export type { PasswordInputProps } from "./PasswordInput";
9
15
  export { TextArea } from "./TextArea";