@slopmachine/react 0.1.9 → 0.1.12

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.
@@ -2,18 +2,50 @@ import React, { useMemo, useState } from "react";
2
2
  import { clsx, type ClassValue } from "clsx";
3
3
  import { twMerge } from "tailwind-merge";
4
4
 
5
- import { buildImageUrl, type SlopImageOptions } from "@slopmachine/core";
5
+ import {
6
+ buildImageUrl,
7
+ type SlopImageOptions,
8
+ type ImageAspectRatio,
9
+ } from "@slopmachine/core";
6
10
 
7
11
  // Utility for Tailwind classes
8
12
  function cn(...inputs: ClassValue[]) {
9
13
  return twMerge(clsx(inputs));
10
14
  }
11
15
 
12
- interface SlopImageProps
16
+ export interface SlopImageProps
13
17
  extends
14
18
  Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">,
15
- SlopImageOptions {}
19
+ Omit<SlopImageOptions, "aspectRatio"> {
20
+ /**
21
+ * The aspect ratio of the generated image. Defaults to "1:1".
22
+ */
23
+ aspectRatio?: ImageAspectRatio;
24
+ /**
25
+ * Custom React node to display while the image is loading.
26
+ * If not provided, a default spinner and shimmer effect will be shown.
27
+ */
28
+ loader?: React.ReactNode;
29
+ }
16
30
 
