diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx new file mode 100644 index 000000000000..6e4a11c82095 --- /dev/null +++ b/src/components/HedgehogGenerator/index.tsx @@ -0,0 +1,490 @@ +import { useUser } from 'hooks/useUser' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import ScrollArea from 'components/RadixUI/ScrollArea' +import SEO from 'components/seo' +import { IconArrowRightDown, IconCheck } from '@posthog/icons' +import { useToast } from '../../context/Toast' +import { OSInput } from 'components/OSForm' +import dayjs from 'dayjs' +import duration from 'dayjs/plugin/duration' +import CloudinaryImage from 'components/CloudinaryImage' + +dayjs.extend(duration) + +const POLL_INTERVAL_MS = 2000 + +interface GeneratedImage { + uid: string + url: string + status: string +} + +function ImageResult({ image }: { image: GeneratedImage }) { + const { addToast } = useToast() + const [downloaded, setDownloaded] = useState(false) + + const handleClick = async () => { + try { + const response = await fetch(image.url) + const blob = await response.blob() + const blobUrl = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = blobUrl + link.download = `hedgehog-${image.uid}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(blobUrl) + setDownloaded(true) + addToast({ + description: 'Image downloaded', + duration: 2000, + }) + } catch (err) { + console.error('Failed to download image:', err) + addToast({ + description: 'Failed to download image', + error: true, + duration: 2000, + }) + } + } + + return ( + + ) +} + +interface RateLimit { + remaining?: number + resetTime?: string | null + windowMs?: number +} + +function useRateLimit(rateLimit: RateLimit | undefined) { + const [timeLeft, setTimeLeft] = useState<{ hours: number; minutes: number; seconds: number } | null>(null) + const [progress, setProgress] = useState(0) + + const resetTime = rateLimit?.remaining === 0 ? rateLimit?.resetTime : null + const windowMs = rateLimit?.windowMs ?? 60 * 60 * 1000 + + useEffect(() => { + if (!resetTime) { + setTimeLeft(null) + setProgress(0) + return + } + + const updateCountdown = () => { + const now = dayjs() + const reset = dayjs(resetTime) + const diff = reset.diff(now) + + if (diff <= 0) { + setTimeLeft(null) + setProgress(100) + return false + } + + const elapsed = windowMs - diff + setProgress(Math.min(100, Math.max(0, (elapsed / windowMs) * 100))) + + const dur = dayjs.duration(diff) + setTimeLeft({ + hours: Math.floor(dur.asHours()), + minutes: dur.minutes(), + seconds: dur.seconds(), + }) + return true + } + + if (!updateCountdown()) return + + const interval = setInterval(() => { + if (!updateCountdown()) { + clearInterval(interval) + } + }, 1000) + + return () => clearInterval(interval) + }, [resetTime, windowMs]) + + const isActive = timeLeft !== null + + const formattedTime = React.useMemo(() => { + if (!timeLeft) return null + if (timeLeft.hours > 0) return `${timeLeft.hours}h ${timeLeft.minutes}m` + if (timeLeft.minutes > 0) return `${timeLeft.minutes}m ${timeLeft.seconds}s` + return `${timeLeft.seconds}s` + }, [timeLeft]) + + return { isActive, progress, formattedTime } +} + +function FloatingZs() { + return ( +
Usually takes about 2 minutes
++ Try again in{' '} + {formattedTime} +
++ Limit reached. Try again in{' '} + {formattedTime} +
+ )} + > + )} + + {error &&{error}
} + + {(image || loading) && ( +Click the image to save it.
++ Don't like this result? + +
++ Example: is holding a phone while sitting on a toilet pondering something +
+ )} + + {user?.imageGenerationRateLimit?.monthlyCount !== undefined && ( ++ {user.imageGenerationRateLimit.monthlyCount} image + {user.imageGenerationRateLimit.monthlyCount === 1 ? '' : 's'} generated this month +
+ )} +
- Uploaded by{' '}
+ {prompt ? (
+
+ Prompt: {prompt}
+
+ Duration: {formattedDuration}
+
- {name} - {isImage && width && height && ( - - ({width}x{height}) - - )} -
- {isImage ? ( -- {generateCloudinaryUrl('orig')} -
- -Select a folder to browse local files
-(Only works in supported browsers)
- -Click a filename to upload instantly
-Drag files here or paste from clipboard
-- {isPasting - ? 'Pasting image...' - : isDragActive - ? 'Drop files here' - : 'Drop files or paste to upload'} -
-- {isPasting - ? 'Processing clipboard image...' - : 'PNG, JPG, WEBP, GIF, MP4, MOV, PDF, SVG'} -
-- Cmd+V / Ctrl+V to paste from clipboard -
-{title}
{showCloseButton && ( diff --git a/src/components/Squeak/util/uploadImage.ts b/src/components/Squeak/util/uploadImage.ts index a902bb330c0c..2bdaecc6cc55 100644 --- a/src/components/Squeak/util/uploadImage.ts +++ b/src/components/Squeak/util/uploadImage.ts @@ -1,7 +1,7 @@ export default async function uploadImage( image: string | Blob, jwt: string, - ref?: { id: number; type: string; field: string } + ref?: { id: number; type: string; field: string; folderId?: number } ) { const formData = new FormData() formData.append('files', image) @@ -20,6 +20,16 @@ export default async function uploadImage( }) const imageData = await imageRes.json() + if (ref?.folderId) { + await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/add-media`, { + method: 'POST', + body: JSON.stringify({ mediaId: imageData?.[0]?.id, folderId: ref.folderId }), + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + }) + } if (!imageRes?.ok) { throw new Error(imageData?.error?.message) diff --git a/src/context/App.tsx b/src/context/App.tsx index dc0334ae79ef..d23af08a80a3 100644 --- a/src/context/App.tsx +++ b/src/context/App.tsx @@ -971,6 +971,25 @@ const appSettings: AppSettings = { type: 'standard', }, }, + 'hedgehog-generator': { + size: { + min: { + width: 550, + height: 650, + }, + max: { + width: 550, + height: 650, + }, + autoHeight: true, + }, + position: { + center: true, + }, + modal: { + type: 'standard', + }, + }, 'cool-tech-jobs-issue': { size: { min: { diff --git a/src/hooks/useMediaLibrary.tsx b/src/hooks/useMediaLibrary.tsx index 7022edea70b7..7cc854b38a5d 100644 --- a/src/hooks/useMediaLibrary.tsx +++ b/src/hooks/useMediaLibrary.tsx @@ -9,10 +9,11 @@ type UseMediaLibraryOptions = { search?: string limit?: number revalidateOnFocus?: boolean + folderId?: number | null } const query = (offset: number, options?: UseMediaLibraryOptions) => { - const { limit = 50, search, tag } = options || {} + const { limit = 50, search, tag, folderId } = options || {} const params: any = { pagination: { @@ -38,6 +39,14 @@ const query = (offset: number, options?: UseMediaLibraryOptions) => { } } + if (folderId) { + filters.mediaFolder = { + id: { + $eq: folderId, + }, + } + } + if (Object.keys(filters).length > 0) { params.filters = filters } diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx index db8c0dcf18f1..fd7200cda870 100644 --- a/src/hooks/useUser.tsx +++ b/src/hooks/useUser.tsx @@ -35,6 +35,14 @@ export type User = { metadata: any }[] } + imageGenerationRateLimit?: { + remaining: number + limit: number + resetTime: string | null + windowMs: number + monthlyCount: number + } + picasso?: boolean } type UserContextValue = { @@ -332,6 +340,7 @@ export const UserProvider: React.FC