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 ( +
+ z + + z + + + Z + +
+ ) +} + +const EXPECTED_DURATION_MS = 120000 + +const STATUS_MESSAGES = [ + 'Warming up the studio', + 'Sharpening the pencils', + 'Mixing the perfect colors', + 'Channeling the muse', + 'Adding extra spikiness', + 'Perfecting the snoot', + 'Fluffing the quills', + 'Consulting the hog gods', + 'Applying artistic flair', + 'Making it extra cute', +] + +function GeneratingLoader({ jobStatus }: { jobStatus: 'pending' | 'processing' | null }) { + const [elapsedMs, setElapsedMs] = useState(0) + const [messageIndex, setMessageIndex] = useState(0) + const startTimeRef = useRef(Date.now()) + + useEffect(() => { + startTimeRef.current = Date.now() + setElapsedMs(0) + setMessageIndex(0) + }, []) + + useEffect(() => { + const timer = setInterval(() => { + setElapsedMs(Date.now() - startTimeRef.current) + }, 100) + return () => clearInterval(timer) + }, []) + + useEffect(() => { + const messageInterval = EXPECTED_DURATION_MS / STATUS_MESSAGES.length + const messageTimer = setInterval(() => { + setMessageIndex((prev) => Math.min(prev + 1, STATUS_MESSAGES.length - 1)) + }, messageInterval) + return () => clearInterval(messageTimer) + }, []) + + const progress = Math.min(95, (elapsedMs / EXPECTED_DURATION_MS) * 100) + + const formatTime = (ms: number) => { + const dur = dayjs.duration(ms) + return dur.format('m:ss') + } + + return ( +
+
+
+ {formatTime(elapsedMs)} + + + {STATUS_MESSAGES[messageIndex]} + +
+ +
+
+
+
+
+ +

Usually takes about 2 minutes

+
+
+ ) +} + +function ProgressRing({ + progress, + size = 100, + strokeWidth = 6, +}: { + progress: number + size?: number + strokeWidth?: number +}) { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const offset = circumference - (progress / 100) * circumference + + return ( + + + + + ) +} + +export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () => void }) { + const { isModerator, getJwt, user } = useUser() + const [prompt, setPrompt] = useState('') + const [image, setImage] = useState(null) + const [loading, setLoading] = useState(false) + const [jobStatus, setJobStatus] = useState<'pending' | 'processing' | null>(null) + const [error, setError] = useState(null) + const abortControllerRef = useRef(null) + + const { isActive: isRateLimited, progress, formattedTime } = useRateLimit(user?.imageGenerationRateLimit) + + const pollJobStatus = useCallback( + async (jobId: string, jwt: string, signal: AbortSignal): Promise => { + while (!signal.aborted) { + const statusResponse = await fetch( + `${process.env.GATSBY_SQUEAK_API_HOST}/api/generate/status/${jobId}`, + { + headers: { Authorization: `Bearer ${jwt}` }, + signal, + } + ) + + if (!statusResponse.ok) { + throw new Error(`Failed to check job status (${statusResponse.status})`) + } + + const statusData = await statusResponse.json() + + switch (statusData.status) { + case 'completed': + return statusData.result?.images?.[0] || null + case 'failed': + throw new Error(statusData.error || 'Image generation failed') + case 'processing': + setJobStatus('processing') + break + case 'pending': + setJobStatus('pending') + break + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + return null + }, + [] + ) + + const generateImage = async (promptText: string) => { + abortControllerRef.current?.abort() + const abortController = new AbortController() + abortControllerRef.current = abortController + + setLoading(true) + setJobStatus('pending') + setError(null) + setImage(null) + + try { + const jwt = await getJwt() + if (!jwt) { + throw new Error('You must be logged in to generate images') + } + + const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/generate/image`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ prompt: `generate a hedgehog that ${promptText}` }), + signal: abortController.signal, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData?.error?.message || `Failed to generate image (${response.status})`) + } + + const data = await response.json() + const { jobId } = data + + if (!jobId) { + throw new Error('No job ID returned from server') + } + + const generatedImage = await pollJobStatus(jobId, jwt, abortController.signal) + if (generatedImage) { + setImage(generatedImage) + onGenerated?.() + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return + } + setError(err instanceof Error ? err.message : 'Failed to generate image') + } finally { + if (!abortController.signal.aborted) { + setLoading(false) + setJobStatus(null) + } + } + } + + useEffect(() => { + return () => { + abortControllerRef.current?.abort() + } + }, []) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!prompt.trim() || loading) return + generateImage(prompt) + } + + const handleRegenerate = () => { + if (!prompt.trim() || loading) return + generateImage(prompt) + } + + return isModerator ? ( +
+ + +
+ {isRateLimited && !image ? ( +
+
+ +
+ +
+ +
+

The hedgehogs are resting

+

+ Try again in{' '} + {formattedTime} +

+
+ ) : ( + <> +

Generate a hedgehog that...

+
+ ) => setPrompt(e.target.value)} + className="pr-12 disabled:cursor-not-allowed disabled:opacity-60" + disabled={loading || isRateLimited} + /> + + + {isRateLimited && ( +

+ Limit reached. Try again in{' '} + {formattedTime} +

+ )} + + )} + + {error &&

{error}

} + + {(image || loading) && ( +
+ {image && !loading && ( +
+

Click the image to save it.

+

+ Don't like this result? + +

+
+ )} + +
+ {loading ? ( + + ) : ( + image && + )} +
+
+ )} + + {!image && !loading && !isRateLimited && ( +

+ 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 +

+ )} +
+
+
+ ) : null +} diff --git a/src/components/MediaLibrary/Image.tsx b/src/components/MediaLibrary/Image.tsx index 5d886dec8ba1..2adf3b38187e 100644 --- a/src/components/MediaLibrary/Image.tsx +++ b/src/components/MediaLibrary/Image.tsx @@ -4,36 +4,76 @@ import React, { useEffect, useState } from 'react' import CreatableMultiSelect from 'components/CreatableMultiSelect' import { useUser } from 'hooks/useUser' import Link from 'components/Link' +import { OSSelect } from 'components/OSForm' +import { useMediaLibraryContext } from './context' +import Tooltip from 'components/Tooltip' +import dayjs from 'dayjs' +import duration from 'dayjs/plugin/duration' + +dayjs.extend(duration) + +const formatDuration = (ms: number): string => { + const dur = dayjs.duration(ms) + const minutes = Math.floor(dur.asMinutes()) + const seconds = dur.seconds() + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` +} const CLOUDINARY_BASE = `https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_CLOUD_NAME}` +interface ImageProps { + name?: string + previewUrl?: string + provider_metadata?: { + public_id: string + resource_type: string + } + ext?: string + width?: number + height?: number + id: number | string + profiles?: Array<{ id: number; firstName?: string; lastName?: string }> + tags?: Array<{ id: string; attributes: { label: string } }> + onMoved?: () => void + mediaFolder: any + prompt?: string + generationDurationMs?: number +} + export default function Image({ - name, + name = '', previewUrl, provider_metadata, - ext, + ext = '', width, height, - allTags, - fetchTags, id, profiles = [], - ...other -}: any) { + tags: initialTags = [], + onMoved, + mediaFolder, + prompt, + generationDurationMs, +}: ImageProps): JSX.Element { + const { folders, tags: allTags, fetchTags } = useMediaLibraryContext() const { public_id, resource_type } = provider_metadata || {} const { addToast } = useToast() const { getJwt, fetchUser } = useUser() const [loadingSize, setLoadingSize] = useState(null) - const [tags, setTags] = useState(other.tags || []) - const [availableOptions, setAvailableOptions] = useState(allTags) + const [tags, setTags] = useState(initialTags) + const [availableOptions, setAvailableOptions] = useState(allTags) const [uploader] = profiles + const [isMoving, setIsMoving] = useState(false) + const currentFolderId = mediaFolder?.id + const formattedDuration = generationDurationMs ? formatDuration(generationDurationMs) : null useEffect(() => { setAvailableOptions(allTags) }, [allTags]) const isImage = - resource_type === 'image' && ['png', 'jpg', 'jpeg', 'webp'].some((format) => ext.toLowerCase().includes(format)) + resource_type === 'image' && + ['png', 'jpg', 'jpeg', 'webp'].some((format) => ext?.toLowerCase().includes(format)) const resizeSizes = [200, 500, 800, 1000, 1600, 2000] const maxDimension = Math.max(width || 0, height || 0) @@ -46,7 +86,7 @@ export default function Image({ } else if (size === 'orig-optimized') { return `${CLOUDINARY_BASE}/${resource_type}/upload/q_auto,f_auto/${public_id}${ext}` } else { - const isPortrait = height > width + const isPortrait = (height || 0) > (width || 0) const transformation = isPortrait ? `h_${size}` : `w_${size}` return `${CLOUDINARY_BASE}/${resource_type}/upload/${transformation},c_limit,q_auto,f_auto/${public_id}${ext}` } @@ -76,7 +116,7 @@ export default function Image({ setTimeout(() => setLoadingSize(null), 500) } - const addTagToMedia = async (tagId: any, jwt: string) => { + const addTagToMedia = async (tagId: string, jwt: string) => { await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-tags/add-media`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, @@ -85,7 +125,7 @@ export default function Image({ await fetchUser() } - const removeTagFromMedia = async (tagId: any, jwt: string) => { + const removeTagFromMedia = async (tagId: string, jwt: string) => { await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-tags/remove-media`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, @@ -94,12 +134,14 @@ export default function Image({ await fetchUser() } - const handleChangeTags = async (tagIds: any[]) => { + const handleChangeTags = async (tagIds: string[]) => { const oldTagIds = tags.map((tag) => tag.id) - const addedTagIds = tagIds.filter((id) => !oldTagIds.includes(id)) - const removedTagIds = oldTagIds.filter((id) => !tagIds.includes(id)) + const addedTagIds = tagIds.filter((tagId) => !oldTagIds.includes(tagId)) + const removedTagIds = oldTagIds.filter((tagId) => !tagIds.includes(tagId)) - const newTags = tagIds.map((tagId) => availableOptions.find((t) => t.id === tagId)).filter(Boolean) + const newTags = tagIds + .map((tagId) => availableOptions.find((t) => t.id === tagId)) + .filter(Boolean) as typeof tags setTags(newTags) const jwt = await getJwt() @@ -139,13 +181,9 @@ export default function Image({ if (response.ok) { const { data } = await response.json() - setAvailableOptions((prev) => [...prev, data]) - setTags((prev) => [...prev, data]) - await addTagToMedia(data.id, jwt) - fetchTags() } } catch (error) { @@ -154,6 +192,76 @@ export default function Image({ } } + const handleMoveToFolder = async (targetFolderId: string) => { + if (!targetFolderId) return + + if (targetFolderId === String(currentFolderId)) { + await handleRemoveFromFolder() + return + } + + setIsMoving(true) + try { + const jwt = await getJwt() + if (!jwt) return + + if (currentFolderId) { + await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/remove-media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ mediaId: id, folderId: currentFolderId }), + }) + } + + await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/add-media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ mediaId: id, folderId: Number(targetFolderId) }), + }) + + const targetFolder = folders.find((f) => f.id === Number(targetFolderId)) + addToast({ + description: `Moved to ${targetFolder?.attributes?.name || 'folder'}`, + duration: 3000, + }) + + onMoved?.() + } catch (error) { + console.error('Failed to move to folder:', error) + addToast({ description: 'Failed to move to folder', error: true, duration: 3000 }) + } finally { + setIsMoving(false) + } + } + + const handleRemoveFromFolder = async () => { + if (!currentFolderId) return + + setIsMoving(true) + try { + const jwt = await getJwt() + if (!jwt) return + + await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/remove-media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ mediaId: id, folderId: currentFolderId }), + }) + + addToast({ + description: 'Removed from folder', + duration: 3000, + }) + + onMoved?.() + } catch (error) { + console.error('Failed to remove from folder:', error) + addToast({ description: 'Failed to remove from folder', error: true, duration: 3000 }) + } finally { + setIsMoving(false) + } + } + return (
  • )} -
    - ({ label: tag.attributes.label, value: tag.id }))} - value={tags.map((tag) => tag.id)} - allowCreate - onChange={handleChangeTags} - onCreate={handleCreateTag} - hideLabel - /> +
    + {folders.length > 0 && ( +
    + ({ + label: folder.attributes.name, + value: String(folder.id), + }))} + value={currentFolderId ? String(currentFolderId) : ''} + onChange={handleMoveToFolder} + disabled={isMoving} + searchable={false} + className="h-[38px]" + /> +
    + )} +
    + ({ label: tag.attributes.label, value: tag.id }))} + value={tags.map((tag) => tag.id)} + allowCreate + onChange={handleChangeTags} + onCreate={handleCreateTag} + hideLabel + /> +
    {uploader && (

    - Uploaded by{' '} + {prompt ? ( + ( +

    +

    + Prompt: {prompt} +

    + {formattedDuration && ( +

    + Duration: {formattedDuration} +

    + )} +
    + )} + > + Generated + + ) : ( + 'Uploaded' + )}{' '} + by{' '} void }) { + const mediaCount = folder.mediaCount + return ( + + ) +} + +export default function Libraries(): JSX.Element { + const { fetchUser: refreshUser, user } = useUser() + const [search, setSearch] = useState('') + const [tag, setTag] = useState('all-tags') + const { addWindow } = useApp() + + const { + folders: allFolders, + foldersLoading, + currentFolder, + setCurrentFolder, + folderStack, + setFolderStack, + tags, + } = useMediaLibraryContext() + const folders = allFolders.filter((f) => + currentFolder ? f.attributes.parent?.data?.id === currentFolder.id : !f.attributes.parent?.data + ) + const { + images, + isLoading: imagesLoading, + hasMore, + fetchMore, + refresh: refreshImages, + } = useMediaLibrary({ + showAll: true, + search, + tag, + folderId: currentFolder?.id ?? null, + revalidateOnFocus: true, + }) + + const isLoading = foldersLoading || imagesLoading + const filteredFolders = folders.filter((f) => f.attributes.name.toLowerCase().includes(search.toLowerCase())) + const hasContent = filteredFolders.length > 0 || images.length > 0 + const showLoading = isLoading && !hasContent + + const handleFolderClick = (folder: MediaFolder) => { + if (currentFolder) setFolderStack((prev) => [...prev, currentFolder]) + setCurrentFolder(folder) + setSearch('') + } + + const handleBack = () => { + setCurrentFolder(folderStack.at(-1) ?? null) + setFolderStack((prev) => prev.slice(0, -1)) + setSearch('') + } + + const handleImageMoved = () => { + refreshImages() + refreshUser() + } + + const handleGenerated = () => { + refreshImages() + refreshUser() + } + + const handleGenerateClick = () => { + addWindow( + + ) + } + + return ( +
    +
    +
    + ) => setSearch(e.target.value)} + /> +
    +
    + ({ label: t.attributes.label, value: t.id })), + ]} + value={tag} + onChange={setTag} + placeholder="Select tag..." + /> +
    +
    + + {currentFolder && ( +
    +
    + } + onClick={handleBack} + /> + {currentFolder.attributes.name} + + {currentFolder.mediaCount} asset{currentFolder.mediaCount === 1 ? '' : 's'} + +
    + {currentFolder.attributes?.name === 'Hedgehogs' && user?.picasso && ( + } onClick={handleGenerateClick}> + Generate + + )} +
    + )} + +
    + {showLoading && ( +
    + +
    + )} + + {!showLoading && !hasContent && ( +
    + {currentFolder ? 'No assets found in this folder' : 'No folders found'} +
    + )} + + {hasContent && ( +
    + {tag === 'all-tags' && filteredFolders.length > 0 && ( +
      + {filteredFolders.map((folder) => ( +
    • + handleFolderClick(folder)} /> +
    • + ))} +
    + )} + + {(currentFolder || !!search || tag !== 'all-tags') && images.length > 0 && ( + <> +
      + {images.map((image: any) => ( +
    • + +
    • + ))} +
    + {hasMore && ( +
    + + {isLoading ? ( + + ) : ( + 'Load more' + )} + +
    + )} + + )} +
    + )} +
    +
    + ) +} diff --git a/src/components/MediaLibrary/Uploads.tsx b/src/components/MediaLibrary/Uploads.tsx new file mode 100644 index 000000000000..e81f7dee8b53 --- /dev/null +++ b/src/components/MediaLibrary/Uploads.tsx @@ -0,0 +1,122 @@ +import { OSInput, OSSelect } from 'components/OSForm' +import { Checkbox } from 'components/RadixUI/Checkbox' +import React, { useEffect, useMemo, useState } from 'react' +import Image from './Image' +import ScrollArea from 'components/RadixUI/ScrollArea' +import { useMediaLibrary } from 'hooks/useMediaLibrary' +import OSButton from 'components/OSButton' +import { IconSpinner, IconUpload } from '@posthog/icons' +import debounce from 'lodash/debounce' +import { useMediaLibraryContext } from './context' +import { useUser } from 'hooks/useUser' + +interface UploadsProps { + mediaUploading: number + onUploadClick: () => void +} + +export default function Uploads({ mediaUploading, onUploadClick }: UploadsProps): JSX.Element { + const { tags } = useMediaLibraryContext() + const { fetchUser: refreshUser } = useUser() + const [showAll, setShowAll] = useState(false) + const [tag, setTag] = useState('all-tags') + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + + const debouncedSetSearch = useMemo( + () => + debounce((value: string) => { + setDebouncedSearch(value) + }, 500), + [] + ) + + useEffect(() => { + debouncedSetSearch(search) + }, [search, debouncedSetSearch]) + + useEffect(() => { + return () => debouncedSetSearch.cancel() + }, [debouncedSetSearch]) + + const { images, isLoading, hasMore, fetchMore, refresh } = useMediaLibrary({ + showAll, + tag, + search: debouncedSearch, + revalidateOnFocus: true, + }) + + const handleImageMoved = () => { + refresh() + refreshUser() + } + + return ( +
    +
    +
    + ) => setSearch(e.target.value)} + /> +
    +
    + setShowAll(!checked)} /> + +
    +
    + ({ label: tag.attributes.label, value: tag.id })), + ]} + value={tag} + onChange={setTag} + placeholder="Select tag..." + /> +
    +
    +
    + Uploads + } onClick={onUploadClick}> + Upload + +
    +
    +
      + {mediaUploading > 0 && + Array.from({ length: mediaUploading }).map((_, index) => ( +
    • + ))} + {isLoading && images.length === 0 ? ( +
    • Loading images...
    • + ) : images.length === 0 ? ( +
    • No images found
    • + ) : ( + images?.map((image: any) => ( +
    • + +
    • + )) + )} +
    + {hasMore && ( +
    + + {isLoading ? : 'Load more'} + +
    + )} +
    +
    + ) +} diff --git a/src/components/MediaLibrary/context.tsx b/src/components/MediaLibrary/context.tsx new file mode 100644 index 000000000000..1bb2099c850f --- /dev/null +++ b/src/components/MediaLibrary/context.tsx @@ -0,0 +1,125 @@ +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' +import { useUser } from 'hooks/useUser' +import qs from 'qs' + +export interface MediaFolder { + id: number + mediaCount: number + attributes: { + name: string + parent?: { + data: { id: number } | null + } + children?: { + data: MediaFolder[] + } + } +} + +export interface MediaTag { + id: string + attributes: { + label: string + } +} + +interface MediaLibraryContextType { + folders: MediaFolder[] + tags: MediaTag[] + foldersLoading: boolean + tagsLoading: boolean + fetchFolders: () => Promise + fetchTags: () => Promise + currentFolder: MediaFolder | null + setCurrentFolder: React.Dispatch> + folderStack: MediaFolder[] + setFolderStack: React.Dispatch> +} + +const MediaLibraryContext = createContext(null) + +export function MediaLibraryProvider({ children }: { children: React.ReactNode }) { + const { getJwt } = useUser() + const [folders, setFolders] = useState([]) + const [tags, setTags] = useState([]) + const [foldersLoading, setFoldersLoading] = useState(true) + const [tagsLoading, setTagsLoading] = useState(true) + const [currentFolder, setCurrentFolder] = useState(null) + const [folderStack, setFolderStack] = useState([]) + + const fetchFolders = useCallback(async () => { + try { + setFoldersLoading(true) + const jwt = await getJwt() + if (!jwt) return + + const query = qs.stringify( + { populate: ['parent', 'children'], sort: ['name:asc'] }, + { encodeValuesOnly: true } + ) + const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders?${query}`, { + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + const data = await response.json() + setFolders(data.data || []) + } catch (error) { + console.error('Failed to fetch folders:', error) + } finally { + setFoldersLoading(false) + } + }, [getJwt]) + + const fetchTags = useCallback(async () => { + try { + setTagsLoading(true) + const jwt = await getJwt() + if (!jwt) return + + const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-tags`, { + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + const data = await response.json() + setTags(data.data || []) + } catch (error) { + console.error('Failed to fetch tags:', error) + } finally { + setTagsLoading(false) + } + }, [getJwt]) + + useEffect(() => { + fetchFolders() + fetchTags() + }, [fetchFolders, fetchTags]) + + return ( + + {children} + + ) +} + +export function useMediaLibraryContext() { + const context = useContext(MediaLibraryContext) + if (!context) { + throw new Error('useMediaLibraryContext must be used within a MediaLibraryProvider') + } + return context +} diff --git a/src/components/MediaLibrary/index.tsx b/src/components/MediaLibrary/index.tsx index 0721ad82d043..d0e9ad28f2af 100644 --- a/src/components/MediaLibrary/index.tsx +++ b/src/components/MediaLibrary/index.tsx @@ -1,132 +1,160 @@ -import { OSInput, OSSelect } from 'components/OSForm' -import { Checkbox } from 'components/RadixUI/Checkbox' -import React, { useEffect, useMemo, useState } from 'react' -import Image from './Image' -import ScrollArea from 'components/RadixUI/ScrollArea' -import { useMediaLibrary } from 'hooks/useMediaLibrary' +import { IconUpload } from '@posthog/icons' +import uploadImage from 'components/Squeak/util/uploadImage' +import { useApp } from '../../context/App' import { useUser } from 'hooks/useUser' -import OSButton from 'components/OSButton' -import { IconSpinner } from '@posthog/icons' -import debounce from 'lodash/debounce' +import React, { useEffect, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { useWindow } from '../../context/Window' +import { useToast } from '../../context/Toast' +import ScrollArea from 'components/RadixUI/ScrollArea' +import Tabs from 'components/RadixUI/Tabs' +import Libraries from 'components/MediaLibrary/Libraries' +import Uploads from 'components/MediaLibrary/Uploads' +import { useMediaLibraryContext } from 'components/MediaLibrary/context' + +export default function MediaLibrary() { + const { appWindow } = useWindow() + const { setWindowTitle } = useApp() + const { getJwt, user, fetchUser } = useUser() + const [loading, setLoading] = useState(0) + const [activeTab, setActiveTab] = useState('libraries') + const { addToast } = useToast() + const { currentFolder, setCurrentFolder } = useMediaLibraryContext() + const isModerator = user?.role?.type === 'moderator' + + const handleTabChange = (value: string) => { + setActiveTab(value) + setCurrentFolder(null) + } -export default function MediaLibrary({ mediaUploading }: { mediaUploading: number }): JSX.Element { - const { getJwt } = useUser() - const [showAll, setShowAll] = useState(false) - const [tag, setTag] = useState('all-tags') - const [search, setSearch] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - const [tags, setTags] = useState<{ id: string; attributes: { label: string } }[]>([]) + const onDrop = async (acceptedFiles: File[]) => { + const profileID = user?.profile?.id + const jwt = await getJwt() + if (isModerator && profileID && jwt) { + setActiveTab('uploads') + console.log('currentFolder', currentFolder) + await Promise.all( + acceptedFiles.map(async (file: File) => { + setLoading((loadingNumber) => loadingNumber + 1) + await uploadImage(file, jwt, { + field: 'images', + id: profileID, + type: 'api::profile.profile', + folderId: currentFolder?.id, + }) + setLoading((loadingNumber) => loadingNumber - 1) + }) + ).catch((err) => console.error(err)) + await fetchUser() + } + } - const debouncedSetSearch = useMemo( - () => - debounce((value: string) => { - setDebouncedSearch(value) - }, 500), - [] - ) + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ onDrop, noClick: true }) useEffect(() => { - debouncedSetSearch(search) - }, [search, debouncedSetSearch]) + if (appWindow) { + setWindowTitle(appWindow, 'Upload media') + } + }, []) useEffect(() => { - return () => debouncedSetSearch.cancel() - }, [debouncedSetSearch]) + const handlePaste = async (e: ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + + const imageItems = Array.from(items).filter((item) => item.type.startsWith('image/')) + if (imageItems.length === 0) return - const { images, isLoading, hasMore, fetchMore } = useMediaLibrary({ - showAll, - tag, - search: debouncedSearch, - }) + e.preventDefault() - const fetchTags = async () => { - try { - const jwt = await getJwt() - const tags = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-tags`, { - headers: { - Authorization: `Bearer ${jwt}`, - }, - }) - .then((res) => res.json()) - .then((data) => data.data) - setTags(tags) - } catch (error) { - console.error('Failed to fetch tags:', error) + try { + const files = await Promise.all( + imageItems.map(async (item) => { + const blob = item.getAsFile() + if (!blob) return null + + const extension = blob.type.split('/')[1] || 'png' + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const fileName = `pasted-image-${timestamp}.${extension}` + + return new File([blob], fileName, { type: blob.type }) + }) + ) + + const validFiles = files.filter((f): f is File => f !== null) + if (validFiles.length > 0) { + await onDrop(validFiles) + addToast({ + description: `Pasted ${validFiles.length} image${ + validFiles.length > 1 ? 's' : '' + } from clipboard`, + duration: 3000, + }) + } + } catch (err) { + console.error('Error pasting image:', err) + addToast({ + description: 'Failed to paste image from clipboard', + error: true, + duration: 3000, + }) + } } - } - useEffect(() => { - fetchTags() - }, []) + window.addEventListener('paste', handlePaste) + return () => { + window.removeEventListener('paste', handlePaste) + } + }, [onDrop, addToast]) - return ( -
    -
    -
    -
    - ) => setSearch(e.target.value)} - /> -
    -
    - setShowAll(!checked)} - /> - -
    -
    - ({ label: tag.attributes.label, value: tag.id })), - ]} - value={tag} - onChange={setTag} - placeholder="Select tag..." - /> + return isModerator ? ( +
    + + +
    +
    +
    + +
    + + + Libraries + + + Uploads + + +
    + + + + + + +
    +
    -
    - -
      - {mediaUploading > 0 && - Array.from({ length: mediaUploading }).map((_, index) => ( -
    • - ))} - {isLoading && images.length === 0 ? ( -
    • Loading images...
    • - ) : images.length === 0 ? ( -
    • No images found
    • - ) : ( - images?.map((image: { id: string | number; [key: string]: unknown }) => ( -
    • - -
    • - )) - )} -
    - {hasMore && ( -
    - - {isLoading ? : 'Load more'} - -
    - )} -
    + + {isDragActive && ( +
    +
    + + Drop files to upload +
    -
    + )}
    - ) + ) : null } diff --git a/src/components/MediaUploadModal/index.tsx b/src/components/MediaUploadModal/index.tsx index 2f1b71f68a16..2925dbedeb11 100644 --- a/src/components/MediaUploadModal/index.tsx +++ b/src/components/MediaUploadModal/index.tsx @@ -1,526 +1,11 @@ -import { - IconCopy, - IconUpload, - IconFolder, - IconDocument, - IconChevronRight, - IconChevronDown, - IconX, -} from '@posthog/icons' -import Modal from 'components/Modal' -import uploadImage from 'components/Squeak/util/uploadImage' -import { useApp } from '../../context/App' -import { useUser } from 'hooks/useUser' -import React, { useEffect, useState } from 'react' -import { useDropzone } from 'react-dropzone' -import { useWindow } from '../../context/Window' -import { useToast } from '../../context/Toast' -import Loading from 'components/Loading' -import ScrollArea from 'components/RadixUI/ScrollArea' +import React from 'react' import MediaLibrary from 'components/MediaLibrary' +import { MediaLibraryProvider } from 'components/MediaLibrary/context' -// File System Access API types -declare global { - interface Window { - showDirectoryPicker(): Promise - } - - interface FileSystemDirectoryHandle { - values(): AsyncIterableIterator - } -} - -const Image = ({ name, previewUrl, provider_metadata: { public_id, resource_type }, ext, width, height }: any) => { - const { addToast } = useToast() - const [loadingSize, setLoadingSize] = useState(null) - - const cloudinaryBase = `https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_CLOUD_NAME}` - - const isImage = - resource_type === 'image' && ['png', 'jpg', 'jpeg', 'webp'].some((format) => ext.toLowerCase().includes(format)) - - const resizeSizes = [200, 500, 800, 1000, 1600, 2000] - const maxDimension = Math.max(width || 0, height || 0) - - const availableSizes = isImage ? resizeSizes.filter((size) => size < maxDimension) : [] - - const generateCloudinaryUrl = (size: string | number) => { - if (size === 'orig') { - return `${cloudinaryBase}/${resource_type}/upload/${public_id}${ext}` - } else if (size === 'orig-optimized') { - return `${cloudinaryBase}/${resource_type}/upload/q_auto,f_auto/${public_id}${ext}` - } else { - const isPortrait = height > width - const transformation = isPortrait ? `h_${size}` : `w_${size}` - return `${cloudinaryBase}/${resource_type}/upload/${transformation},c_limit,q_auto,f_auto/${public_id}${ext}` - } - } - - const handleCopy = async (size: string | number) => { - setLoadingSize(size) - const url = generateCloudinaryUrl(size) - - try { - await navigator.clipboard.writeText(url) - const sizeText = - size === 'orig' ? 'Original' : size === 'orig-optimized' ? 'Original (optimized)' : `${size}px` - addToast({ - description: `${sizeText} image URL copied to clipboard`, - duration: 3000, - }) - } catch (err) { - console.error('Failed to copy text: ', err) - addToast({ - description: 'Failed to copy URL', - error: true, - duration: 3000, - }) - } - - setTimeout(() => setLoadingSize(null), 500) - } - - return ( -
  • -
    - -
    -
    -

    - {name} - {isImage && width && height && ( - - ({width}x{height}) - - )} -

    - {isImage ? ( -
    - {availableSizes.map((size) => ( - - ))} - - -
    - ) : ( -
    -

    - {generateCloudinaryUrl('orig')} -

    - -
    - )} -
    -
  • - ) -} - -interface FileNode { - name: string - type: 'file' | 'directory' - handle: FileSystemHandle - children?: FileNode[] - expanded?: boolean - lastModified?: number -} - -const FileExplorer = ({ onFileDrop }: { onFileDrop: (files: File[]) => void }) => { - const [rootDirectory, setRootDirectory] = useState(null) - const [loading, setLoading] = useState(false) - const { addToast } = useToast() - - const openDirectory = async () => { - try { - const dirHandle = await window.showDirectoryPicker() - const rootNode: FileNode = { - name: dirHandle.name, - type: 'directory', - handle: dirHandle, - expanded: true, - } - await loadDirectoryContents(rootNode) - setRootDirectory(rootNode) - } catch (err) { - console.error('Error opening directory:', err) - } - } - - const loadDirectoryContents = async (node: FileNode) => { - if (node.type !== 'directory') return - - const dirHandle = node.handle as FileSystemDirectoryHandle - const children: FileNode[] = [] - - for await (const entry of dirHandle.values()) { - const fileNode: FileNode = { - name: entry.name, - type: entry.kind as 'file' | 'directory', - handle: entry, - expanded: false, - } - - // Get last modified time for files - if (entry.kind === 'file') { - try { - const fileHandle = entry as FileSystemFileHandle - const file = await fileHandle.getFile() - fileNode.lastModified = file.lastModified - } catch (err) { - console.error('Error getting file info:', err) - } - } - - children.push(fileNode) - } - - // Sort: directories first, then by most recent modification date for files - node.children = children.sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1 - - // For files, sort by most recent first - if (a.type === 'file' && b.type === 'file') { - const aTime = a.lastModified || 0 - const bTime = b.lastModified || 0 - return bTime - aTime // Descending order (most recent first) - } - - // For directories, keep alphabetical - return a.name.localeCompare(b.name) - }) - } - - const toggleDirectory = async (node: FileNode) => { - if (node.type !== 'directory') return - - if (!node.expanded && !node.children) { - await loadDirectoryContents(node) - } - - node.expanded = !node.expanded - setRootDirectory({ ...rootDirectory! }) - } - - const handleFileDrag = async (e: React.DragEvent, node: FileNode) => { - if (node.type !== 'file') return - - const fileHandle = node.handle as FileSystemFileHandle - const file = await fileHandle.getFile() - - // Create a DataTransfer object - const dt = new DataTransfer() - dt.items.add(file) - e.dataTransfer = dt - e.dataTransfer.effectAllowed = 'copy' - } - - const handleFileClick = async (node: FileNode) => { - if (node.type !== 'file') return - - const supportedFormats = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'mp4', 'mov'] - const extension = node.name.split('.').pop()?.toLowerCase() - - if (!extension || !supportedFormats.includes(extension)) { - addToast({ - description: 'Unsupported file format', - error: true, - duration: 3000, - }) - return - } - - setLoading(true) - try { - const fileHandle = node.handle as FileSystemFileHandle - const file = await fileHandle.getFile() - onFileDrop([file]) - } catch (err) { - console.error('Error uploading file:', err) - addToast({ - description: 'Failed to upload file', - error: true, - duration: 3000, - }) - } finally { - setLoading(false) - } - } - - const renderNode = (node: FileNode, level = 0) => { - const isImage = - node.type === 'file' && - ['png', 'jpg', 'jpeg', 'webp', 'gif'].some((ext) => node.name.toLowerCase().endsWith(`.${ext}`)) - - // Format the last modified date - const formatDate = (timestamp?: number) => { - if (!timestamp) return '' - const date = new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - - if (diffHours < 1) { - const diffMins = Math.floor(diffMs / (1000 * 60)) - return `${diffMins}m ago` - } else if (diffHours < 24) { - return `${diffHours}h ago` - } else if (diffHours < 168) { - // 7 days - const diffDays = Math.floor(diffHours / 24) - return `${diffDays}d ago` - } else { - return date.toLocaleDateString() - } - } - - return ( -
    -
    (node.type === 'directory' ? toggleDirectory(node) : handleFileClick(node))} - draggable={node.type === 'file'} - onDragStart={(e) => handleFileDrag(e, node)} - > - {node.type === 'directory' ? ( - <> - {node.expanded ? ( - - ) : ( - - )} - - - ) : ( -
    - -
    - )} - {node.name} - {node.type === 'file' && node.lastModified && ( - {formatDate(node.lastModified)} - )} - {loading && } -
    - {node.type === 'directory' && node.expanded && node.children && ( -
    {node.children.map((child) => renderNode(child, level + 1))}
    - )} -
    - ) - } - +export default function MediaUploadModal() { return ( -
    - {!rootDirectory ? ( -
    -

    Select a folder to browse local files

    -

    (Only works in supported browsers)

    - -
    - ) : ( -
    -
    - {rootDirectory.name} - -
    - {renderNode(rootDirectory)} -
    - )} -
    + + + ) } - -export default function MediaUploadModal() { - const { appWindow } = useWindow() - const { setWindowTitle } = useApp() - const { getJwt, user, fetchUser } = useUser() - const [loading, setLoading] = useState(0) - const [isPasting, setIsPasting] = useState(false) - const { addToast } = useToast() - const isModerator = user?.role?.type === 'moderator' - - const onDrop = async (acceptedFiles: File[]) => { - const profileID = user?.profile?.id - const jwt = await getJwt() - if (isModerator && profileID && jwt) { - await Promise.all( - acceptedFiles.map(async (file: File) => { - setLoading((loadingNumber) => loadingNumber + 1) - await uploadImage(file, jwt, { - field: 'images', - id: profileID, - type: 'api::profile.profile', - }) - setLoading((loadingNumber) => loadingNumber - 1) - }) - ).catch((err) => console.error(err)) - await fetchUser() - } - } - - useEffect(() => { - if (appWindow) { - setWindowTitle(appWindow, 'Upload media') - } - }, []) - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) - - useEffect(() => { - const handlePaste = async (e: ClipboardEvent) => { - const items = e.clipboardData?.items - if (!items) return - - const imageItems = Array.from(items).filter((item) => item.type.startsWith('image/')) - if (imageItems.length === 0) return - - e.preventDefault() - setIsPasting(true) - - try { - const files = await Promise.all( - imageItems.map(async (item) => { - const blob = item.getAsFile() - if (!blob) return null - - // Create a proper filename with extension - const extension = blob.type.split('/')[1] || 'png' - const timestamp = new Date().toISOString().replace(/[:.]/g, '-') - const fileName = `pasted-image-${timestamp}.${extension}` - - // Create a new File object with the proper name - return new File([blob], fileName, { type: blob.type }) - }) - ) - - const validFiles = files.filter((f): f is File => f !== null) - if (validFiles.length > 0) { - await onDrop(validFiles) - addToast({ - description: `Pasted ${validFiles.length} image${ - validFiles.length > 1 ? 's' : '' - } from clipboard`, - duration: 3000, - }) - } - } catch (err) { - console.error('Error pasting image:', err) - addToast({ - description: 'Failed to paste image from clipboard', - error: true, - duration: 3000, - }) - } finally { - setIsPasting(false) - } - } - - window.addEventListener('paste', handlePaste) - return () => { - window.removeEventListener('paste', handlePaste) - } - }, [onDrop, addToast]) - - return isModerator ? ( - -
    -
    -
    -
    -

    Local files

    -

    Click a filename to upload instantly

    - -
    - -
    -

    Upload media

    -

    Drag files here or paste from clipboard

    -
    -
    - {isPasting ? ( - - ) : ( - - )} -

    - {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 -

    -
    - -
    -
    -
    - - -
    -
    -
    - ) : null -} diff --git a/src/components/OSForm/select.tsx b/src/components/OSForm/select.tsx index 79bf9d4a9156..b1d803333912 100644 --- a/src/components/OSForm/select.tsx +++ b/src/components/OSForm/select.tsx @@ -149,6 +149,7 @@ const OSSelect = ({ }, []) // Remove dependency on isOpen // Focus search input when dropdown opens and highlight first non-header option + // Only run when isOpen changes - not when filteredOptions changes (which can happen on parent re-renders) useEffect(() => { if (isOpen) { if (searchable && searchInputRef.current) { @@ -162,7 +163,7 @@ const OSSelect = ({ } else { setHighlightedIndex(-1) } - }, [isOpen, searchable, filteredOptions]) + }, [isOpen]) // Scroll highlighted option into view useEffect(() => { @@ -174,13 +175,14 @@ const OSSelect = ({ } }, [highlightedIndex]) - // Auto-highlight first selectable option when filtered options change + // Auto-highlight first selectable option when search term changes + // This handles the case where the user types in the search box and results filter useEffect(() => { - if (isOpen) { + if (isOpen && searchTerm) { const firstSelectableIndex = filteredOptions.findIndex((opt) => !opt.isHeader) setHighlightedIndex(firstSelectableIndex >= 0 ? firstSelectableIndex : -1) } - }, [filteredOptions, isOpen]) + }, [searchTerm]) // Helper functions to find next/previous non-header option const findNextSelectableIndex = (currentIndex: number): number => { diff --git a/src/components/RadixUI/Modal.tsx b/src/components/RadixUI/Modal.tsx index 7b3c324c606b..76dfe450ceaf 100644 --- a/src/components/RadixUI/Modal.tsx +++ b/src/components/RadixUI/Modal.tsx @@ -61,7 +61,7 @@ const Modal = ({ : {} } > -
    +

    {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 = ({ children }) => { images: { sort: ['createdAt:desc'], populate: { + mediaFolder: true, tags: true, related: true, }, diff --git a/tailwind.config.js b/tailwind.config.js index c71ff23dc3f1..f0993954e846 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -396,6 +396,14 @@ module.exports = { transform: 'translateY(-10px)', }, }, + float: { + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-6px)' }, + }, + breathe: { + '0%, 100%': { transform: 'scale(1)' }, + '50%': { transform: 'scale(1.03)' }, + }, }, animation: { wiggle: 'wiggle .2s ease-in-out 3', @@ -417,6 +425,8 @@ module.exports = { 'gradient-rotate': 'gradient-rotate 3s ease-in-out infinite', 'slide-up-fade-in': 'slideUpFadeIn 300ms ease-out forwards', 'slide-up-fade-out': 'slideUpFadeOut 300ms ease-in forwards', + float: 'float 2s ease-in-out infinite', + breathe: 'breathe 3s ease-in-out infinite', }, containers: { '2xs': '16rem',