@slopmachine/react 0.1.10 → 0.1.13

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,20 +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
+ */
16
28
  loader?: React.ReactNode;
17
29
  }
18
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.13
48
+ */
19
49
  export const SlopImage: React.FC<SlopImageProps> = ({
20
50
  bucketId,
21
51
  className,
@@ -107,45 +137,23 @@ export const SlopImage: React.FC<SlopImageProps> = ({
107
137
  }
108
138
  }
109
139
  `}</style>
110
- <div
111
- className={cn("relative overflow-hidden", className)}
112
- style={{
113
- aspectRatio: String(aspectRatio).replace(":", "/"),
114
- backgroundColor: "var(--muted, #f3f4f6)",
115
- position: "relative",
116
- overflow: "hidden",
117
- }}
118
- >
119
- {/* Loading UI */}
120
- {loader ? (
121
- <div
122
- className={cn(
123
- "absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-300",
124
- isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
125
- )}
126
- style={{
127
- position: "absolute",
128
- top: 0,
129
- left: 0,
130
- right: 0,
131
- bottom: 0,
132
- zIndex: 10,
133
- display: "flex",
134
- alignItems: "center",
135
- justifyContent: "center",
136
- opacity: isLoading ? 1 : 0,
137
- pointerEvents: isLoading ? "auto" : "none",
138
- transition: "opacity 300ms ease-in-out",
139
- }}
140
- >
141
- {loader}
142
- </div>
143
- ) : (
144
- <>
145
- {/* Loading overlay */}
140
+ <div className={className} style={props.style}>
141
+ <div
142
+ className="slop-wrapper relative overflow-hidden w-full h-full"
143
+ style={{
144
+ aspectRatio: String(aspectRatio).replace(":", "/"),
145
+ backgroundColor: "var(--muted, #f3f4f6)",
146
+ position: "relative",
147
+ overflow: "hidden",
148
+ width: "100%",
149
+ height: "100%",
150
+ }}
151
+ >
152
+ {/* Loading UI */}
153
+ {loader ? (
146
154
  <div
147
155
  className={cn(
148
- "absolute inset-0 z-10 flex items-center justify-center bg-muted transition-opacity duration-300",
156
+ "absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-300",
149
157
  isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
150
158
  )}
151
159
  style={{
@@ -158,104 +166,130 @@ export const SlopImage: React.FC<SlopImageProps> = ({
158
166
  display: "flex",
159
167
  alignItems: "center",
160
168
  justifyContent: "center",
161
- backgroundColor: "var(--muted, #f3f4f6)",
162
169
  opacity: isLoading ? 1 : 0,
163
170
  pointerEvents: isLoading ? "auto" : "none",
164
171
  transition: "opacity 300ms ease-in-out",
165
172
  }}
166
173
  >
174
+ {loader}
175
+ </div>
176
+ ) : (
177
+ <>
178
+ {/* Loading overlay */}
167
179
  <div
168
- className="flex flex-col items-center gap-2"
180
+ className={cn(
181
+ "absolute inset-0 z-10 flex items-center justify-center bg-muted transition-opacity duration-300",
182
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
183
+ )}
169
184
  style={{
185
+ position: "absolute",
186
+ top: 0,
187
+ left: 0,
188
+ right: 0,
189
+ bottom: 0,
190
+ zIndex: 10,
170
191
  display: "flex",
171
- flexDirection: "column",
172
192
  alignItems: "center",
173
- gap: "0.5rem",
193
+ justifyContent: "center",
194
+ backgroundColor: "var(--muted, #f3f4f6)",
195
+ opacity: isLoading ? 1 : 0,
196
+ pointerEvents: isLoading ? "auto" : "none",
197
+ transition: "opacity 300ms ease-in-out",
174
198
  }}
175
199
  >
176
- <svg
177
- className="spinner size-6 text-muted-foreground"
178
- xmlns="http://www.w3.org/2000/svg"
179
- fill="none"
180
- viewBox="0 0 24 24"
200
+ <div
201
+ className="flex flex-col items-center gap-2"
181
202
  style={{
182
- width: "24px",
183
- height: "24px",
184
- color: "var(--muted-foreground, #6b7280)",
185
- animation: "slop-spin 1s linear infinite",
203
+ display: "flex",
204
+ flexDirection: "column",
205
+ alignItems: "center",
206
+ gap: "0.5rem",
186
207
  }}
187
208
  >
188
- <circle
189
- className="opacity-25"
190
- cx="12"
191
- cy="12"
192
- r="10"
193
- stroke="currentColor"
194
- strokeWidth="4"
195
- style={{ opacity: 0.25 }}
196
- />
197
- <path
198
- className="opacity-75"
199
- fill="currentColor"
200
- 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"
201
- style={{ opacity: 0.75 }}
202
- />
203
- </svg>
204
- <span
205
- className="text-xs text-muted-foreground"
206
- style={{
207
- fontSize: "0.75rem",
208
- color: "var(--muted-foreground, #6b7280)",
209
- }}
210
- >
211
- Loading...
212
- </span>
209
+ <svg
210
+ className="spinner size-6 text-muted-foreground"
211
+ xmlns="http://www.w3.org/2000/svg"
212
+ fill="none"
213
+ viewBox="0 0 24 24"
214
+ style={{
215
+ width: "24px",
216
+ height: "24px",
217
+ color: "var(--muted-foreground, #6b7280)",
218
+ animation: "slop-spin 1s linear infinite",
219
+ }}
220
+ >
221
+ <circle
222
+ className="opacity-25"
223
+ cx="12"
224
+ cy="12"
225
+ r="10"
226
+ stroke="currentColor"
227
+ strokeWidth="4"
228
+ style={{ opacity: 0.25 }}
229
+ />
230
+ <path
231
+ className="opacity-75"
232
+ fill="currentColor"
233
+ 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"
234
+ style={{ opacity: 0.75 }}
235
+ />
236
+ </svg>
237
+ <span
238
+ className="text-xs text-muted-foreground"
239
+ style={{
240
+ fontSize: "0.75rem",
241
+ color: "var(--muted-foreground, #6b7280)",
242
+ }}
243
+ >
244
+ Loading...
245
+ </span>
246
+ </div>
213
247
  </div>
214
- </div>
215
248
 
216
- {/* Shimmer effect underneath */}
217
- <div
218
- className={cn(
219
- "shimmer-effect absolute inset-0 bg-gradient-to-r from-muted via-muted/50 to-muted",
220
- isLoading ? "opacity-100" : "opacity-0",
221
- )}
222
- style={{
223
- position: "absolute",
224
- top: 0,
225
- left: 0,
226
- right: 0,
227
- bottom: 0,
228
- background:
229
- "linear-gradient(90deg, var(--muted, #f3f4f6) 0%, var(--muted-foreground, #e5e7eb) 50%, var(--muted, #f3f4f6) 100%)",
230
- backgroundSize: "200% 100%",
231
- animation: "slop-shimmer 2s ease-in-out infinite",
232
- opacity: isLoading ? 1 : 0,
233
- transition: "opacity 300ms",
234
- }}
235
- />
236
- </>
237
- )}
238
-
239
- {/* Image */}
240
- <img
241
- key={src}
242
- src={src}
243
- alt={alt}
244
- onLoad={() => setIsLoading(false)}
245
- onError={() => setIsLoading(false)}
246
- className={cn(
247
- "h-full w-full object-cover transition-opacity duration-500",
248
- isLoading ? "opacity-0" : "opacity-100",
249
+ {/* Shimmer effect underneath */}
250
+ <div
251
+ className={cn(
252
+ "shimmer-effect absolute inset-0 bg-gradient-to-r from-muted via-muted/50 to-muted",
253
+ isLoading ? "opacity-100" : "opacity-0",
254
+ )}
255
+ style={{
256
+ position: "absolute",
257
+ top: 0,
258
+ left: 0,
259
+ right: 0,
260
+ bottom: 0,
261
+ background:
262
+ "linear-gradient(90deg, var(--muted, #f3f4f6) 0%, var(--muted-foreground, #e5e7eb) 50%, var(--muted, #f3f4f6) 100%)",
263
+ backgroundSize: "200% 100%",
264
+ animation: "slop-shimmer 2s ease-in-out infinite",
265
+ opacity: isLoading ? 1 : 0,
266
+ transition: "opacity 300ms",
267
+ }}
268
+ />
269
+ </>
249
270
  )}
250
- style={{
251
- height: "100%",
252
- width: "100%",
253
- objectFit: "cover",
254
- transition: "opacity 500ms",
255
- opacity: isLoading ? 0 : 1,
256
- }}
257
- {...props}
258
- />
271
+
272
+ {/* Image */}
273
+ <img
274
+ key={src}
275
+ src={src}
276
+ alt={alt}
277
+ onLoad={() => setIsLoading(false)}
278
+ onError={() => setIsLoading(false)}
279
+ className={cn(
280
+ "h-full w-full object-cover transition-opacity duration-500",
281
+ isLoading ? "opacity-0" : "opacity-100",
282
+ )}
283
+ style={{
284
+ height: "100%",
285
+ width: "100%",
286
+ objectFit: "cover",
287
+ transition: "opacity 500ms",
288
+ opacity: isLoading ? 0 : 1,
289
+ }}
290
+ {...props}
291
+ />
292
+ </div>
259
293
  </div>
260
294
  </>
261
295
  );
@@ -0,0 +1,312 @@
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 className={className} style={props.style}>
153
+ <div
154
+ className="slop-wrapper relative overflow-hidden w-full h-full"
155
+ style={{
156
+ aspectRatio: String(aspectRatio).replace(":", "/"),
157
+ backgroundColor: "var(--muted, #f3f4f6)",
158
+ position: "relative",
159
+ overflow: "hidden",
160
+ width: "100%",
161
+ height: "100%",
162
+ }}
163
+ >
164
+ {/* Loading UI */}
165
+ {loader ? (
166
+ <div
167
+ className={cn(
168
+ "absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-300",
169
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
170
+ )}
171
+ style={{
172
+ position: "absolute",
173
+ top: 0,
174
+ left: 0,
175
+ right: 0,
176
+ bottom: 0,
177
+ zIndex: 10,
178
+ display: "flex",
179
+ alignItems: "center",
180
+ justifyContent: "center",
181
+ opacity: isLoading ? 1 : 0,
182
+ pointerEvents: isLoading ? "auto" : "none",
183
+ transition: "opacity 300ms ease-in-out",
184
+ }}
185
+ >
186
+ {loader}
187
+ </div>
188
+ ) : (
189
+ <>
190
+ {/* Loading overlay */}
191
+ <div
192
+ className={cn(
193
+ "absolute inset-0 z-10 flex items-center justify-center bg-muted transition-opacity duration-300",
194
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none",
195
+ )}
196
+ style={{
197
+ position: "absolute",
198
+ top: 0,
199
+ left: 0,
200
+ right: 0,
201
+ bottom: 0,
202
+ zIndex: 10,
203
+ display: "flex",
204
+ alignItems: "center",
205
+ justifyContent: "center",
206
+ backgroundColor: "var(--muted, #f3f4f6)",
207
+ opacity: isLoading ? 1 : 0,
208
+ pointerEvents: isLoading ? "auto" : "none",
209
+ transition: "opacity 300ms ease-in-out",
210
+ }}
211
+ >
212
+ <div
213
+ className="flex flex-col items-center gap-2"
214
+ style={{
215
+ display: "flex",
216
+ flexDirection: "column",
217
+ alignItems: "center",
218
+ gap: "0.5rem",
219
+ }}
220
+ >
221
+ <svg
222
+ className="spinner size-6 text-muted-foreground"
223
+ xmlns="http://www.w3.org/2000/svg"
224
+ fill="none"
225
+ viewBox="0 0 24 24"
226
+ style={{
227
+ width: "24px",
228
+ height: "24px",
229
+ color: "var(--muted-foreground, #6b7280)",
230
+ animation: "slop-spin 1s linear infinite",
231
+ }}
232
+ >
233
+ <circle
234
+ className="opacity-25"
235
+ cx="12"
236
+ cy="12"
237
+ r="10"
238
+ stroke="currentColor"
239
+ strokeWidth="4"
240
+ style={{ opacity: 0.25 }}
241
+ />
242
+ <path
243
+ className="opacity-75"
244
+ fill="currentColor"
245
+ 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"
246
+ style={{ opacity: 0.75 }}
247
+ />
248
+ </svg>
249
+ <span
250
+ className="text-xs text-muted-foreground"
251
+ style={{
252
+ fontSize: "0.75rem",
253
+ color: "var(--muted-foreground, #6b7280)",
254
+ }}
255
+ >
256
+ Loading...
257
+ </span>
258
+ </div>
259
+ </div>
260
+
261
+ {/* Shimmer effect underneath */}
262
+ <div
263
+ className={cn(
264
+ "shimmer-effect absolute inset-0 bg-gradient-to-r from-muted via-muted/50 to-muted",
265
+ isLoading ? "opacity-100" : "opacity-0",
266
+ )}
267
+ style={{
268
+ position: "absolute",
269
+ top: 0,
270
+ left: 0,
271
+ right: 0,
272
+ bottom: 0,
273
+ background:
274
+ "linear-gradient(90deg, var(--muted, #f3f4f6) 0%, var(--muted-foreground, #e5e7eb) 50%, var(--muted, #f3f4f6) 100%)",
275
+ backgroundSize: "200% 100%",
276
+ animation: "slop-shimmer 2s ease-in-out infinite",
277
+ opacity: isLoading ? 1 : 0,
278
+ transition: "opacity 300ms",
279
+ }}
280
+ />
281
+ </>
282
+ )}
283
+
284
+ {/* Video */}
285
+ <video
286
+ key={src}
287
+ ref={videoRef}
288
+ src={src}
289
+ autoPlay={autoPlay}
290
+ loop={loop}
291
+ muted={muted}
292
+ playsInline={playsInline}
293
+ onLoadedData={() => setIsLoading(false)}
294
+ onError={() => setIsLoading(false)}
295
+ className={cn(
296
+ "h-full w-full object-cover transition-opacity duration-500",
297
+ isLoading ? "opacity-0" : "opacity-100",
298
+ )}
299
+ style={{
300
+ height: "100%",
301
+ width: "100%",
302
+ objectFit: "cover",
303
+ transition: "opacity 500ms",
304
+ opacity: isLoading ? 0 : 1,
305
+ }}
306
+ {...props}
307
+ />
308
+ </div>
309
+ </div>
310
+ </>
311
+ );
312
+ };
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
  });