31
+ /**
32
+ * Renders an image generated by Slop Machine.
33
+ *
34
+ * This component automatically constructs the correct URL for the Slop Machine API,
35
+ * displays a loading skeleton or custom loader while fetching, and handles errors.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <SlopImage
40
+ * bucketId="my-bucket"
41
+ * resultId="some-result"
42
+ * aspectRatio="16:9"
43
+ * className="rounded-lg"
44
+ * />
45
+ * ```
46
+ *
47
+ * @version 0.1.12
48
+ */
17
49
  export const SlopImage: React.FC<SlopImageProps> = ({
18
50
  bucketId,
19
51
  className,
@@ -23,6 +55,7 @@ export const SlopImage: React.FC<SlopImageProps> = ({
23
55
  model,
24
56
  variables = {},
25
57
  baseUrl,
58
+ loader,
26
59
  ...props
27
60
  }) => {
28
61
  // Construct API URL
@@ -113,97 +146,125 @@ export const SlopImage: React.FC<SlopImageProps> = ({
113
146
  overflow: "hidden",
114
147
  }}
115
148
  >
116
- {/* Loading overlay */}
117
- <div
118
- className={cn(
119
- "absolute inset-0 z-10 flex items-center justify-center bg-muted transition-opacity duration-300",
120
- isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
121
- )}
122
- style={{
123
- position: "absolute",
124
- top: 0,
125
- left: 0,
126
- right: 0,
127
- bottom: 0,
128
- zIndex: 10,
129
- display: "flex",
130
- alignItems: "center",
131
- justifyContent: "center",
132
- backgroundColor: "var(--muted, #f3f4f6)",
133
- opacity: isLoading ? 1 : 0,
134
- pointerEvents: isLoading ? "auto" : "none",
135
- transition: "opacity 300ms ease-in-out",
136
- }}
137
- >
149
+ {/* Loading UI */}
150
+ {loader ? (
138
151
  <div
139
- className="flex flex-col items-center gap-2"
152
+ className={cn(
153
+ "absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-300",
154
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
155
+ )}
140
156
  style={{
157
+ position: "absolute",
158
+ top: 0,
159
+ left: 0,
160
+ right: 0,
161
+ bottom: 0,
162
+ zIndex: 10,
141
163
  display: "flex",
142
- flexDirection: "column",
143
164
  alignItems: "center",
144
- gap: "0.5rem",
165
+ justifyContent: "center",
166
+ opacity: isLoading ? 1 : 0,
167
+ pointerEvents: isLoading ? "auto" : "none",
168
+ transition: "opacity 300ms ease-in-out",
145
169
  }}
146
170
  >
147
- <svg
148
- className="spinner size-6 text-muted-foreground"
149
- xmlns="http://www.w3.org/2000/svg"
150
- fill="none"
151
- viewBox="0 0 24 24"
171
+ {loader}
172
+ </div>
173
+ ) : (
174
+ <>
175
+ {/* Loading overlay */}
176
+ <div
177
+ className={cn(
178
+ "absolute inset-0 z-10 flex items-center justify-center bg-muted transition-opacity duration-300",
179
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
180
+ )}
152
181
  style={{
153
- width: "24px",
154
- height: "24px",
155
- color: "var(--muted-foreground, #6b7280)",
156
- animation: "slop-spin 1s linear infinite",
182
+ position: "absolute",
183
+ top: 0,
184
+ left: 0,
185
+ right: 0,
186
+ bottom: 0,
187
+ zIndex: 10,
188
+ display: "flex",
189
+ alignItems: "center",
190
+ justifyContent: "center",
191
+ backgroundColor: "var(--muted, #f3f4f6)",
192
+ opacity: isLoading ? 1 : 0,
193
+ pointerEvents: isLoading ? "auto" : "none",
194
+ transition: "opacity 300ms ease-in-out",
157
195
  }}
158
196
  >
159
- <circle
160
- className="opacity-25"
161
- cx="12"
162
- cy="12"
163
- r="10"
164
- stroke="currentColor"
165
- strokeWidth="4"
166
- style={{ opacity: 0.25 }}
167
- />
168
- <path
169
- className="opacity-75"
170
- fill="currentColor"
171
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
172
- style={{ opacity: 0.75 }}
173
- />
174
- </svg>
175
- <span
176
- className="text-xs text-muted-foreground"
197
+ <div
198
+ className="flex flex-col items-center gap-2"
199
+ style={{
200
+ display: "flex",
201
+ flexDirection: "column",
202
+ alignItems: "center",
203
+ gap: "0.5rem",
204
+ }}
205
+ >
206
+ <svg
207
+ className="spinner size-6 text-muted-foreground"
208
+ xmlns="http://www.w3.org/2000/svg"
209
+ fill="none"
210
+ viewBox="0 0 24 24"
211
+ style={{
212
+ width: "24px",
213
+ height: "24px",
214
+ color: "var(--muted-foreground, #6b7280)",
215
+ animation: "slop-spin 1s linear infinite",
216
+ }}
217
+ >
218
+ <circle
219
+ className="opacity-25"
220
+ cx="12"
221
+ cy="12"
222
+ r="10"
223
+ stroke="currentColor"
224
+ strokeWidth="4"
225
+ style={{ opacity: 0.25 }}
226
+ />
227
+ <path
228
+ className="opacity-75"
229
+ fill="currentColor"
230
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
231
+ style={{ opacity: 0.75 }}
232
+ />
233
+ </svg>
234
+ <span
235
+ className="text-xs text-muted-foreground"
236
+ style={{
237
+ fontSize: "0.75rem",
238
+ color: "var(--muted-foreground, #6b7280)",
239
+ }}
240
+ >
241
+ Loading...
242
+ </span>
243
+ </div>
244
+ </div>
245
+
246
+ {/* Shimmer effect underneath */}
247
+ <div
248
+ className={cn(
249
+ "shimmer-effect absolute inset-0 bg-gradient-to-r from-muted via-muted/50 to-muted",
250
+ isLoading ? "opacity-100" : "opacity-0",
251
+ )}
177
252
  style={{
178
- fontSize: "0.75rem",
179
- color: "var(--muted-foreground, #6b7280)",
253
+ position: "absolute",
254
+ top: 0,
255
+ left: 0,
256
+ right: 0,
257
+ bottom: 0,
258
+ background:
259
+ "linear-gradient(90deg, var(--muted, #f3f4f6) 0%, var(--muted-foreground, #e5e7eb) 50%, var(--muted, #f3f4f6) 100%)",
260
+ backgroundSize: "200% 100%",
261
+ animation: "slop-shimmer 2s ease-in-out infinite",
262
+ opacity: isLoading ? 1 : 0,
263
+ transition: "opacity 300ms",
180
264
  }}
181
- >
182
- Loading...
183
- </span>
184
- </div>
185
- </div>
186
-
187
- {/* Shimmer effect underneath */}
188
- <div
189
- className={cn(
190
- "shimmer-effect absolute inset-0 bg-gradient-to-r from-muted via-muted/50 to-muted",
191
- isLoading ? "opacity-100" : "opacity-0",
192
- )}
193
- style={{
194
- position: "absolute",
195
- top: 0,
196
- left: 0,
197
- right: 0,
198
- bottom: 0,
199
- background:
200
- "linear-gradient(90deg, var(--muted, #f3f4f6) 0%, var(--muted-foreground, #e5e7eb) 50%, var(--muted, #f3f4f6) 100%)",
201
- backgroundSize: "200% 100%",
202
- animation: "slop-shimmer 2s ease-in-out infinite",
203
- opacity: isLoading ? 1 : 0,
204
- transition: "opacity 300ms",
205
- }}
206
- />
265
+ />
266
+ </>
267
+ )}
207
268
 
208
269
  {/* Image */}
209
270
  <img
@@ -0,0 +1,308 @@
1
+ import React, { useMemo, useState, useRef, useEffect } from "react";
2
+ import { clsx, type ClassValue } from "clsx";
3
+ import { twMerge } from "tailwind-merge";
4
+
5
+ import {
6
+ buildVideoUrl,
7
+ type SlopVideoOptions,
8
+ type VideoAspectRatio,
9
+ } from "@slopmachine/core";
10
+
11
+ // Utility for Tailwind classes
12
+ function cn(...inputs: ClassValue[]) {
13
+ return twMerge(clsx(inputs));
14
+ }
15
+
16
+ export interface SlopVideoProps
17
+ extends
18
+ Omit<React.VideoHTMLAttributes<HTMLVideoElement>, "src">,
19
+ Omit<SlopVideoOptions, "aspectRatio"> {
20
+ /**
21
+ * The aspect ratio of the generated video. Defaults to "16:9".
22
+ */
23
+ aspectRatio?: VideoAspectRatio;
24
+ /**
25
+ * Custom React node to display while the video is loading.
26
+ * If not provided, a default spinner and shimmer effect will be shown.
27
+ */
28
+ loader?: React.ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Renders a video generated by Slop Machine.
33
+ *
34
+ * This component automatically constructs the correct URL for the Slop Machine API,
35
+ * displays a loading skeleton or custom loader while fetching, and handles errors.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <SlopVideo
40
+ * bucketId="my-bucket"
41
+ * resultId="some-result"
42
+ * aspectRatio="16:9"
43
+ * className="rounded-lg"
44
+ * autoPlay
45
+ * loop
46
+ * muted
47
+ * />
48
+ * ```
49
+ */
50
+ export const SlopVideo: React.FC<SlopVideoProps> = ({
51
+ bucketId,
52
+ className,
53
+ aspectRatio = "16:9",
54
+ version,
55
+ resultId,
56
+ model,
57
+ variables = {},
58
+ duration,
59
+ baseUrl,
60
+ loader,
61
+ autoPlay = true,
62
+ loop = true,
63
+ muted = true,
64
+ playsInline = true,
65
+ ...props
66
+ }) => {
67
+ // Construct API URL
68
+ const rawSrc = useMemo(
69
+ () =>
70
+ buildVideoUrl({
71
+ bucketId,
72
+ aspectRatio,
73
+ version,
74
+ resultId,
75
+ variables,
76
+ duration,
77
+ baseUrl,
78
+ model,
79
+ }),
80
+ [
81
+ bucketId,
82
+ aspectRatio,
83
+ version,
84
+ resultId,
85
+ variables,
86
+ duration,
87
+ baseUrl,
88
+ model,
89
+ ],
90
+ );
91
+
92
+ const [src, setSrc] = useState(rawSrc);
93
+
94
+ useEffect(() => {
95
+ const timer = setTimeout(() => {
96
+ setSrc(rawSrc);
97
+ }, 100);
98
+ return () => clearTimeout(timer);
99
+ }, [rawSrc]);
100
+
101
+ const [isLoading, setIsLoading] = useState(true);
102
+ const [currentSrc, setCurrentSrc] = useState(src);
103
+ const videoRef = useRef<HTMLVideoElement>(null);
104
+
105
+ if (src !== currentSrc) {
106
+ setCurrentSrc(src);
107
+ setIsLoading(true);
108
+ }
109
+
110
+ useEffect(() => {
111
+ if (!src) return;
112
+
113
+ fetch(src, { method: "HEAD" })
114
+ .then(async (res) => {
115
+ if (!res.ok) {
116
+ try {
117
+ const errRes = await fetch(src);
118
+ const errJson = await errRes.json();
119
+ console.error("SlopVideo error:", res.status, errJson.error);
120
+ } catch (e) {
121
+ // Failed to fetch or parse error details
122
+ }
123
+ setIsLoading(false);
124
+ }
125
+ })
126
+ .catch((err) => {
127
+ console.error(`Error fetching video from ${src}:`, err);
128
+ setIsLoading(false);
129
+ });
130
+ }, [src]);
131
+
132
+ return (
133
+ <>
134
+ <style>{`
135
+ @keyframes slop-shimmer {
136
+ 0% {
137
+ background-position: 200% 0;
138
+ }
139
+ 100% {
140
+ background-position: -200% 0;
141
+ }
142
+ }
143
+ @keyframes slop-spin {
144
+ from {
145
+ transform: rotate(0deg);
146
+ }
147
+ to {
148
+ transform: rotate(360deg);
149
+ }
150
+ }
151
+ `}</style>
152
+ <div
153
+ className={cn("relative overflow-hidden", className)}
154
+ style={{
155
+ aspectRatio: String(aspectRatio).replace(":", "/"),
156
+ backgroundColor: "var(--muted, #f3f4f6)",
157
+ position: "relative",
158
+ overflow: "hidden",
159
+ }}
160
+ >
161
+ {/* Loading UI */}
162
+ {loader ? (
163
+ <div
164
+ className={cn(
165
+ "absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-300",
166
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
167
+ )}
168
+ style={{
169
+ position: "absolute",
170
+ top: 0,
171
+ left: 0,
172
+ right: 0,
173
+ bottom: 0,
174
+ zIndex: 10,
175
+ display: "flex",
176
+ alignItems: "center",
177
+ justifyContent: "center",
178
+ opacity: isLoading ? 1 : 0,
179
+ pointerEvents: isLoading ? "auto" : "none",
180
+ transition: "opacity 300ms ease-in-out",
181
+ }}
182
+ >
183
+ {loader}
184
+ </div>
185
+ ) : (
186
+ <>
187
+ {/* Loading overlay */}
188
+ <div
189
+ className={cn(
190
+ "absolute inset-0 z-10 flex items-center justify-center bg-muted transition-opacity duration-300",
191
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
192
+ )}
193
+ style={{
194
+ position: "absolute",
195
+ top: 0,
196
+ left: 0,
197
+ right: 0,
198
+ bottom: 0,
199
+ zIndex: 10,
200
+ display: "flex",
201
+ alignItems: "center",
202
+ justifyContent: "center",
203
+ backgroundColor: "var(--muted, #f3f4f6)",
204
+ opacity: isLoading ? 1 : 0,
205
+ pointerEvents: isLoading ? "auto" : "none",
206
+ transition: "opacity 300ms ease-in-out",
207
+ }}
208
+ >
209
+ <div
210
+ className="flex flex-col items-center gap-2"
211
+ style={{
212
+ display: "flex",
213
+ flexDirection: "column",
214
+ alignItems: "center",
215
+ gap: "0.5rem",
216
+ }}
217
+ >
218
+ <svg
219
+ className="spinner size-6 text-muted-foreground"
220
+ xmlns="http://www.w3.org/2000/svg"
221
+ fill="none"
222
+ viewBox="0 0 24 24"
223
+ style={{
224
+ width: "24px",
225
+ height: "24px",
226
+ color: "var(--muted-foreground, #6b7280)",
227
+ animation: "slop-spin 1s linear infinite",
228
+ }}
229
+ >
230
+ <circle
231
+ className="opacity-25"
232
+ cx="12"
233
+ cy="12"
234
+ r="10"
235
+ stroke="currentColor"
236
+ strokeWidth="4"
237
+ style={{ opacity: 0.25 }}
238
+ />
239
+ <path
240
+ className="opacity-75"
241
+ fill="currentColor"
242
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
243
+ style={{ opacity: 0.75 }}
244
+ />
245
+ </svg>
246
+ <span
247
+ className="text-xs text-muted-foreground"
248
+ style={{
249
+ fontSize: "0.75rem",
250
+ color: "var(--muted-foreground, #6b7280)",
251
+ }}
252
+ >
253
+ Loading...
254
+ </span>
255
+ </div>
256
+ </div>
257
+
258
+ {/* Shimmer effect underneath */}
259
+ <div
260
+ className={cn(
261
+ "shimmer-effect absolute inset-0 bg-gradient-to-r from-muted via-muted/50 to-muted",
262
+ isLoading ? "opacity-100" : "opacity-0",
263
+ )}
264
+ style={{
265
+ position: "absolute",
266
+ top: 0,
267
+ left: 0,
268
+ right: 0,
269
+ bottom: 0,
270
+ background:
271
+ "linear-gradient(90deg, var(--muted, #f3f4f6) 0%, var(--muted-foreground, #e5e7eb) 50%, var(--muted, #f3f4f6) 100%)",
272
+ backgroundSize: "200% 100%",
273
+ animation: "slop-shimmer 2s ease-in-out infinite",
274
+ opacity: isLoading ? 1 : 0,
275
+ transition: "opacity 300ms",
276
+ }}
277
+ />
278
+ </>
279
+ )}
280
+
281
+ {/* Video */}
282
+ <video
283
+ key={src}
284
+ ref={videoRef}
285
+ src={src}
286
+ autoPlay={autoPlay}
287
+ loop={loop}
288
+ muted={muted}
289
+ playsInline={playsInline}
290
+ onLoadedData={() => setIsLoading(false)}
291
+ onError={() => setIsLoading(false)}
292
+ className={cn(
293
+ "h-full w-full object-cover transition-opacity duration-500",
294
+ isLoading ? "opacity-0" : "opacity-100",
295
+ )}
296
+ style={{
297
+ height: "100%",
298
+ width: "100%",
299
+ objectFit: "cover",
300
+ transition: "opacity 500ms",
301
+ opacity: isLoading ? 0 : 1,
302
+ }}
303
+ {...props}
304
+ />
305
+ </div>
306
+ </>
307
+ );
308
+ };
package/src/index.ts CHANGED
@@ -1 +1,8 @@
1
- export { SlopImage } from './components/SlopImage';
1
+ export { SlopImage, type SlopImageProps } from "./components/SlopImage";
2
+ export { SlopVideo, type SlopVideoProps } from "./components/SlopVideo";
3
+ export type {
4
+ ImageAspectRatio,
5
+ VideoAspectRatio,
6
+ SlopImageOptions,
7
+ SlopVideoOptions,
8
+ } from "@slopmachine/core";
package/tsconfig.json CHANGED
@@ -21,6 +21,7 @@
21
21
  "noFallthroughCasesInSwitch": true,
22
22
 
23
23
  "declaration": true,
24
+ "declarationMap": true,
24
25
  "baseUrl": "."
25
26
  },
26
27
  "include": ["src"],
package/tsup.config.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { defineConfig } from 'tsup';
1
+ import { defineConfig } from "tsup";
2
2
 
3
3
  export default defineConfig({
4
- entry: ['src/index.ts'],
5
- format: ['cjs', 'esm'],
4
+ entry: ["src/index.ts"],
5
+ format: ["cjs", "esm"],
6
6
  dts: true,
7
+ sourcemap: true,
7
8
  clean: true,
8
- external: ['react', 'react-dom'],
9
+ external: ["react", "react-dom"],
9
10
  });