@byline/ui 1.6.2 → 1.7.1

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.
@@ -1,8 +1,10 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
2
3
  import { isPendingStoredFileValue } from "@byline/core";
3
4
  import classnames from "classnames";
4
5
  import { useFieldError, useFieldValue, useFormContext, useIsDirty } from "../../forms/form-context.js";
5
6
  import { ErrorText } from "../../uikit.js";
7
+ import { ImageLightbox } from "../../widgets/image-lightbox/image-lightbox.js";
6
8
  import { useFieldChangeHandler } from "../use-field-change-handler.js";
7
9
  import image_field_module from "./image-field.module.js";
8
10
  import { ImageUploadField } from "./image-upload-field.js";
@@ -21,10 +23,14 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
21
23
  return 'placeholder' === maybe.storageProvider && 'pending' === maybe.storagePath;
22
24
  };
23
25
  const showUploadWidget = null == incomingValue || isOldPlaceholder(incomingValue);
26
+ const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue) ? incomingValue.variants?.find((v)=>'thumbnail' === v.name) : void 0;
27
+ const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl;
24
28
  const handleRemove = ()=>{
25
29
  if (isPending) removePendingUpload(fieldPath);
26
30
  handleChange(null);
27
31
  };
32
+ const [lightboxOpen, setLightboxOpen] = useState(false);
33
+ const canOpenLightbox = !isPending && !!incomingValue?.storageUrl;
28
34
  return /*#__PURE__*/ jsxs("div", {
29
35
  className: `byline-field-image ${field.name}`,
30
36
  children: [
@@ -67,11 +73,24 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
67
73
  }) : /*#__PURE__*/ jsxs("div", {
68
74
  className: classnames('byline-field-image-tile', image_field_module.tile),
69
75
  children: [
70
- incomingValue?.storageUrl && /*#__PURE__*/ jsxs("div", {
76
+ previewUrl && /*#__PURE__*/ jsxs("div", {
71
77
  className: classnames('byline-field-image-preview-wrap', image_field_module["preview-wrap"]),
72
78
  children: [
73
- /*#__PURE__*/ jsx("img", {
74
- src: incomingValue.storageUrl,
79
+ canOpenLightbox ? /*#__PURE__*/ jsx("button", {
80
+ type: "button",
81
+ onClick: ()=>setLightboxOpen(true),
82
+ "aria-label": "Open full-size preview",
83
+ className: classnames('byline-field-image-preview-button', image_field_module["preview-button"]),
84
+ children: /*#__PURE__*/ jsx("img", {
85
+ src: previewUrl,
86
+ alt: incomingValue.originalFilename ?? incomingValue.filename,
87
+ className: classnames('byline-field-image-preview', image_field_module.preview, 'image/svg+xml' === incomingValue.mimeType && [
88
+ 'byline-field-image-preview-svg',
89
+ image_field_module["preview-svg"]
90
+ ])
91
+ })
92
+ }) : /*#__PURE__*/ jsx("img", {
93
+ src: previewUrl,
75
94
  alt: incomingValue.originalFilename ?? incomingValue.filename,
76
95
  className: classnames('byline-field-image-preview', image_field_module.preview, 'image/svg+xml' === incomingValue.mimeType && [
77
96
  'byline-field-image-preview-svg',
@@ -179,7 +198,7 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
179
198
  children: "Thumbnail:"
180
199
  }),
181
200
  ' ',
182
- incomingValue?.thumbnailGenerated ? 'Generated' : 'Pending'
201
+ thumbVariant ? 'Generated' : 'Pending'
183
202
  ]
184
203
  })
185
204
  ]
@@ -191,6 +210,20 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
191
210
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
192
211
  id: `${field.name}-error`,
193
212
  text: fieldError
213
+ }),
214
+ canOpenLightbox && incomingValue?.storageUrl && /*#__PURE__*/ jsx(ImageLightbox, {
215
+ isOpen: lightboxOpen,
216
+ onDismiss: ()=>setLightboxOpen(false),
217
+ src: incomingValue.storageUrl,
218
+ alt: incomingValue.originalFilename ?? incomingValue.filename,
219
+ downloadFilename: incomingValue.originalFilename ?? incomingValue.filename,
220
+ title: incomingValue.originalFilename ?? incomingValue.filename,
221
+ meta: {
222
+ width: incomingValue.imageWidth,
223
+ height: incomingValue.imageHeight,
224
+ fileSize: incomingValue.fileSize,
225
+ mimeType: incomingValue.mimeType
226
+ }
194
227
  })
195
228
  ]
196
229
  });
@@ -8,6 +8,8 @@ const image_field_module = {
8
8
  tile: "tile-izbLn0",
9
9
  "preview-wrap": "preview-wrap-Cjr0PD",
10
10
  previewWrap: "preview-wrap-Cjr0PD",
11
+ "preview-button": "preview-button-LAsENX",
12
+ previewButton: "preview-button-LAsENX",
11
13
  preview: "preview-DIASGB",
12
14
  "preview-svg": "preview-svg-sCwreb",
13
15
  previewSvg: "preview-svg-sCwreb",
@@ -51,6 +51,30 @@
51
51
  position: relative;
52
52
  }
53
53
 
54
+ :is(.preview-button-LAsENX, .byline-field-image-preview-button) {
55
+ appearance: none;
56
+ cursor: pointer;
57
+ border-radius: var(--border-radius-sm);
58
+ background: none;
59
+ border: none;
60
+ margin: 0;
61
+ padding: 0;
62
+ display: block;
63
+ }
64
+
65
+ :is(.preview-button-LAsENX:focus-visible, .byline-field-image-preview-button:focus-visible) {
66
+ outline: 2px solid var(--primary-500);
67
+ outline-offset: 2px;
68
+ }
69
+
70
+ :is(.preview-button-LAsENX img, .byline-field-image-preview-button img) {
71
+ transition: opacity .12s;
72
+ }
73
+
74
+ :is(.preview-button-LAsENX:hover img, .byline-field-image-preview-button:hover img) {
75
+ opacity: .85;
76
+ }
77
+
54
78
  :is(.preview-DIASGB, .byline-field-image-preview) {
55
79
  border: var(--border-width-thin) var(--border-style-solid) var(--gray-600);
56
80
  border-radius: var(--border-radius-sm);
package/dist/uikit.d.ts CHANGED
@@ -103,6 +103,7 @@ export * from './loaders/spinner.js';
103
103
  export * from './widgets/datepicker/datepicker.js';
104
104
  export * from './widgets/drawer/drawer.js';
105
105
  export * from './widgets/drawer/drawer-context.js';
106
+ export * from './widgets/image-lightbox/image-lightbox.js';
106
107
  export * from './widgets/modal/modal.js';
107
108
  export * from './widgets/search/search.js';
108
109
  export * from './widgets/timeline/timeline.js';
package/dist/uikit.js CHANGED
@@ -98,6 +98,7 @@ export * from "./loaders/spinner.js";
98
98
  export * from "./widgets/datepicker/datepicker.js";
99
99
  export * from "./widgets/drawer/drawer.js";
100
100
  export * from "./widgets/drawer/drawer-context.js";
101
+ export * from "./widgets/image-lightbox/image-lightbox.js";
101
102
  export * from "./widgets/modal/modal.js";
102
103
  export * from "./widgets/search/search.js";
103
104
  export * from "./widgets/timeline/timeline.js";
@@ -0,0 +1,25 @@
1
+ export interface ImageLightboxProps {
2
+ isOpen: boolean;
3
+ onDismiss: () => void;
4
+ /** Full-resolution image URL. */
5
+ src: string;
6
+ /** Alt text — falls back to filename. */
7
+ alt?: string;
8
+ /** Used as the `download` attribute on the download link. */
9
+ downloadFilename?: string;
10
+ /** Header label. Defaults to `downloadFilename`. */
11
+ title?: string;
12
+ /** Optional metadata row beneath the image. */
13
+ meta?: {
14
+ width?: number | null;
15
+ height?: number | null;
16
+ fileSize?: number | null;
17
+ mimeType?: string | null;
18
+ };
19
+ }
20
+ /**
21
+ * ImageLightbox — modal preview of a full-resolution image with a download
22
+ * affordance. Right-click on the image works natively (browser "Save image
23
+ * as…") because it's a regular `<img src>`.
24
+ */
25
+ export declare function ImageLightbox({ isOpen, onDismiss, src, alt, downloadFilename, title, meta, }: ImageLightboxProps): import("react").JSX.Element;
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import classnames from "classnames";
4
+ import { IconButton } from "../../components/button/icon-button.js";
5
+ import { CloseIcon } from "../../icons/close-icon.js";
6
+ import { DownloadIcon } from "../../icons/download-icon.js";
7
+ import { Modal } from "../modal/modal.js";
8
+ import image_lightbox_module from "./image-lightbox.module.js";
9
+ function formatFileSize(bytes) {
10
+ if (bytes < 1024) return `${bytes} B`;
11
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
12
+ return `${(bytes / 1048576).toFixed(1)} MB`;
13
+ }
14
+ function triggerDownload(url, filename) {
15
+ if ("u" < typeof document) return;
16
+ const a = document.createElement('a');
17
+ a.href = url;
18
+ if (filename) a.download = filename;
19
+ a.target = '_blank';
20
+ a.rel = 'noreferrer';
21
+ document.body.appendChild(a);
22
+ a.click();
23
+ document.body.removeChild(a);
24
+ }
25
+ function ImageLightbox({ isOpen, onDismiss, src, alt, downloadFilename, title, meta }) {
26
+ const headingText = title ?? downloadFilename ?? 'Image';
27
+ const altText = alt ?? downloadFilename ?? 'Image preview';
28
+ return /*#__PURE__*/ jsx(Modal, {
29
+ isOpen: isOpen,
30
+ closeOnOverlayClick: true,
31
+ onDismiss: onDismiss,
32
+ children: /*#__PURE__*/ jsxs(Modal.Container, {
33
+ className: classnames('byline-image-lightbox-container', image_lightbox_module.container),
34
+ children: [
35
+ /*#__PURE__*/ jsxs(Modal.Header, {
36
+ className: classnames('byline-image-lightbox-header', image_lightbox_module.header),
37
+ children: [
38
+ /*#__PURE__*/ jsxs("div", {
39
+ className: classnames('byline-image-lightbox-title-stack', image_lightbox_module["title-stack"]),
40
+ children: [
41
+ /*#__PURE__*/ jsx("h3", {
42
+ className: classnames('byline-image-lightbox-title', image_lightbox_module.title),
43
+ children: headingText
44
+ }),
45
+ meta?.width != null && meta?.height != null && /*#__PURE__*/ jsxs("p", {
46
+ className: classnames('byline-image-lightbox-subtitle', image_lightbox_module.subtitle),
47
+ children: [
48
+ meta.width,
49
+ "\xd7",
50
+ meta.height,
51
+ null != meta.fileSize ? ` · ${formatFileSize(meta.fileSize)}` : '',
52
+ meta.mimeType ? ` · ${meta.mimeType}` : ''
53
+ ]
54
+ })
55
+ ]
56
+ }),
57
+ /*#__PURE__*/ jsxs("div", {
58
+ className: classnames('byline-image-lightbox-actions', image_lightbox_module["header-actions"]),
59
+ children: [
60
+ /*#__PURE__*/ jsx(IconButton, {
61
+ onClick: ()=>triggerDownload(src, downloadFilename),
62
+ size: "xs",
63
+ "aria-label": "Download original",
64
+ children: /*#__PURE__*/ jsx(DownloadIcon, {
65
+ width: "15px",
66
+ height: "15px"
67
+ })
68
+ }),
69
+ /*#__PURE__*/ jsx(IconButton, {
70
+ onClick: onDismiss,
71
+ size: "xs",
72
+ "aria-label": "Close preview",
73
+ children: /*#__PURE__*/ jsx(CloseIcon, {
74
+ width: "15px",
75
+ height: "15px"
76
+ })
77
+ })
78
+ ]
79
+ })
80
+ ]
81
+ }),
82
+ /*#__PURE__*/ jsx(Modal.Content, {
83
+ className: classnames('byline-image-lightbox-content', image_lightbox_module.content),
84
+ children: /*#__PURE__*/ jsx("img", {
85
+ src: src,
86
+ alt: altText,
87
+ className: classnames('byline-image-lightbox-image', image_lightbox_module.image)
88
+ })
89
+ })
90
+ ]
91
+ })
92
+ });
93
+ }
94
+ export { ImageLightbox };
@@ -0,0 +1,17 @@
1
+ import "./image-lightbox_module.css";
2
+ const image_lightbox_module = {
3
+ container: "container-E7tMnX",
4
+ header: "header-soW8da",
5
+ "title-stack": "title-stack-zc7tfO",
6
+ titleStack: "title-stack-zc7tfO",
7
+ title: "title-o7aRVk",
8
+ subtitle: "subtitle-a_cB7m",
9
+ "header-actions": "header-actions-Pm2hb3",
10
+ headerActions: "header-actions-Pm2hb3",
11
+ content: "content-r1WUuT",
12
+ image: "image-CFBDM6",
13
+ meta: "meta-M5aIfQ",
14
+ "meta-key": "meta-key-ohNmZ4",
15
+ metaKey: "meta-key-ohNmZ4"
16
+ };
17
+ export default image_lightbox_module;
@@ -0,0 +1,82 @@
1
+ @layer byline-base, byline-utilities, byline-theme;
2
+
3
+ @layer byline-components {
4
+ .container-E7tMnX {
5
+ flex-direction: column;
6
+ width: auto;
7
+ max-width: 96vw;
8
+ max-height: 96vh;
9
+ display: flex;
10
+ overflow: hidden;
11
+ }
12
+
13
+ .header-soW8da {
14
+ justify-content: space-between;
15
+ align-items: center;
16
+ gap: var(--gap-3);
17
+ display: flex;
18
+ }
19
+
20
+ .title-stack-zc7tfO {
21
+ flex: 1;
22
+ min-width: 0;
23
+ }
24
+
25
+ .title-o7aRVk {
26
+ font-size: var(--font-size-md);
27
+ text-overflow: ellipsis;
28
+ white-space: nowrap;
29
+ margin: 0;
30
+ font-weight: 600;
31
+ overflow: hidden;
32
+ }
33
+
34
+ .subtitle-a_cB7m {
35
+ font-size: var(--font-size-xs);
36
+ color: var(--text-color-muted);
37
+ margin: 0;
38
+ margin-top: var(--spacing-1);
39
+ }
40
+
41
+ .header-actions-Pm2hb3 {
42
+ align-items: center;
43
+ gap: var(--gap-2);
44
+ flex-shrink: 0;
45
+ display: flex;
46
+ }
47
+
48
+ .content-r1WUuT {
49
+ min-height: 0;
50
+ padding: var(--spacing-8);
51
+ background: var(--surface-canvas, #0b0b0b);
52
+ justify-content: center;
53
+ align-items: center;
54
+ display: flex;
55
+ overflow: auto;
56
+ }
57
+
58
+ .image-CFBDM6 {
59
+ object-fit: contain;
60
+ -webkit-user-select: none;
61
+ user-select: none;
62
+ width: auto;
63
+ max-width: 100%;
64
+ height: auto;
65
+ max-height: calc(96vh - 140px);
66
+ display: block;
67
+ }
68
+
69
+ .meta-M5aIfQ {
70
+ gap: var(--gap-3);
71
+ font-size: var(--font-size-xs);
72
+ color: var(--text-color-muted);
73
+ flex-wrap: wrap;
74
+ display: flex;
75
+ }
76
+
77
+ .meta-key-ohNmZ4 {
78
+ margin-right: var(--spacing-1);
79
+ font-weight: 600;
80
+ }
81
+ }
82
+
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "1.6.2",
6
+ "version": "1.7.1",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -65,9 +65,9 @@
65
65
  "react-diff-viewer-continued": "^4.2.2",
66
66
  "zod": "^4.4.2",
67
67
  "zod-form-data": "^3.0.1",
68
- "@byline/core": "1.6.2",
69
- "@byline/admin": "1.6.2",
70
- "@byline/client": "1.6.2"
68
+ "@byline/admin": "1.7.1",
69
+ "@byline/core": "1.7.1",
70
+ "@byline/client": "1.7.1"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "^19.0.0",
@@ -79,6 +79,34 @@
79
79
  position: relative;
80
80
  }
81
81
 
82
+ .preview-button,
83
+ :global(.byline-field-image-preview-button) {
84
+ appearance: none;
85
+ background: none;
86
+ border: none;
87
+ padding: 0;
88
+ margin: 0;
89
+ display: block;
90
+ cursor: pointer;
91
+ border-radius: var(--border-radius-sm);
92
+ }
93
+
94
+ .preview-button:focus-visible,
95
+ :global(.byline-field-image-preview-button):focus-visible {
96
+ outline: 2px solid var(--primary-500);
97
+ outline-offset: 2px;
98
+ }
99
+
100
+ .preview-button img,
101
+ :global(.byline-field-image-preview-button) img {
102
+ transition: opacity 120ms ease;
103
+ }
104
+
105
+ .preview-button:hover img,
106
+ :global(.byline-field-image-preview-button):hover img {
107
+ opacity: 0.85;
108
+ }
109
+
82
110
  .preview,
83
111
  :global(.byline-field-image-preview) {
84
112
  max-height: 10rem;
@@ -6,6 +6,8 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { useState } from 'react'
10
+
9
11
  import {
10
12
  type ImageField as FieldType,
11
13
  isPendingStoredFileValue,
@@ -15,6 +17,7 @@ import cx from 'classnames'
15
17
 
16
18
  import { useFieldError, useFieldValue, useFormContext, useIsDirty } from '../../forms/form-context'
17
19
  import { ErrorText } from '../../uikit.js'
20
+ import { ImageLightbox } from '../../widgets/image-lightbox/image-lightbox.js'
18
21
  import { useFieldChangeHandler } from '../use-field-change-handler'
19
22
  import styles from './image-field.module.css'
20
23
  import { ImageUploadField } from './image-upload-field'
@@ -68,6 +71,14 @@ export const ImageField = ({
68
71
  // Show upload widget only if no value or old placeholder
69
72
  const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
70
73
 
74
+ // Prefer the generated thumbnail variant for the preview tile. SVGs and
75
+ // other bypass types have no variants — fall back to the original.
76
+ const thumbVariant =
77
+ incomingValue && !isPendingStoredFileValue(incomingValue)
78
+ ? incomingValue.variants?.find((v) => v.name === 'thumbnail')
79
+ : undefined
80
+ const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl
81
+
71
82
  // Handle remove, including cleanup of pending uploads
72
83
  const handleRemove = () => {
73
84
  if (isPending) {
@@ -76,6 +87,11 @@ export const ImageField = ({
76
87
  handleChange(null)
77
88
  }
78
89
 
90
+ // Lightbox state — only enabled for stored (non-pending) images that have a
91
+ // resolvable original storageUrl.
92
+ const [lightboxOpen, setLightboxOpen] = useState(false)
93
+ const canOpenLightbox = !isPending && !!incomingValue?.storageUrl
94
+
79
95
  return (
80
96
  <div className={`byline-field-image ${field.name}`}>
81
97
  <div className={cx('byline-field-image-header', styles.header)}>
@@ -116,20 +132,42 @@ export const ImageField = ({
116
132
  ) : (
117
133
  <div className={cx('byline-field-image-tile', styles.tile)}>
118
134
  {/* Preview */}
119
- {incomingValue?.storageUrl && (
135
+ {previewUrl && (
120
136
  <div className={cx('byline-field-image-preview-wrap', styles['preview-wrap'])}>
121
- <img
122
- src={incomingValue.storageUrl}
123
- alt={incomingValue.originalFilename ?? incomingValue.filename}
124
- className={cx(
125
- 'byline-field-image-preview',
126
- styles.preview,
127
- incomingValue.mimeType === 'image/svg+xml' && [
128
- 'byline-field-image-preview-svg',
129
- styles['preview-svg'],
130
- ]
131
- )}
132
- />
137
+ {canOpenLightbox ? (
138
+ <button
139
+ type="button"
140
+ onClick={() => setLightboxOpen(true)}
141
+ aria-label="Open full-size preview"
142
+ className={cx('byline-field-image-preview-button', styles['preview-button'])}
143
+ >
144
+ <img
145
+ src={previewUrl}
146
+ alt={incomingValue.originalFilename ?? incomingValue.filename}
147
+ className={cx(
148
+ 'byline-field-image-preview',
149
+ styles.preview,
150
+ incomingValue.mimeType === 'image/svg+xml' && [
151
+ 'byline-field-image-preview-svg',
152
+ styles['preview-svg'],
153
+ ]
154
+ )}
155
+ />
156
+ </button>
157
+ ) : (
158
+ <img
159
+ src={previewUrl}
160
+ alt={incomingValue.originalFilename ?? incomingValue.filename}
161
+ className={cx(
162
+ 'byline-field-image-preview',
163
+ styles.preview,
164
+ incomingValue.mimeType === 'image/svg+xml' && [
165
+ 'byline-field-image-preview-svg',
166
+ styles['preview-svg'],
167
+ ]
168
+ )}
169
+ />
170
+ )}
133
171
  {/* Pending upload badge */}
134
172
  {isPending && (
135
173
  <div className={cx('byline-field-image-pending', styles.pending)}>
@@ -198,7 +236,7 @@ export const ImageField = ({
198
236
  <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
199
237
  Thumbnail:
200
238
  </span>{' '}
201
- {incomingValue?.thumbnailGenerated ? 'Generated' : 'Pending'}
239
+ {thumbVariant ? 'Generated' : 'Pending'}
202
240
  </div>
203
241
  </>
204
242
  )}
@@ -207,6 +245,23 @@ export const ImageField = ({
207
245
  )}
208
246
 
209
247
  {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
248
+
249
+ {canOpenLightbox && incomingValue?.storageUrl && (
250
+ <ImageLightbox
251
+ isOpen={lightboxOpen}
252
+ onDismiss={() => setLightboxOpen(false)}
253
+ src={incomingValue.storageUrl}
254
+ alt={incomingValue.originalFilename ?? incomingValue.filename}
255
+ downloadFilename={incomingValue.originalFilename ?? incomingValue.filename}
256
+ title={incomingValue.originalFilename ?? incomingValue.filename}
257
+ meta={{
258
+ width: incomingValue.imageWidth,
259
+ height: incomingValue.imageHeight,
260
+ fileSize: incomingValue.fileSize,
261
+ mimeType: incomingValue.mimeType,
262
+ }}
263
+ />
264
+ )}
210
265
  </div>
211
266
  )
212
267
  }
package/src/uikit.ts CHANGED
@@ -111,6 +111,7 @@ export * from './loaders/spinner.js'
111
111
  export * from './widgets/datepicker/datepicker.js'
112
112
  export * from './widgets/drawer/drawer.js'
113
113
  export * from './widgets/drawer/drawer-context.js'
114
+ export * from './widgets/image-lightbox/image-lightbox.js'
114
115
  export * from './widgets/modal/modal.js'
115
116
  export * from './widgets/search/search.js'
116
117
  export * from './widgets/timeline/timeline.js'
@@ -0,0 +1,80 @@
1
+ @layer byline-base, byline-utilities, byline-theme, byline-components;
2
+
3
+ @layer byline-components {
4
+ .container {
5
+ width: auto;
6
+ max-width: 96vw;
7
+ max-height: 96vh;
8
+ display: flex;
9
+ flex-direction: column;
10
+ overflow: hidden;
11
+ }
12
+
13
+ .header {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ gap: var(--gap-3);
18
+ }
19
+
20
+ .title-stack {
21
+ min-width: 0;
22
+ flex: 1;
23
+ }
24
+
25
+ .title {
26
+ font-size: var(--font-size-md);
27
+ font-weight: 600;
28
+ margin: 0;
29
+ overflow: hidden;
30
+ text-overflow: ellipsis;
31
+ white-space: nowrap;
32
+ }
33
+
34
+ .subtitle {
35
+ font-size: var(--font-size-xs);
36
+ color: var(--text-color-muted);
37
+ margin: 0;
38
+ margin-top: var(--spacing-1);
39
+ }
40
+
41
+ .header-actions {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: var(--gap-2);
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ .content {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ min-height: 0;
53
+ overflow: auto;
54
+ padding: var(--spacing-8);
55
+ background: var(--surface-canvas, #0b0b0b);
56
+ }
57
+
58
+ .image {
59
+ max-width: 100%;
60
+ max-height: calc(96vh - 140px);
61
+ width: auto;
62
+ height: auto;
63
+ object-fit: contain;
64
+ display: block;
65
+ user-select: none;
66
+ }
67
+
68
+ .meta {
69
+ display: flex;
70
+ flex-wrap: wrap;
71
+ gap: var(--gap-3);
72
+ font-size: var(--font-size-xs);
73
+ color: var(--text-color-muted);
74
+ }
75
+
76
+ .meta-key {
77
+ font-weight: 600;
78
+ margin-right: var(--spacing-1);
79
+ }
80
+ }
@@ -0,0 +1,118 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import cx from 'classnames'
12
+
13
+ import { IconButton } from '../../components/button/icon-button.js'
14
+ import { CloseIcon } from '../../icons/close-icon.js'
15
+ import { DownloadIcon } from '../../icons/download-icon.js'
16
+ import { Modal } from '../modal/modal.js'
17
+ import styles from './image-lightbox.module.css'
18
+
19
+ export interface ImageLightboxProps {
20
+ isOpen: boolean
21
+ onDismiss: () => void
22
+ /** Full-resolution image URL. */
23
+ src: string
24
+ /** Alt text — falls back to filename. */
25
+ alt?: string
26
+ /** Used as the `download` attribute on the download link. */
27
+ downloadFilename?: string
28
+ /** Header label. Defaults to `downloadFilename`. */
29
+ title?: string
30
+ /** Optional metadata row beneath the image. */
31
+ meta?: {
32
+ width?: number | null
33
+ height?: number | null
34
+ fileSize?: number | null
35
+ mimeType?: string | null
36
+ }
37
+ }
38
+
39
+ function formatFileSize(bytes: number): string {
40
+ if (bytes < 1024) return `${bytes} B`
41
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
42
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
43
+ }
44
+
45
+ /**
46
+ * Trigger a download via a temporary anchor. Uses the `download` attribute so
47
+ * same-origin URLs save with the suggested filename; cross-origin URLs that
48
+ * don't honour the attribute will navigate to the file in a new tab, where
49
+ * the user can still right-click to save.
50
+ */
51
+ function triggerDownload(url: string, filename?: string) {
52
+ if (typeof document === 'undefined') return
53
+ const a = document.createElement('a')
54
+ a.href = url
55
+ if (filename) a.download = filename
56
+ a.target = '_blank'
57
+ a.rel = 'noreferrer'
58
+ document.body.appendChild(a)
59
+ a.click()
60
+ document.body.removeChild(a)
61
+ }
62
+
63
+ /**
64
+ * ImageLightbox — modal preview of a full-resolution image with a download
65
+ * affordance. Right-click on the image works natively (browser "Save image
66
+ * as…") because it's a regular `<img src>`.
67
+ */
68
+ export function ImageLightbox({
69
+ isOpen,
70
+ onDismiss,
71
+ src,
72
+ alt,
73
+ downloadFilename,
74
+ title,
75
+ meta,
76
+ }: ImageLightboxProps) {
77
+ const headingText = title ?? downloadFilename ?? 'Image'
78
+ const altText = alt ?? downloadFilename ?? 'Image preview'
79
+
80
+ return (
81
+ <Modal isOpen={isOpen} closeOnOverlayClick={true} onDismiss={onDismiss}>
82
+ <Modal.Container className={cx('byline-image-lightbox-container', styles.container)}>
83
+ <Modal.Header className={cx('byline-image-lightbox-header', styles.header)}>
84
+ <div className={cx('byline-image-lightbox-title-stack', styles['title-stack'])}>
85
+ <h3 className={cx('byline-image-lightbox-title', styles.title)}>{headingText}</h3>
86
+ {meta?.width != null && meta?.height != null && (
87
+ <p className={cx('byline-image-lightbox-subtitle', styles.subtitle)}>
88
+ {meta.width}×{meta.height}
89
+ {meta.fileSize != null ? ` · ${formatFileSize(meta.fileSize)}` : ''}
90
+ {meta.mimeType ? ` · ${meta.mimeType}` : ''}
91
+ </p>
92
+ )}
93
+ </div>
94
+ <div className={cx('byline-image-lightbox-actions', styles['header-actions'])}>
95
+ <IconButton
96
+ onClick={() => triggerDownload(src, downloadFilename)}
97
+ size="xs"
98
+ aria-label="Download original"
99
+ >
100
+ <DownloadIcon width="15px" height="15px" />
101
+ </IconButton>
102
+ <IconButton onClick={onDismiss} size="xs" aria-label="Close preview">
103
+ <CloseIcon width="15px" height="15px" />
104
+ </IconButton>
105
+ </div>
106
+ </Modal.Header>
107
+
108
+ <Modal.Content className={cx('byline-image-lightbox-content', styles.content)}>
109
+ <img
110
+ src={src}
111
+ alt={altText}
112
+ className={cx('byline-image-lightbox-image', styles.image)}
113
+ />
114
+ </Modal.Content>
115
+ </Modal.Container>
116
+ </Modal>
117
+ )
118
+ }