From 126659b37803272ebd111c60cde00af0f5f34536 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Thu, 19 Feb 2026 10:20:06 -0800 Subject: [PATCH 01/31] tabs --- src/components/MediaLibrary/Libraries.tsx | 85 ++++++++++++ src/components/MediaLibrary/Uploads.tsx | 130 ++++++++++++++++++ src/components/MediaLibrary/index.tsx | 154 +++++----------------- src/components/RadixUI/Modal.tsx | 2 +- 4 files changed, 247 insertions(+), 124 deletions(-) create mode 100644 src/components/MediaLibrary/Libraries.tsx create mode 100644 src/components/MediaLibrary/Uploads.tsx diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx new file mode 100644 index 000000000000..d52320559391 --- /dev/null +++ b/src/components/MediaLibrary/Libraries.tsx @@ -0,0 +1,85 @@ +import { OSInput, OSSelect } from 'components/OSForm' +import React, { useState } from 'react' +import ScrollArea from 'components/RadixUI/ScrollArea' +import { IconChevronDown } from '@posthog/icons' + +interface Library { + name: string + folder: string + assetCount: number +} + +const libraries: Library[] = [{ name: 'Hedgehogs', folder: 'hogs', assetCount: 0 }] + +export default function Libraries(): JSX.Element { + const [search, setSearch] = useState('') + const [tag, setTag] = useState('all-tags') + const [tags] = useState<{ id: string; attributes: { label: string } }[]>([]) + + const handleSearch = (value: string) => { + setSearch(value) + // TODO: Implement library search + } + + const handleTagChange = (value: string) => { + setTag(value) + // TODO: Implement library tag filtering + } + + const handleLibraryClick = (library: Library) => { + // TODO: Implement library folder navigation + console.log('Clicked library:', library) + } + + return ( +
+
+
+ ) => handleSearch(e.target.value)} + /> +
+
+ ({ label: tag.attributes.label, value: tag.id })), + ]} + value={tag} + onChange={handleTagChange} + placeholder="Select tag..." + /> +
+
+
+ +
    + {libraries.map((library) => ( +
  • + +
  • + ))} +
+
+
+
+ ) +} diff --git a/src/components/MediaLibrary/Uploads.tsx b/src/components/MediaLibrary/Uploads.tsx new file mode 100644 index 000000000000..5606ea20f8fa --- /dev/null +++ b/src/components/MediaLibrary/Uploads.tsx @@ -0,0 +1,130 @@ +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 { useUser } from 'hooks/useUser' +import OSButton from 'components/OSButton' +import { IconSpinner } from '@posthog/icons' +import debounce from 'lodash/debounce' + +interface UploadsProps { + mediaUploading: number +} + +export default function Uploads({ mediaUploading }: UploadsProps): 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 debouncedSetSearch = useMemo( + () => + debounce((value: string) => { + setDebouncedSearch(value) + }, 500), + [] + ) + + useEffect(() => { + debouncedSetSearch(search) + }, [search, debouncedSetSearch]) + + useEffect(() => { + return () => debouncedSetSearch.cancel() + }, [debouncedSetSearch]) + + const { images, isLoading, hasMore, fetchMore } = useMediaLibrary({ + showAll, + tag, + search: debouncedSearch, + }) + + 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) + } + } + + useEffect(() => { + fetchTags() + }, []) + + return ( +
+
+
+ ) => setSearch(e.target.value)} + /> +
+
+ setShowAll(!checked)} /> + +
+
+ ({ label: tag.attributes.label, value: tag.id })), + ]} + value={tag} + onChange={setTag} + placeholder="Select tag..." + /> +
+
+
+ +
    + {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'} + +
+ )} +
+
+
+ ) +} diff --git a/src/components/MediaLibrary/index.tsx b/src/components/MediaLibrary/index.tsx index 0721ad82d043..702c7085317b 100644 --- a/src/components/MediaLibrary/index.tsx +++ b/src/components/MediaLibrary/index.tsx @@ -1,132 +1,40 @@ -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 { useUser } from 'hooks/useUser' -import OSButton from 'components/OSButton' -import { IconSpinner } from '@posthog/icons' -import debounce from 'lodash/debounce' +import React, { useState } from 'react' +import Tabs from 'components/RadixUI/Tabs' +import Libraries from './Libraries' +import Uploads from './Uploads' -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 debouncedSetSearch = useMemo( - () => - debounce((value: string) => { - setDebouncedSearch(value) - }, 500), - [] - ) - - useEffect(() => { - debouncedSetSearch(search) - }, [search, debouncedSetSearch]) - - useEffect(() => { - return () => debouncedSetSearch.cancel() - }, [debouncedSetSearch]) - - const { images, isLoading, hasMore, fetchMore } = useMediaLibrary({ - showAll, - tag, - search: debouncedSearch, - }) - - 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) - } - } +interface MediaLibraryProps { + mediaUploading: number +} - useEffect(() => { - fetchTags() - }, []) +export default function MediaLibrary({ mediaUploading }: MediaLibraryProps): JSX.Element { + const [activeTab, setActiveTab] = useState('libraries') return (
-
-
-
- ) => setSearch(e.target.value)} - /> -
-
- setShowAll(!checked)} - /> - -
-
- ({ label: tag.attributes.label, value: tag.id })), - ]} - value={tag} - onChange={setTag} - placeholder="Select tag..." - /> -
-
-
- -
    - {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'} - -
- )} -
+ +
+ + + Libraries + + + Uploads + +
-
+ + + + + + +
) } 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 && ( From bb037ebfd903f11abe84e4b248f2e6d97c988b8b Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Thu, 19 Feb 2026 12:11:06 -0800 Subject: [PATCH 02/31] working folders --- src/components/MediaLibrary/Folder.tsx | 182 ++++++++++++++++++++++ src/components/MediaLibrary/Libraries.tsx | 73 ++++----- src/hooks/useMediaLibrary.tsx | 11 +- 3 files changed, 226 insertions(+), 40 deletions(-) create mode 100644 src/components/MediaLibrary/Folder.tsx diff --git a/src/components/MediaLibrary/Folder.tsx b/src/components/MediaLibrary/Folder.tsx new file mode 100644 index 000000000000..23a20e6e0a47 --- /dev/null +++ b/src/components/MediaLibrary/Folder.tsx @@ -0,0 +1,182 @@ +import React, { useEffect, useState } from 'react' +import ScrollArea from 'components/RadixUI/ScrollArea' +import { IconChevronDown, IconSpinner } from '@posthog/icons' +import { useUser } from 'hooks/useUser' +import { useMediaLibrary } from 'hooks/useMediaLibrary' +import qs from 'qs' +import Image from './Image' +import OSButton from 'components/OSButton' + +export interface MediaFolder { + id: number + attributes: { + name: string + parent?: { + data: { id: number } | null + } + children?: { + data: MediaFolder[] + } + } +} + +interface FolderProps { + folderId: number | null + search: string + onFolderClick: (folder: MediaFolder) => void +} + +export default function Folder({ folderId, search, onFolderClick }: FolderProps): JSX.Element { + const { getJwt } = useUser() + const [folders, setFolders] = useState([]) + const [tags, setTags] = useState<{ id: string; attributes: { label: string } }[]>([]) + const [foldersLoading, setFoldersLoading] = useState(true) + const [foldersError, setFoldersError] = useState(null) + + const { + images, + isLoading: imagesLoading, + hasMore, + fetchMore, + } = useMediaLibrary({ + showAll: true, + search, + folderId, + }) + + const fetchTags = async () => { + try { + const jwt = await getJwt() + 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 (err) { + console.error('Failed to fetch tags:', err) + } + } + + useEffect(() => { + const fetchFolders = async () => { + try { + setFoldersLoading(true) + setFoldersError(null) + const jwt = await getJwt() + + const isRoot = folderId === null + const query = qs.stringify( + { + populate: ['children'], + ...(isRoot && { + filters: { parent: { id: { $null: true } } }, + }), + }, + { encodeValuesOnly: true } + ) + + const url = isRoot + ? `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders?${query}` + : `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/${folderId}?${query}` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${jwt}` }, + }) + + if (!response.ok) { + throw new Error('Failed to fetch folders') + } + + const data = await response.json() + setFolders(isRoot ? data.data || [] : data.data?.attributes?.children?.data || []) + } catch (err) { + console.error('Failed to fetch folders:', err) + setFoldersError(err instanceof Error ? err.message : 'Failed to fetch folders') + } finally { + setFoldersLoading(false) + } + } + + fetchFolders() + fetchTags() + }, [folderId, getJwt]) + + const getChildCount = (folder: MediaFolder): number => { + return folder.attributes.children?.data?.length ?? 0 + } + + const filteredFolders = folders.filter((folder) => + folder.attributes.name.toLowerCase().includes(search.toLowerCase()) + ) + + const isLoading = foldersLoading || (folderId !== null && imagesLoading && images.length === 0) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (foldersError) { + return
{foldersError}
+ } + + const hasContent = filteredFolders.length > 0 || images.length > 0 + + if (!hasContent) { + return ( +
+ {folderId ? 'This folder is empty' : 'No folders found'} +
+ ) + } + + return ( + + {filteredFolders.length > 0 && ( +
    + {filteredFolders.map((folder) => ( +
  • + +
  • + ))} +
+ )} + + {folderId !== null && images.length > 0 && ( + <> +
    + {images.map((image: { id: string | number; [key: string]: unknown }) => ( +
  • + +
  • + ))} +
+ {hasMore && ( +
+ + {imagesLoading ? : 'Load more'} + +
+ )} + + )} +
+ ) +} diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index d52320559391..09dff57b3331 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -1,34 +1,31 @@ import { OSInput, OSSelect } from 'components/OSForm' import React, { useState } from 'react' -import ScrollArea from 'components/RadixUI/ScrollArea' -import { IconChevronDown } from '@posthog/icons' - -interface Library { - name: string - folder: string - assetCount: number -} - -const libraries: Library[] = [{ name: 'Hedgehogs', folder: 'hogs', assetCount: 0 }] +import { IconArrowLeft } from '@posthog/icons' +import Folder, { MediaFolder } from './Folder' export default function Libraries(): JSX.Element { const [search, setSearch] = useState('') const [tag, setTag] = useState('all-tags') const [tags] = useState<{ id: string; attributes: { label: string } }[]>([]) + const [currentFolder, setCurrentFolder] = useState(null) + const [folderStack, setFolderStack] = useState([]) - const handleSearch = (value: string) => { - setSearch(value) - // TODO: Implement library search + const handleFolderClick = (folder: MediaFolder) => { + if (currentFolder) { + setFolderStack((prev) => [...prev, currentFolder]) + } + setCurrentFolder(folder) + setSearch('') } - const handleTagChange = (value: string) => { - setTag(value) - // TODO: Implement library tag filtering + const handleBack = () => { + setCurrentFolder(folderStack.at(-1) ?? null) + setFolderStack((prev) => prev.slice(0, -1)) + setSearch('') } - const handleLibraryClick = (library: Library) => { - // TODO: Implement library folder navigation - console.log('Clicked library:', library) + const handleTagChange = (value: string) => { + setTag(value) } return ( @@ -42,7 +39,7 @@ export default function Libraries(): JSX.Element { showLabel={false} label="Search libraries" value={search} - onChange={(e: React.ChangeEvent) => handleSearch(e.target.value)} + onChange={(e: React.ChangeEvent) => setSearch(e.target.value)} />
@@ -59,26 +56,24 @@ export default function Libraries(): JSX.Element { />
+ + {currentFolder && ( +
+ + / + {currentFolder.attributes.name} +
+ )} +
- -
    - {libraries.map((library) => ( -
  • - -
  • - ))} -
-
+
) diff --git a/src/hooks/useMediaLibrary.tsx b/src/hooks/useMediaLibrary.tsx index 7022edea70b7..cb8c4076735b 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.mediaFolders = { + id: { + $eq: folderId, + }, + } + } + if (Object.keys(filters).length > 0) { params.filters = filters } From 7c704355fb3728d4dbac9a6e5083b0ead83c806c Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Thu, 19 Feb 2026 15:07:06 -0800 Subject: [PATCH 03/31] working folders --- src/components/MediaLibrary/Folder.tsx | 182 ---------------------- src/components/MediaLibrary/Image.tsx | 140 +++++++++++++---- src/components/MediaLibrary/Libraries.tsx | 147 ++++++++++++----- src/components/MediaLibrary/Uploads.tsx | 31 +--- src/components/MediaLibrary/context.tsx | 160 +++++++++++++++++++ src/components/MediaLibrary/index.tsx | 53 ++++--- src/hooks/useUser.tsx | 1 + 7 files changed, 411 insertions(+), 303 deletions(-) delete mode 100644 src/components/MediaLibrary/Folder.tsx create mode 100644 src/components/MediaLibrary/context.tsx diff --git a/src/components/MediaLibrary/Folder.tsx b/src/components/MediaLibrary/Folder.tsx deleted file mode 100644 index 23a20e6e0a47..000000000000 --- a/src/components/MediaLibrary/Folder.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useEffect, useState } from 'react' -import ScrollArea from 'components/RadixUI/ScrollArea' -import { IconChevronDown, IconSpinner } from '@posthog/icons' -import { useUser } from 'hooks/useUser' -import { useMediaLibrary } from 'hooks/useMediaLibrary' -import qs from 'qs' -import Image from './Image' -import OSButton from 'components/OSButton' - -export interface MediaFolder { - id: number - attributes: { - name: string - parent?: { - data: { id: number } | null - } - children?: { - data: MediaFolder[] - } - } -} - -interface FolderProps { - folderId: number | null - search: string - onFolderClick: (folder: MediaFolder) => void -} - -export default function Folder({ folderId, search, onFolderClick }: FolderProps): JSX.Element { - const { getJwt } = useUser() - const [folders, setFolders] = useState([]) - const [tags, setTags] = useState<{ id: string; attributes: { label: string } }[]>([]) - const [foldersLoading, setFoldersLoading] = useState(true) - const [foldersError, setFoldersError] = useState(null) - - const { - images, - isLoading: imagesLoading, - hasMore, - fetchMore, - } = useMediaLibrary({ - showAll: true, - search, - folderId, - }) - - const fetchTags = async () => { - try { - const jwt = await getJwt() - 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 (err) { - console.error('Failed to fetch tags:', err) - } - } - - useEffect(() => { - const fetchFolders = async () => { - try { - setFoldersLoading(true) - setFoldersError(null) - const jwt = await getJwt() - - const isRoot = folderId === null - const query = qs.stringify( - { - populate: ['children'], - ...(isRoot && { - filters: { parent: { id: { $null: true } } }, - }), - }, - { encodeValuesOnly: true } - ) - - const url = isRoot - ? `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders?${query}` - : `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/${folderId}?${query}` - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - if (!response.ok) { - throw new Error('Failed to fetch folders') - } - - const data = await response.json() - setFolders(isRoot ? data.data || [] : data.data?.attributes?.children?.data || []) - } catch (err) { - console.error('Failed to fetch folders:', err) - setFoldersError(err instanceof Error ? err.message : 'Failed to fetch folders') - } finally { - setFoldersLoading(false) - } - } - - fetchFolders() - fetchTags() - }, [folderId, getJwt]) - - const getChildCount = (folder: MediaFolder): number => { - return folder.attributes.children?.data?.length ?? 0 - } - - const filteredFolders = folders.filter((folder) => - folder.attributes.name.toLowerCase().includes(search.toLowerCase()) - ) - - const isLoading = foldersLoading || (folderId !== null && imagesLoading && images.length === 0) - - if (isLoading) { - return ( -
- -
- ) - } - - if (foldersError) { - return
{foldersError}
- } - - const hasContent = filteredFolders.length > 0 || images.length > 0 - - if (!hasContent) { - return ( -
- {folderId ? 'This folder is empty' : 'No folders found'} -
- ) - } - - return ( - - {filteredFolders.length > 0 && ( -
    - {filteredFolders.map((folder) => ( -
  • - -
  • - ))} -
- )} - - {folderId !== null && images.length > 0 && ( - <> -
    - {images.map((image: { id: string | number; [key: string]: unknown }) => ( -
  • - -
  • - ))} -
- {hasMore && ( -
- - {imagesLoading ? : 'Load more'} - -
- )} - - )} -
- ) -} diff --git a/src/components/MediaLibrary/Image.tsx b/src/components/MediaLibrary/Image.tsx index 5d886dec8ba1..8e67e9084769 100644 --- a/src/components/MediaLibrary/Image.tsx +++ b/src/components/MediaLibrary/Image.tsx @@ -4,36 +4,59 @@ 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' 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 +} + export default function Image({ - name, + name = '', previewUrl, provider_metadata, - ext, + ext = '', width, height, - allTags, - fetchTags, id, profiles = [], - ...other -}: any) { + tags: initialTags = [], + onMoved, + mediaFolder, +}: 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 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 +69,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 +99,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 +108,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 +117,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 +164,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 +175,43 @@ export default function Image({ } } + const handleMoveToFolder = async (targetFolderId: string) => { + if (!targetFolderId || targetFolderId === String(currentFolderId)) 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) + } + } + return (
  • )} -
    - ({ label: tag.attributes.label, value: tag.id }))} - value={tags.map((tag) => tag.id)} - allowCreate - onChange={handleChangeTags} - onCreate={handleCreateTag} - hideLabel - /> +
    +
    + ({ 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]" + /> +
    + )}
    {uploader && (

    diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 09dff57b3331..d1146a9bc239 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -1,19 +1,55 @@ -import { OSInput, OSSelect } from 'components/OSForm' +import { OSInput } from 'components/OSForm' import React, { useState } from 'react' -import { IconArrowLeft } from '@posthog/icons' -import Folder, { MediaFolder } from './Folder' +import { IconArrowLeft, IconChevronDown, IconSpinner } from '@posthog/icons' +import ScrollArea from 'components/RadixUI/ScrollArea' +import OSButton from 'components/OSButton' +import Image from './Image' +import { MediaFolder, useFolders } from './context' +import { useMediaLibrary } from 'hooks/useMediaLibrary' + +function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => void }) { + const childCount = folder.attributes.children?.data?.length ?? 0 + return ( + + ) +} export default function Libraries(): JSX.Element { const [search, setSearch] = useState('') - const [tag, setTag] = useState('all-tags') - const [tags] = useState<{ id: string; attributes: { label: string } }[]>([]) const [currentFolder, setCurrentFolder] = useState(null) const [folderStack, setFolderStack] = useState([]) + const folderId = currentFolder?.id ?? null + const { folders, isLoading: foldersLoading, error: foldersError } = useFolders(folderId) + const { + images, + isLoading: imagesLoading, + hasMore, + fetchMore, + refresh: refreshImages, + } = useMediaLibrary({ + showAll: true, + search, + folderId, + }) + + 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]) - } + if (currentFolder) setFolderStack((prev) => [...prev, currentFolder]) setCurrentFolder(folder) setSearch('') } @@ -24,38 +60,17 @@ export default function Libraries(): JSX.Element { setSearch('') } - const handleTagChange = (value: string) => { - setTag(value) - } - return (

    -
    -
    - ) => setSearch(e.target.value)} - /> -
    -
    - ({ label: tag.attributes.label, value: tag.id })), - ]} - value={tag} - onChange={handleTagChange} - placeholder="Select tag..." - /> -
    -
    + ) => setSearch(e.target.value)} + /> {currentFolder && (
    @@ -73,7 +88,61 @@ export default function Libraries(): JSX.Element { )}
    - + {showLoading && ( +
    + +
    + )} + + {foldersError &&
    {foldersError}
    } + + {!showLoading && !foldersError && !hasContent && ( +
    + {currentFolder ? 'This folder is empty' : 'No folders found'} +
    + )} + + {hasContent && ( + + {filteredFolders.length > 0 && ( +
      + {filteredFolders.map((folder) => ( +
    • + handleFolderClick(folder)} /> +
    • + ))} +
    + )} + + {currentFolder && 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 index 5606ea20f8fa..7769c232fbdc 100644 --- a/src/components/MediaLibrary/Uploads.tsx +++ b/src/components/MediaLibrary/Uploads.tsx @@ -4,22 +4,21 @@ import React, { useEffect, useMemo, useState } from 'react' import Image from './Image' import ScrollArea from 'components/RadixUI/ScrollArea' import { useMediaLibrary } from 'hooks/useMediaLibrary' -import { useUser } from 'hooks/useUser' import OSButton from 'components/OSButton' import { IconSpinner } from '@posthog/icons' import debounce from 'lodash/debounce' +import { useMediaLibraryContext } from './context' interface UploadsProps { mediaUploading: number } export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element { - const { getJwt } = useUser() + const { tags } = useMediaLibraryContext() 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 debouncedSetSearch = useMemo( () => @@ -37,32 +36,12 @@ export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element { return () => debouncedSetSearch.cancel() }, [debouncedSetSearch]) - const { images, isLoading, hasMore, fetchMore } = useMediaLibrary({ + const { images, isLoading, hasMore, fetchMore, refresh } = useMediaLibrary({ showAll, tag, search: debouncedSearch, }) - 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) - } - } - - useEffect(() => { - fetchTags() - }, []) - return (
    @@ -109,9 +88,9 @@ export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element { ) : images.length === 0 ? (
  • No images found
  • ) : ( - images?.map((image: { id: string | number; [key: string]: unknown }) => ( + images?.map((image: any) => (
  • - +
  • )) )} diff --git a/src/components/MediaLibrary/context.tsx b/src/components/MediaLibrary/context.tsx new file mode 100644 index 000000000000..2d2b081b292d --- /dev/null +++ b/src/components/MediaLibrary/context.tsx @@ -0,0 +1,160 @@ +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' +import { useUser } from 'hooks/useUser' +import qs from 'qs' + +export interface MediaFolder { + id: 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 +} + +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 fetchFolders = useCallback(async () => { + try { + setFoldersLoading(true) + const jwt = await getJwt() + if (!jwt) return + + const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders`, { + 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 +} + +interface UseFoldersResult { + folders: MediaFolder[] + isLoading: boolean + error: string | null + refresh: () => void +} + +export function useFolders(folderId: number | null): UseFoldersResult { + const { getJwt } = useUser() + const [folders, setFolders] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchFolders = useCallback(async () => { + try { + setIsLoading(true) + setError(null) + const jwt = await getJwt() + + const isRoot = folderId === null + const query = qs.stringify( + { + populate: ['children'], + ...(isRoot && { + filters: { parent: { id: { $null: true } } }, + }), + }, + { encodeValuesOnly: true } + ) + + const url = isRoot + ? `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders?${query}` + : `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/${folderId}?${query}` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${jwt}` }, + }) + + if (!response.ok) { + throw new Error('Failed to fetch folders') + } + + const data = await response.json() + setFolders(isRoot ? data.data || [] : data.data?.attributes?.children?.data || []) + } catch (err) { + console.error('Failed to fetch folders:', err) + setError(err instanceof Error ? err.message : 'Failed to fetch folders') + } finally { + setIsLoading(false) + } + }, [folderId, getJwt]) + + useEffect(() => { + fetchFolders() + }, [fetchFolders]) + + return { folders, isLoading, error, refresh: fetchFolders } +} diff --git a/src/components/MediaLibrary/index.tsx b/src/components/MediaLibrary/index.tsx index 702c7085317b..920aec7f6f61 100644 --- a/src/components/MediaLibrary/index.tsx +++ b/src/components/MediaLibrary/index.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import Tabs from 'components/RadixUI/Tabs' import Libraries from './Libraries' import Uploads from './Uploads' +import { MediaLibraryProvider } from './context' interface MediaLibraryProps { mediaUploading: number @@ -11,30 +12,32 @@ export default function MediaLibrary({ mediaUploading }: MediaLibraryProps): JSX const [activeTab, setActiveTab] = useState('libraries') return ( -
    - -
    - - - Libraries - - - Uploads - - -
    - - - - - - -
    -
    + +
    + +
    + + + Libraries + + + Uploads + + +
    + + + + + + +
    +
    +
    ) } diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx index db8c0dcf18f1..b1993f602131 100644 --- a/src/hooks/useUser.tsx +++ b/src/hooks/useUser.tsx @@ -332,6 +332,7 @@ export const UserProvider: React.FC = ({ children }) => { images: { sort: ['createdAt:desc'], populate: { + mediaFolder: true, tags: true, related: true, }, From fd62034141cd44f6609b52af8bbd262997f02628 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Thu, 19 Feb 2026 15:16:25 -0800 Subject: [PATCH 04/31] clean things --- src/components/MediaLibrary/Libraries.tsx | 14 ++--- src/components/MediaLibrary/context.tsx | 62 +---------------------- 2 files changed, 9 insertions(+), 67 deletions(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index d1146a9bc239..944be8126800 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -4,7 +4,7 @@ import { IconArrowLeft, IconChevronDown, IconSpinner } from '@posthog/icons' import ScrollArea from 'components/RadixUI/ScrollArea' import OSButton from 'components/OSButton' import Image from './Image' -import { MediaFolder, useFolders } from './context' +import { MediaFolder, useMediaLibraryContext } from './context' import { useMediaLibrary } from 'hooks/useMediaLibrary' function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => void }) { @@ -29,8 +29,10 @@ export default function Libraries(): JSX.Element { const [currentFolder, setCurrentFolder] = useState(null) const [folderStack, setFolderStack] = useState([]) - const folderId = currentFolder?.id ?? null - const { folders, isLoading: foldersLoading, error: foldersError } = useFolders(folderId) + const { folders: allFolders, foldersLoading } = useMediaLibraryContext() + const folders = allFolders.filter((f) => + currentFolder ? f.attributes.parent?.data?.id === currentFolder.id : !f.attributes.parent?.data + ) const { images, isLoading: imagesLoading, @@ -40,7 +42,7 @@ export default function Libraries(): JSX.Element { } = useMediaLibrary({ showAll: true, search, - folderId, + folderId: currentFolder?.id ?? null, }) const isLoading = foldersLoading || imagesLoading @@ -94,9 +96,7 @@ export default function Libraries(): JSX.Element {
    )} - {foldersError &&
    {foldersError}
    } - - {!showLoading && !foldersError && !hasContent && ( + {!showLoading && !hasContent && (
    {currentFolder ? 'This folder is empty' : 'No folders found'}
    diff --git a/src/components/MediaLibrary/context.tsx b/src/components/MediaLibrary/context.tsx index 2d2b081b292d..d4bfcfddbd62 100644 --- a/src/components/MediaLibrary/context.tsx +++ b/src/components/MediaLibrary/context.tsx @@ -46,7 +46,8 @@ export function MediaLibraryProvider({ children }: { children: React.ReactNode } const jwt = await getJwt() if (!jwt) return - const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders`, { + const query = qs.stringify({ populate: ['parent', 'children'] }, { encodeValuesOnly: true }) + const response = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders?${query}`, { headers: { Authorization: `Bearer ${jwt}`, }, @@ -99,62 +100,3 @@ export function useMediaLibraryContext() { } return context } - -interface UseFoldersResult { - folders: MediaFolder[] - isLoading: boolean - error: string | null - refresh: () => void -} - -export function useFolders(folderId: number | null): UseFoldersResult { - const { getJwt } = useUser() - const [folders, setFolders] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchFolders = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - const jwt = await getJwt() - - const isRoot = folderId === null - const query = qs.stringify( - { - populate: ['children'], - ...(isRoot && { - filters: { parent: { id: { $null: true } } }, - }), - }, - { encodeValuesOnly: true } - ) - - const url = isRoot - ? `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders?${query}` - : `${process.env.GATSBY_SQUEAK_API_HOST}/api/media-folders/${folderId}?${query}` - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${jwt}` }, - }) - - if (!response.ok) { - throw new Error('Failed to fetch folders') - } - - const data = await response.json() - setFolders(isRoot ? data.data || [] : data.data?.attributes?.children?.data || []) - } catch (err) { - console.error('Failed to fetch folders:', err) - setError(err instanceof Error ? err.message : 'Failed to fetch folders') - } finally { - setIsLoading(false) - } - }, [folderId, getJwt]) - - useEffect(() => { - fetchFolders() - }, [fetchFolders]) - - return { folders, isLoading, error, refresh: fetchFolders } -} From d3b090dd61ad309ae11f5874391b134953b5be7b Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Thu, 19 Feb 2026 15:29:24 -0800 Subject: [PATCH 05/31] remove from folder --- src/components/MediaLibrary/Image.tsx | 35 ++++++++++++++++++++++- src/components/MediaLibrary/Libraries.tsx | 9 +++++- src/components/MediaLibrary/Uploads.tsx | 9 +++++- src/hooks/useMediaLibrary.tsx | 2 +- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/components/MediaLibrary/Image.tsx b/src/components/MediaLibrary/Image.tsx index 8e67e9084769..e103c74eda91 100644 --- a/src/components/MediaLibrary/Image.tsx +++ b/src/components/MediaLibrary/Image.tsx @@ -176,7 +176,12 @@ export default function Image({ } const handleMoveToFolder = async (targetFolderId: string) => { - if (!targetFolderId || targetFolderId === String(currentFolderId)) return + if (!targetFolderId) return + + if (targetFolderId === String(currentFolderId)) { + await handleRemoveFromFolder() + return + } setIsMoving(true) try { @@ -212,6 +217,34 @@ export default function Image({ } } + 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 (
  • void }) { const childCount = folder.attributes.children?.data?.length ?? 0 @@ -25,6 +26,7 @@ function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => vo } export default function Libraries(): JSX.Element { + const { fetchUser: refreshUser } = useUser() const [search, setSearch] = useState('') const [currentFolder, setCurrentFolder] = useState(null) const [folderStack, setFolderStack] = useState([]) @@ -62,6 +64,11 @@ export default function Libraries(): JSX.Element { setSearch('') } + const handleImageMoved = () => { + refreshImages() + refreshUser() + } + return (
    {images.map((image: any) => (
  • - +
  • ))} diff --git a/src/components/MediaLibrary/Uploads.tsx b/src/components/MediaLibrary/Uploads.tsx index 7769c232fbdc..aadf3910f137 100644 --- a/src/components/MediaLibrary/Uploads.tsx +++ b/src/components/MediaLibrary/Uploads.tsx @@ -8,6 +8,7 @@ import OSButton from 'components/OSButton' import { IconSpinner } from '@posthog/icons' import debounce from 'lodash/debounce' import { useMediaLibraryContext } from './context' +import { useUser } from 'hooks/useUser' interface UploadsProps { mediaUploading: number @@ -15,6 +16,7 @@ interface UploadsProps { export default function Uploads({ mediaUploading }: 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('') @@ -42,6 +44,11 @@ export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element { search: debouncedSearch, }) + const handleImageMoved = () => { + refresh() + refreshUser() + } + return (
    @@ -90,7 +97,7 @@ export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element { ) : ( images?.map((image: any) => (
  • - +
  • )) )} diff --git a/src/hooks/useMediaLibrary.tsx b/src/hooks/useMediaLibrary.tsx index cb8c4076735b..7cc854b38a5d 100644 --- a/src/hooks/useMediaLibrary.tsx +++ b/src/hooks/useMediaLibrary.tsx @@ -40,7 +40,7 @@ const query = (offset: number, options?: UseMediaLibraryOptions) => { } if (folderId) { - filters.mediaFolders = { + filters.mediaFolder = { id: { $eq: folderId, }, From fdf591c4b370663660c86fe664e64b23b1b107a3 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 09:34:01 -0800 Subject: [PATCH 06/31] fixes --- src/components/MediaLibrary/Image.tsx | 24 ++++++------ src/components/MediaLibrary/Libraries.tsx | 4 +- src/components/MediaLibrary/Uploads.tsx | 48 +++++++++++------------ src/components/MediaUploadModal/index.tsx | 2 +- 4 files changed, 38 insertions(+), 40 deletions(-) diff --git a/src/components/MediaLibrary/Image.tsx b/src/components/MediaLibrary/Image.tsx index e103c74eda91..97166b15f205 100644 --- a/src/components/MediaLibrary/Image.tsx +++ b/src/components/MediaLibrary/Image.tsx @@ -315,18 +315,6 @@ export default function Image({
    )}
    -
    - ({ label: tag.attributes.label, value: tag.id }))} - value={tags.map((tag) => tag.id)} - allowCreate - onChange={handleChangeTags} - onCreate={handleCreateTag} - hideLabel - /> -
    {folders.length > 0 && (
    )} +
    + ({ label: tag.attributes.label, value: tag.id }))} + value={tags.map((tag) => tag.id)} + allowCreate + onChange={handleChangeTags} + onCreate={handleCreateTag} + hideLabel + /> +
    {uploader && (

    diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 3a1b0efe12b9..7e2dffa7eec7 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -110,7 +110,7 @@ export default function Libraries(): JSX.Element { )} {hasContent && ( - +

    {filteredFolders.length > 0 && (
      {filteredFolders.map((folder) => ( @@ -148,7 +148,7 @@ export default function Libraries(): JSX.Element { )} )} - +
    )}
    diff --git a/src/components/MediaLibrary/Uploads.tsx b/src/components/MediaLibrary/Uploads.tsx index aadf3910f137..cae5a53ae00d 100644 --- a/src/components/MediaLibrary/Uploads.tsx +++ b/src/components/MediaLibrary/Uploads.tsx @@ -84,32 +84,30 @@ export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element {
    - -
      - {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'} - -
    +
      + {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/MediaUploadModal/index.tsx b/src/components/MediaUploadModal/index.tsx index 2f1b71f68a16..5b11c7e88682 100644 --- a/src/components/MediaUploadModal/index.tsx +++ b/src/components/MediaUploadModal/index.tsx @@ -463,7 +463,7 @@ export default function MediaUploadModal() { }, [onDrop, addToast]) return isModerator ? ( - +
    From 1a5bca3604d2528a5ad074ed2dafd6b05c72cbfe Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 12:22:29 -0800 Subject: [PATCH 07/31] cleanup --- src/components/MediaLibrary/Libraries.tsx | 15 +- src/components/MediaLibrary/Uploads.tsx | 1 + src/components/MediaLibrary/context.tsx | 21 +- src/components/MediaLibrary/index.tsx | 185 ++++++-- src/components/MediaUploadModal/index.tsx | 527 +--------------------- src/components/Squeak/util/uploadImage.ts | 12 +- 6 files changed, 196 insertions(+), 565 deletions(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 7e2dffa7eec7..abf4170a8a59 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -1,7 +1,6 @@ import { OSInput } from 'components/OSForm' import React, { useState } from 'react' import { IconArrowLeft, IconChevronDown, IconSpinner } from '@posthog/icons' -import ScrollArea from 'components/RadixUI/ScrollArea' import OSButton from 'components/OSButton' import Image from './Image' import { MediaFolder, useMediaLibraryContext } from './context' @@ -14,7 +13,7 @@ function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => vo - ))} - - -
    - ) : ( -
    -

    - {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/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) From 1b6847dda3d07d202199e3a4aa55976a4a0c2def Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 12:26:32 -0800 Subject: [PATCH 08/31] search --- src/components/MediaLibrary/Libraries.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index abf4170a8a59..92456c3425a8 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -126,7 +126,7 @@ export default function Libraries(): JSX.Element { )} - {currentFolder && images.length > 0 && ( + {(currentFolder || !!search) && images.length > 0 && ( <>
      {images.map((image: any) => ( From 1e0b3fba795cf63231c96a4adf0c0f236baf2dc9 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 12:41:16 -0800 Subject: [PATCH 09/31] tags --- src/components/MediaLibrary/Libraries.tsx | 44 ++++++++++++++++------- src/components/OSForm/select.tsx | 10 +++--- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 92456c3425a8..32e8d9e9a3c8 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -1,4 +1,4 @@ -import { OSInput } from 'components/OSForm' +import { OSInput, OSSelect } from 'components/OSForm' import React, { useState } from 'react' import { IconArrowLeft, IconChevronDown, IconSpinner } from '@posthog/icons' import OSButton from 'components/OSButton' @@ -27,6 +27,7 @@ function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => vo export default function Libraries(): JSX.Element { const { fetchUser: refreshUser } = useUser() const [search, setSearch] = useState('') + const [tag, setTag] = useState('all-tags') const { folders: allFolders, @@ -35,6 +36,7 @@ export default function Libraries(): JSX.Element { setCurrentFolder, folderStack, setFolderStack, + tags, } = useMediaLibraryContext() const folders = allFolders.filter((f) => currentFolder ? f.attributes.parent?.data?.id === currentFolder.id : !f.attributes.parent?.data @@ -48,6 +50,7 @@ export default function Libraries(): JSX.Element { } = useMediaLibrary({ showAll: true, search, + tag, folderId: currentFolder?.id ?? null, revalidateOnFocus: true, }) @@ -76,15 +79,32 @@ export default function Libraries(): JSX.Element { return (
      - ) => setSearch(e.target.value)} - /> +
      +
      + ) => setSearch(e.target.value)} + /> +
      +
      + ({ label: t.attributes.label, value: t.id })), + ]} + value={tag} + onChange={setTag} + placeholder="Select tag..." + /> +
      +
      {currentFolder && (
      @@ -116,7 +136,7 @@ export default function Libraries(): JSX.Element { {hasContent && (
      - {filteredFolders.length > 0 && ( + {tag === 'all-tags' && filteredFolders.length > 0 && (
        {filteredFolders.map((folder) => (
      • @@ -126,7 +146,7 @@ export default function Libraries(): JSX.Element {
      )} - {(currentFolder || !!search) && images.length > 0 && ( + {(currentFolder || !!search || tag !== 'all-tags') && images.length > 0 && ( <>
        {images.map((image: any) => ( 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 => { From b500db163ea5184862b6aa7b3dd87fd7e298eeab Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 13:06:04 -0800 Subject: [PATCH 10/31] show asset count --- src/components/MediaLibrary/Libraries.tsx | 8 +++++--- src/components/MediaLibrary/context.tsx | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 32e8d9e9a3c8..3f6d98dbc720 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -8,7 +8,7 @@ import { useMediaLibrary } from 'hooks/useMediaLibrary' import { useUser } from 'hooks/useUser' function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => void }) { - const childCount = folder.attributes.children?.data?.length ?? 0 + const mediaCount = folder.mediaCount return ( @@ -130,7 +132,7 @@ export default function Libraries(): JSX.Element { {!showLoading && !hasContent && (
        - {currentFolder ? 'This folder is empty' : 'No folders found'} + {currentFolder ? 'No assets found in this folder' : 'No folders found'}
        )} diff --git a/src/components/MediaLibrary/context.tsx b/src/components/MediaLibrary/context.tsx index 0d90da40417d..b3f0f9285d84 100644 --- a/src/components/MediaLibrary/context.tsx +++ b/src/components/MediaLibrary/context.tsx @@ -4,6 +4,7 @@ import qs from 'qs' export interface MediaFolder { id: number + mediaCount: number attributes: { name: string parent?: { From 4558846551cbc4867fa24f8a456b97b485fa5e5f Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 15:37:08 -0800 Subject: [PATCH 11/31] mock --- src/components/MediaLibrary/Libraries.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 3f6d98dbc720..da8e528424cd 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -1,6 +1,6 @@ import { OSInput, OSSelect } from 'components/OSForm' import React, { useState } from 'react' -import { IconArrowLeft, IconChevronDown, IconSpinner } from '@posthog/icons' +import { IconChevronDown, IconChevronLeft, IconSpinner } from '@posthog/icons' import OSButton from 'components/OSButton' import Image from './Image' import { MediaFolder, useMediaLibraryContext } from './context' @@ -109,17 +109,12 @@ export default function Libraries(): JSX.Element {
      {currentFolder && ( -
      - - / - {currentFolder.attributes.name} +
      + } onClick={handleBack} /> + {currentFolder.attributes.name} + + {currentFolder.mediaCount} asset{currentFolder.mediaCount === 1 ? '' : 's'} +
      )} @@ -150,7 +145,7 @@ export default function Libraries(): JSX.Element { {(currentFolder || !!search || tag !== 'all-tags') && images.length > 0 && ( <> -
        +
          {images.map((image: any) => (
        • From a608e653dc6fd497a02d9ffa71a93077c17e998a Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 15:50:22 -0800 Subject: [PATCH 12/31] upload button --- src/components/MediaLibrary/Uploads.tsx | 11 +++++++++-- src/components/MediaLibrary/index.tsx | 16 +++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/MediaLibrary/Uploads.tsx b/src/components/MediaLibrary/Uploads.tsx index 733a13635bbe..e81f7dee8b53 100644 --- a/src/components/MediaLibrary/Uploads.tsx +++ b/src/components/MediaLibrary/Uploads.tsx @@ -5,16 +5,17 @@ import Image from './Image' import ScrollArea from 'components/RadixUI/ScrollArea' import { useMediaLibrary } from 'hooks/useMediaLibrary' import OSButton from 'components/OSButton' -import { IconSpinner } from '@posthog/icons' +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 }: UploadsProps): JSX.Element { +export default function Uploads({ mediaUploading, onUploadClick }: UploadsProps): JSX.Element { const { tags } = useMediaLibraryContext() const { fetchUser: refreshUser } = useUser() const [showAll, setShowAll] = useState(false) @@ -84,6 +85,12 @@ export default function Uploads({ mediaUploading }: UploadsProps): JSX.Element { />
      +
      + Uploads + } onClick={onUploadClick}> + Upload + +
        {mediaUploading > 0 && diff --git a/src/components/MediaLibrary/index.tsx b/src/components/MediaLibrary/index.tsx index 1addd5e67244..f5d646bdbd2b 100644 --- a/src/components/MediaLibrary/index.tsx +++ b/src/components/MediaLibrary/index.tsx @@ -19,14 +19,20 @@ export default function MediaLibrary() { const [loading, setLoading] = useState(0) const [activeTab, setActiveTab] = useState('libraries') const { addToast } = useToast() - const { currentFolder } = useMediaLibraryContext() + const { currentFolder, setCurrentFolder } = useMediaLibraryContext() const isModerator = user?.role?.type === 'moderator' + const handleTabChange = (value: string) => { + setActiveTab(value) + setCurrentFolder(null) + } + 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) @@ -43,14 +49,14 @@ export default function MediaLibrary() { } } + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ onDrop, noClick: true }) + useEffect(() => { if (appWindow) { setWindowTitle(appWindow, 'Upload media') } }, []) - const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, noClick: true }) - useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { const items = e.clipboardData?.items @@ -110,7 +116,7 @@ export default function MediaLibrary() {
        @@ -134,7 +140,7 @@ export default function MediaLibrary() { - +
        From 96efe43616f4703544816926412aeea69c1a24bd Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 20 Feb 2026 15:53:58 -0800 Subject: [PATCH 13/31] sort --- src/components/MediaLibrary/context.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MediaLibrary/context.tsx b/src/components/MediaLibrary/context.tsx index b3f0f9285d84..1bb2099c850f 100644 --- a/src/components/MediaLibrary/context.tsx +++ b/src/components/MediaLibrary/context.tsx @@ -53,7 +53,10 @@ export function MediaLibraryProvider({ children }: { children: React.ReactNode } const jwt = await getJwt() if (!jwt) return - const query = qs.stringify({ populate: ['parent', 'children'] }, { encodeValuesOnly: true }) + 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}`, From 9f07864438bfa0de825d0135ad42d308a84122df Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 10:55:24 -0800 Subject: [PATCH 14/31] hedgehog generator --- .env.development | 2 +- src/components/HedgehogGenerator/index.tsx | 195 +++++++++++++++++++++ src/components/MediaLibrary/Libraries.tsx | 34 +++- src/context/App.tsx | 18 ++ 4 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 src/components/HedgehogGenerator/index.tsx diff --git a/.env.development b/.env.development index 5ed51c3adba3..d04ec32d1269 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,6 @@ # This file is public and only contains non-sensitive values -GATSBY_SQUEAK_API_HOST=https://better-animal-d658c56969.strapiapp.com +GATSBY_SQUEAK_API_HOST=http://127.0.0.1:1337 GATSBY_ALGOLIA_APP_ID=7VNQB5W0TX GATSBY_ALGOLIA_SEARCH_API_KEY=e9ff9279dc8771a35a26d586c73c20a8 diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx new file mode 100644 index 000000000000..98d7e63a01b6 --- /dev/null +++ b/src/components/HedgehogGenerator/index.tsx @@ -0,0 +1,195 @@ +import { useUser } from 'hooks/useUser' +import React, { useEffect, useState } from 'react' +import ScrollArea from 'components/RadixUI/ScrollArea' +import SEO from 'components/seo' +import { IconArrowRightDown, IconCheck, IconSend } from '@posthog/icons' +import { OSInput } from 'components/OSForm' + +interface GeneratedImage { + uid: string + url: string + status: string +} + +const DUMMY_IMAGE: GeneratedImage = { + uid: '114cf27f-3fdf-4bd0-bf61-36a238696ca0', + url: 'https://image.exactly.ai/generations/114cf27f-3fdf-4bd0-bf61-36a238696ca0.jpg?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXRoIjoiL2dlbmVyYXRpb25zLzExNGNmMjdmLTNmZGYtNGJkMC1iZjYxLTM2YTIzODY5NmNhMC5qcGciLCJleHAiOjE3NzE3MDE2MjYsImlhdCI6MTc3MTY5ODAyNn0.JkupNRXsQYjGFSsHW-2MT2CtciOkR0487smszbVnlac', + status: 'completed', +} + +const USE_DUMMY_DATA = true + +function ImageResult({ image }: { image: GeneratedImage }) { + const [downloaded, setDownloaded] = useState(false) + + useEffect(() => { + if (!downloaded) return + const timeout = setTimeout(() => setDownloaded(false), 2000) + return () => clearTimeout(timeout) + }, [downloaded]) + + 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) + } catch (err) { + console.error('Failed to download image:', err) + } + } + + return ( + + ) +} + +export default function HedgehogGenerator() { + const { isModerator, getJwt } = useUser() + const [prompt, setPrompt] = useState('') + const [image, setImage] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const generateImage = async (promptText: string) => { + setLoading(true) + setError(null) + setImage(null) + + try { + if (USE_DUMMY_DATA) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + setImage(DUMMY_IMAGE) + return + } + + 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}` }), + }) + + 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() + setImage(data.image || null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate image') + } finally { + setLoading(false) + } + } + + 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 ? ( +
        + + +
        +

        Generate a hedgehog that...

        +
        + ) => setPrompt(e.target.value)} + className="pr-12" + disabled={loading} + /> + + + + {error &&

        {error}

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

        Click the image to save it.

        +

        + Don't like this result? + +

        +
        + )} + +
        + {loading ? ( +
        +
        Generating...
        +
        + ) : ( + image && + )} +
        +
        + )} + + {!image && !loading && ( +

        + Example: is holding a phone while sitting on a toilet pondering something +

        + )} +
        +
        +
        + ) : null +} diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index da8e528424cd..4b21b9796d40 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -1,11 +1,13 @@ import { OSInput, OSSelect } from 'components/OSForm' import React, { useState } from 'react' -import { IconChevronDown, IconChevronLeft, IconSpinner } from '@posthog/icons' +import { IconChevronDown, IconChevronLeft, IconSparkles, IconSpinner } from '@posthog/icons' import OSButton from 'components/OSButton' import Image from './Image' import { MediaFolder, useMediaLibraryContext } from './context' import { useMediaLibrary } from 'hooks/useMediaLibrary' import { useUser } from 'hooks/useUser' +import { useApp } from '../../context/App' +import HedgehogGenerator from 'components/HedgehogGenerator' function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => void }) { const mediaCount = folder.mediaCount @@ -30,6 +32,7 @@ export default function Libraries(): JSX.Element { const { fetchUser: refreshUser } = useUser() const [search, setSearch] = useState('') const [tag, setTag] = useState('all-tags') + const { addWindow } = useApp() const { folders: allFolders, @@ -79,6 +82,12 @@ export default function Libraries(): JSX.Element { refreshUser() } + const handleGenerateClick = () => { + addWindow( + + ) + } + return (
        @@ -109,12 +118,23 @@ export default function Libraries(): JSX.Element {
        {currentFolder && ( -
        - } onClick={handleBack} /> - {currentFolder.attributes.name} - - {currentFolder.mediaCount} asset{currentFolder.mediaCount === 1 ? '' : 's'} - +
        +
        + } + onClick={handleBack} + /> + {currentFolder.attributes.name} + + {currentFolder.mediaCount} asset{currentFolder.mediaCount === 1 ? '' : 's'} + +
        + {currentFolder.attributes?.name === 'Hedgehogs' && ( + } onClick={handleGenerateClick}> + Generate + + )}
        )} diff --git a/src/context/App.tsx b/src/context/App.tsx index dc0334ae79ef..63c04f5b565a 100644 --- a/src/context/App.tsx +++ b/src/context/App.tsx @@ -971,6 +971,24 @@ const appSettings: AppSettings = { type: 'standard', }, }, + 'hedgehog-generator': { + size: { + min: { + width: 900, + height: 500, + }, + max: { + width: 900, + height: 800, + }, + }, + position: { + center: true, + }, + modal: { + type: 'standard', + }, + }, 'cool-tech-jobs-issue': { size: { min: { From 0c3aa5cedce1de9c83ff218611ffb941dccbfa7b Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 11:23:05 -0800 Subject: [PATCH 15/31] size/saved --- src/components/HedgehogGenerator/index.tsx | 33 +++++++++++++--------- src/context/App.tsx | 9 +++--- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 98d7e63a01b6..288a6fb99549 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -2,7 +2,8 @@ import { useUser } from 'hooks/useUser' import React, { useEffect, useState } from 'react' import ScrollArea from 'components/RadixUI/ScrollArea' import SEO from 'components/seo' -import { IconArrowRightDown, IconCheck, IconSend } from '@posthog/icons' +import { IconArrowRightDown, IconCheck } from '@posthog/icons' +import { useToast } from '../../context/Toast' import { OSInput } from 'components/OSForm' interface GeneratedImage { @@ -20,14 +21,9 @@ const DUMMY_IMAGE: GeneratedImage = { const USE_DUMMY_DATA = true function ImageResult({ image }: { image: GeneratedImage }) { + const { addToast } = useToast() const [downloaded, setDownloaded] = useState(false) - useEffect(() => { - if (!downloaded) return - const timeout = setTimeout(() => setDownloaded(false), 2000) - return () => clearTimeout(timeout) - }, [downloaded]) - const handleClick = async () => { try { const response = await fetch(image.url) @@ -41,8 +37,17 @@ function ImageResult({ image }: { image: GeneratedImage }) { 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, + }) } } @@ -50,14 +55,14 @@ function ImageResult({ image }: { image: GeneratedImage }) { @@ -126,7 +131,7 @@ export default function HedgehogGenerator() {
        -
        +

        Generate a hedgehog that...

        {error}

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

        Click the image to save it.

        diff --git a/src/context/App.tsx b/src/context/App.tsx index 63c04f5b565a..d23af08a80a3 100644 --- a/src/context/App.tsx +++ b/src/context/App.tsx @@ -974,13 +974,14 @@ const appSettings: AppSettings = { 'hedgehog-generator': { size: { min: { - width: 900, - height: 500, + width: 550, + height: 650, }, max: { - width: 900, - height: 800, + width: 550, + height: 650, }, + autoHeight: true, }, position: { center: true, From 6e1fc5f1f6d33fa4fd527a793cde973ed7916b95 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 11:24:31 -0800 Subject: [PATCH 16/31] style clean --- src/components/HedgehogGenerator/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 288a6fb99549..5253a21612ba 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -132,7 +132,7 @@ export default function HedgehogGenerator() {
        -

        Generate a hedgehog that...

        +

        Generate a hedgehog that...

        {image && !loading && ( -
        +

        Click the image to save it.

        Don't like this result? From 1fdf7da0de150a32317ffafb28179fe23213f942 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 11:49:12 -0800 Subject: [PATCH 17/31] update save image --- src/components/HedgehogGenerator/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 5253a21612ba..094b5cc01bd3 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -108,7 +108,7 @@ export default function HedgehogGenerator() { } const data = await response.json() - setImage(data.image || null) + setImage(data.images?.[0] || null) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate image') } finally { From e5ad43dd07e5013dedf5ff19b5a86d83ccc86f50 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 12:15:36 -0800 Subject: [PATCH 18/31] bg color --- src/components/MediaLibrary/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MediaLibrary/index.tsx b/src/components/MediaLibrary/index.tsx index f5d646bdbd2b..d0e9ad28f2af 100644 --- a/src/components/MediaLibrary/index.tsx +++ b/src/components/MediaLibrary/index.tsx @@ -110,7 +110,7 @@ export default function MediaLibrary() { return isModerator ? (

        - +
        From f61b54fd9d34a4aa83f98e40c0fb7f4e86f94d7f Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 18:06:54 -0800 Subject: [PATCH 19/31] limits --- src/components/HedgehogGenerator/index.tsx | 202 ++++++++++++++++++--- src/hooks/useUser.tsx | 6 + tailwind.config.js | 5 + 3 files changed, 187 insertions(+), 26 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 094b5cc01bd3..34beff267e74 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -5,6 +5,10 @@ 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' + +dayjs.extend(duration) interface GeneratedImage { uid: string @@ -18,7 +22,7 @@ const DUMMY_IMAGE: GeneratedImage = { status: 'completed', } -const USE_DUMMY_DATA = true +const USE_DUMMY_DATA = false function ImageResult({ image }: { image: GeneratedImage }) { const { addToast } = useToast() @@ -69,13 +73,128 @@ function ImageResult({ image }: { image: GeneratedImage }) { ) } +function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 60 * 60 * 1000) { + const [timeRemaining, setTimeRemaining] = useState(null) + const [progress, setProgress] = useState(0) + + useEffect(() => { + if (!resetTime) { + setTimeRemaining(null) + setProgress(0) + return + } + + const updateCountdown = () => { + const now = dayjs() + const reset = dayjs(resetTime) + const diff = reset.diff(now) + + if (diff <= 0) { + setTimeRemaining(null) + setProgress(100) + return false + } + + const elapsed = rateLimitDurationMs - diff + setProgress(Math.min(100, Math.max(0, (elapsed / rateLimitDurationMs) * 100))) + + const dur = dayjs.duration(diff) + const hours = Math.floor(dur.asHours()) + const minutes = dur.minutes() + const seconds = dur.seconds() + + if (hours > 0) { + setTimeRemaining(`${hours}h ${minutes}m ${seconds}s`) + } else if (minutes > 0) { + setTimeRemaining(`${minutes}m ${seconds}s`) + } else { + setTimeRemaining(`${seconds}s`) + } + return true + } + + if (!updateCountdown()) return + + const interval = setInterval(() => { + if (!updateCountdown()) { + clearInterval(interval) + } + }, 1000) + + return () => clearInterval(interval) + }, [resetTime, rateLimitDurationMs]) + + return { timeRemaining, progress } +} + +function FloatingZs() { + return ( +
        + z + + z + + + Z + +
        + ) +} + +function ProgressRing({ + progress, + size = 120, + 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() { - const { isModerator, getJwt } = useUser() + const { isModerator, getJwt, user, fetchUser } = useUser() const [prompt, setPrompt] = useState('') const [image, setImage] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const rateLimit = user?.imageGenerationRateLimit + const isRateLimited = rateLimit?.remaining === 0 && !!rateLimit?.resetTime + const { timeRemaining, progress } = useRateLimitCountdown( + isRateLimited ? rateLimit.resetTime : null, + rateLimit?.windowMs + ) + const generateImage = async (promptText: string) => { setLoading(true) setError(null) @@ -109,6 +228,7 @@ export default function HedgehogGenerator() { const data = await response.json() setImage(data.images?.[0] || null) + fetchUser() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate image') } finally { @@ -132,28 +252,58 @@ export default function HedgehogGenerator() {
        -

        Generate a hedgehog that...

        - - ) => setPrompt(e.target.value)} - className="pr-12" - disabled={loading} - /> - - + {isRateLimited && timeRemaining && !image ? ( +
        +
        + +
        + Sleeping hedgehog +
        + +
        +

        The hedgehogs are resting

        +

        You've reached your limit for now

        +
        + Try again in + {timeRemaining} +
        +
        + ) : ( + <> +

        Generate a hedgehog that...

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

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

        + )} + + )} {error &&

        {error}

        } @@ -167,7 +317,7 @@ export default function HedgehogGenerator() {
        )} - {!image && !loading && ( + {!image && !loading && !isRateLimited && (

        Example: is holding a phone while sitting on a toilet pondering something

        diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx index b1993f602131..4bf448de920f 100644 --- a/src/hooks/useUser.tsx +++ b/src/hooks/useUser.tsx @@ -35,6 +35,12 @@ export type User = { metadata: any }[] } + imageGenerationRateLimit?: { + remaining: number + limit: number + resetTime: string | null + windowMs: number + } } type UserContextValue = { diff --git a/tailwind.config.js b/tailwind.config.js index c71ff23dc3f1..4a7811972e77 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -396,6 +396,10 @@ module.exports = { transform: 'translateY(-10px)', }, }, + float: { + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-6px)' }, + }, }, animation: { wiggle: 'wiggle .2s ease-in-out 3', @@ -417,6 +421,7 @@ 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', }, containers: { '2xs': '16rem', From 6618dfe56e4c9b747b7f88f61153f02f18b3dbfa Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 18:17:40 -0800 Subject: [PATCH 20/31] clean --- src/components/HedgehogGenerator/index.tsx | 84 +++++++++++++++------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 34beff267e74..685769c4b54e 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -73,13 +73,19 @@ function ImageResult({ image }: { image: GeneratedImage }) { ) } +interface TimeLeft { + hours: number + minutes: number + seconds: number +} + function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 60 * 60 * 1000) { - const [timeRemaining, setTimeRemaining] = useState(null) + const [timeLeft, setTimeLeft] = useState(null) const [progress, setProgress] = useState(0) useEffect(() => { if (!resetTime) { - setTimeRemaining(null) + setTimeLeft(null) setProgress(0) return } @@ -90,7 +96,7 @@ function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 6 const diff = reset.diff(now) if (diff <= 0) { - setTimeRemaining(null) + setTimeLeft(null) setProgress(100) return false } @@ -99,17 +105,11 @@ function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 6 setProgress(Math.min(100, Math.max(0, (elapsed / rateLimitDurationMs) * 100))) const dur = dayjs.duration(diff) - const hours = Math.floor(dur.asHours()) - const minutes = dur.minutes() - const seconds = dur.seconds() - - if (hours > 0) { - setTimeRemaining(`${hours}h ${minutes}m ${seconds}s`) - } else if (minutes > 0) { - setTimeRemaining(`${minutes}m ${seconds}s`) - } else { - setTimeRemaining(`${seconds}s`) - } + setTimeLeft({ + hours: Math.floor(dur.asHours()), + minutes: dur.minutes(), + seconds: dur.seconds(), + }) return true } @@ -124,7 +124,20 @@ function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 6 return () => clearInterval(interval) }, [resetTime, rateLimitDurationMs]) - return { timeRemaining, progress } + return { timeLeft, progress } +} + +function CountdownBlock({ value, label }: { value: number; label: string }) { + return ( +
        +
        + + {String(value).padStart(2, '0')} + +
        + {label} +
        + ) } function FloatingZs() { @@ -143,7 +156,7 @@ function FloatingZs() { function ProgressRing({ progress, - size = 120, + size = 100, strokeWidth = 6, }: { progress: number @@ -190,11 +203,18 @@ export default function HedgehogGenerator() { const rateLimit = user?.imageGenerationRateLimit const isRateLimited = rateLimit?.remaining === 0 && !!rateLimit?.resetTime - const { timeRemaining, progress } = useRateLimitCountdown( + const { timeLeft, progress } = useRateLimitCountdown( isRateLimited ? rateLimit.resetTime : null, rateLimit?.windowMs ) + const formattedTimeRemaining = 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]) + const generateImage = async (promptText: string) => { setLoading(true) setError(null) @@ -252,24 +272,34 @@ export default function HedgehogGenerator() {
        - {isRateLimited && timeRemaining && !image ? ( -
        + {isRateLimited && timeLeft && !image ? ( +
        - +
        Sleeping hedgehog
        -

        The hedgehogs are resting

        +

        The hedgehogs are resting

        You've reached your limit for now

        -
        - Try again in - {timeRemaining} +

        + Try again in +

        +
        + {timeLeft.hours > 0 && ( + <> + + : + + )} + + : +
        ) : ( @@ -296,10 +326,10 @@ export default function HedgehogGenerator() { - {isRateLimited && timeRemaining && ( + {isRateLimited && timeLeft && (

        Limit reached. Try again in{' '} - {timeRemaining} + {formattedTimeRemaining}

        )} From 39b9894150bcd1eb8b3510a1f30983a02a81b49f Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 18:21:29 -0800 Subject: [PATCH 21/31] simplify --- src/components/HedgehogGenerator/index.tsx | 32 +++------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 685769c4b54e..55e0ec055b12 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -127,19 +127,6 @@ function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 6 return { timeLeft, progress } } -function CountdownBlock({ value, label }: { value: number; label: string }) { - return ( -
        -
        - - {String(value).padStart(2, '0')} - -
        - {label} -
        - ) -} - function FloatingZs() { return (
        @@ -285,22 +272,11 @@ export default function HedgehogGenerator() {
        -

        The hedgehogs are resting

        -

        You've reached your limit for now

        -

        - Try again in +

        The hedgehogs are resting

        +

        + Try again in{' '} + {formattedTimeRemaining}

        -
        - {timeLeft.hours > 0 && ( - <> - - : - - )} - - : - -
        ) : ( <> From b4bcf6068249186dd0907b590e23f216a2578de3 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sat, 21 Feb 2026 18:39:24 -0800 Subject: [PATCH 22/31] breathe --- src/components/HedgehogGenerator/index.tsx | 5 +++-- tailwind.config.js | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 55e0ec055b12..dc6bcd4ffc55 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -7,6 +7,7 @@ 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) @@ -264,10 +265,10 @@ export default function HedgehogGenerator() {
        - Sleeping hedgehog
        diff --git a/tailwind.config.js b/tailwind.config.js index 4a7811972e77..f0993954e846 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -400,6 +400,10 @@ module.exports = { '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', @@ -422,6 +426,7 @@ module.exports = { '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', From b323b78fb511e4ade221cd45fb474219048576ed Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Sun, 22 Feb 2026 10:26:22 -0800 Subject: [PATCH 23/31] rate limit tweaks --- src/components/HedgehogGenerator/index.tsx | 54 +++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index dc6bcd4ffc55..aadb482140a8 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -74,16 +74,19 @@ function ImageResult({ image }: { image: GeneratedImage }) { ) } -interface TimeLeft { - hours: number - minutes: number - seconds: number +interface RateLimit { + remaining?: number + resetTime?: string | null + windowMs?: number } -function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 60 * 60 * 1000) { - const [timeLeft, setTimeLeft] = useState(null) +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) @@ -102,8 +105,8 @@ function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 6 return false } - const elapsed = rateLimitDurationMs - diff - setProgress(Math.min(100, Math.max(0, (elapsed / rateLimitDurationMs) * 100))) + const elapsed = windowMs - diff + setProgress(Math.min(100, Math.max(0, (elapsed / windowMs) * 100))) const dur = dayjs.duration(diff) setTimeLeft({ @@ -123,9 +126,18 @@ function useRateLimitCountdown(resetTime: string | null, rateLimitDurationMs = 6 }, 1000) return () => clearInterval(interval) - }, [resetTime, rateLimitDurationMs]) + }, [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 { timeLeft, progress } + return { isActive, progress, formattedTime } } function FloatingZs() { @@ -189,19 +201,7 @@ export default function HedgehogGenerator() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const rateLimit = user?.imageGenerationRateLimit - const isRateLimited = rateLimit?.remaining === 0 && !!rateLimit?.resetTime - const { timeLeft, progress } = useRateLimitCountdown( - isRateLimited ? rateLimit.resetTime : null, - rateLimit?.windowMs - ) - - const formattedTimeRemaining = 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]) + const { isActive: isRateLimited, progress, formattedTime } = useRateLimit(user?.imageGenerationRateLimit) const generateImage = async (promptText: string) => { setLoading(true) @@ -260,7 +260,7 @@ export default function HedgehogGenerator() {
        - {isRateLimited && timeLeft && !image ? ( + {isRateLimited && !image ? (
        @@ -276,7 +276,7 @@ export default function HedgehogGenerator() {

        The hedgehogs are resting

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

        ) : ( @@ -303,10 +303,10 @@ export default function HedgehogGenerator() { - {isRateLimited && timeLeft && ( + {isRateLimited && (

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

        )} From 84ad826ab18b013a892e1141fc78a640315f6f35 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Thu, 26 Feb 2026 17:40:22 -0800 Subject: [PATCH 24/31] add picasso field support --- src/components/HedgehogGenerator/index.tsx | 6 +++--- src/components/MediaLibrary/Libraries.tsx | 16 +++++++++++++--- src/hooks/useUser.tsx | 1 + 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index aadb482140a8..907c31f522d9 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -194,8 +194,8 @@ function ProgressRing({ ) } -export default function HedgehogGenerator() { - const { isModerator, getJwt, user, fetchUser } = useUser() +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) @@ -236,7 +236,7 @@ export default function HedgehogGenerator() { const data = await response.json() setImage(data.images?.[0] || null) - fetchUser() + onGenerated?.() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate image') } finally { diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index 4b21b9796d40..ce69700c91bb 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -29,7 +29,7 @@ function FolderRow({ folder, onClick }: { folder: MediaFolder; onClick: () => vo } export default function Libraries(): JSX.Element { - const { fetchUser: refreshUser } = useUser() + const { fetchUser: refreshUser, user } = useUser() const [search, setSearch] = useState('') const [tag, setTag] = useState('all-tags') const { addWindow } = useApp() @@ -82,9 +82,19 @@ export default function Libraries(): JSX.Element { refreshUser() } + const handleGenerated = () => { + refreshImages() + refreshUser() + } + const handleGenerateClick = () => { addWindow( - + ) } @@ -130,7 +140,7 @@ export default function Libraries(): JSX.Element { {currentFolder.mediaCount} asset{currentFolder.mediaCount === 1 ? '' : 's'}
        - {currentFolder.attributes?.name === 'Hedgehogs' && ( + {currentFolder.attributes?.name === 'Hedgehogs' && user?.picasso && ( } onClick={handleGenerateClick}> Generate diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx index 4bf448de920f..9560c72a886e 100644 --- a/src/hooks/useUser.tsx +++ b/src/hooks/useUser.tsx @@ -41,6 +41,7 @@ export type User = { resetTime: string | null windowMs: number } + picasso?: boolean } type UserContextValue = { From 79f11de24e3eea4e5f7ef871ad172bea253634a8 Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 27 Feb 2026 12:59:41 -0800 Subject: [PATCH 25/31] show prompt --- .env.development | 2 +- src/components/MediaLibrary/Image.tsx | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.env.development b/.env.development index d04ec32d1269..5ed51c3adba3 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,6 @@ # This file is public and only contains non-sensitive values -GATSBY_SQUEAK_API_HOST=http://127.0.0.1:1337 +GATSBY_SQUEAK_API_HOST=https://better-animal-d658c56969.strapiapp.com GATSBY_ALGOLIA_APP_ID=7VNQB5W0TX GATSBY_ALGOLIA_SEARCH_API_KEY=e9ff9279dc8771a35a26d586c73c20a8 diff --git a/src/components/MediaLibrary/Image.tsx b/src/components/MediaLibrary/Image.tsx index 97166b15f205..f2d5275f57cc 100644 --- a/src/components/MediaLibrary/Image.tsx +++ b/src/components/MediaLibrary/Image.tsx @@ -6,6 +6,7 @@ import { useUser } from 'hooks/useUser' import Link from 'components/Link' import { OSSelect } from 'components/OSForm' import { useMediaLibraryContext } from './context' +import Tooltip from 'components/Tooltip' const CLOUDINARY_BASE = `https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_CLOUD_NAME}` @@ -24,6 +25,7 @@ interface ImageProps { tags?: Array<{ id: string; attributes: { label: string } }> onMoved?: () => void mediaFolder: any + prompt?: string } export default function Image({ @@ -38,6 +40,7 @@ export default function Image({ tags: initialTags = [], onMoved, mediaFolder, + prompt, }: ImageProps): JSX.Element { const { folders, tags: allTags, fetchTags } = useMediaLibraryContext() const { public_id, resource_type } = provider_metadata || {} @@ -348,7 +351,20 @@ export default function Image({
        {uploader && (

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

        + Prompt: {prompt} +
        + )} + > + Generated + + ) : ( + 'Uploaded' + )}{' '} + by{' '} Date: Fri, 27 Feb 2026 13:07:11 -0800 Subject: [PATCH 26/31] remove dummy data --- src/components/HedgehogGenerator/index.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 907c31f522d9..317ffc82b162 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -17,14 +17,6 @@ interface GeneratedImage { status: string } -const DUMMY_IMAGE: GeneratedImage = { - uid: '114cf27f-3fdf-4bd0-bf61-36a238696ca0', - url: 'https://image.exactly.ai/generations/114cf27f-3fdf-4bd0-bf61-36a238696ca0.jpg?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXRoIjoiL2dlbmVyYXRpb25zLzExNGNmMjdmLTNmZGYtNGJkMC1iZjYxLTM2YTIzODY5NmNhMC5qcGciLCJleHAiOjE3NzE3MDE2MjYsImlhdCI6MTc3MTY5ODAyNn0.JkupNRXsQYjGFSsHW-2MT2CtciOkR0487smszbVnlac', - status: 'completed', -} - -const USE_DUMMY_DATA = false - function ImageResult({ image }: { image: GeneratedImage }) { const { addToast } = useToast() const [downloaded, setDownloaded] = useState(false) @@ -209,12 +201,6 @@ export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () => setImage(null) try { - if (USE_DUMMY_DATA) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - setImage(DUMMY_IMAGE) - return - } - const jwt = await getJwt() if (!jwt) { throw new Error('You must be logged in to generate images') From b38e85381a7f9ba713cc5fa25a3a638be68c28dc Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 27 Feb 2026 13:20:05 -0800 Subject: [PATCH 27/31] add monthly count --- src/components/HedgehogGenerator/index.tsx | 7 +++++++ src/hooks/useUser.tsx | 1 + 2 files changed, 8 insertions(+) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 317ffc82b162..1845e06be59d 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -336,6 +336,13 @@ export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () => 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 +

        + )}
        diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx index 9560c72a886e..fd7200cda870 100644 --- a/src/hooks/useUser.tsx +++ b/src/hooks/useUser.tsx @@ -40,6 +40,7 @@ export type User = { limit: number resetTime: string | null windowMs: number + monthlyCount: number } picasso?: boolean } From b2370d7977b32bfa5a8ef15922e9acecaac5592d Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 27 Feb 2026 14:06:27 -0800 Subject: [PATCH 28/31] button size --- src/components/MediaLibrary/Libraries.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index ce69700c91bb..b3bf98798c79 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -141,7 +141,7 @@ export default function Libraries(): JSX.Element {
        {currentFolder.attributes?.name === 'Hedgehogs' && user?.picasso && ( - } onClick={handleGenerateClick}> + } onClick={handleGenerateClick}> Generate )} From b9f14fc73c03deed56f7c41806f89cbc1e85beeb Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 27 Feb 2026 14:23:56 -0800 Subject: [PATCH 29/31] margin --- src/components/MediaLibrary/Libraries.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MediaLibrary/Libraries.tsx b/src/components/MediaLibrary/Libraries.tsx index b3bf98798c79..212193c3bf6e 100644 --- a/src/components/MediaLibrary/Libraries.tsx +++ b/src/components/MediaLibrary/Libraries.tsx @@ -128,7 +128,7 @@ export default function Libraries(): JSX.Element {
        {currentFolder && ( -
        +
        Date: Fri, 27 Feb 2026 16:16:29 -0800 Subject: [PATCH 30/31] poll --- src/components/HedgehogGenerator/index.tsx | 154 ++++++++++++++++++++- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/src/components/HedgehogGenerator/index.tsx b/src/components/HedgehogGenerator/index.tsx index 1845e06be59d..6e4a11c82095 100644 --- a/src/components/HedgehogGenerator/index.tsx +++ b/src/components/HedgehogGenerator/index.tsx @@ -1,5 +1,5 @@ import { useUser } from 'hooks/useUser' -import React, { useEffect, useState } from 'react' +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' @@ -11,6 +11,8 @@ import CloudinaryImage from 'components/CloudinaryImage' dayjs.extend(duration) +const POLL_INTERVAL_MS = 2000 + interface GeneratedImage { uid: string url: string @@ -146,6 +148,80 @@ function FloatingZs() { ) } +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, @@ -191,12 +267,56 @@ export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () => 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) @@ -213,6 +333,7 @@ export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () => Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ prompt: `generate a hedgehog that ${promptText}` }), + signal: abortController.signal, }) if (!response.ok) { @@ -221,15 +342,36 @@ export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () => } const data = await response.json() - setImage(data.images?.[0] || null) - onGenerated?.() + 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 { - setLoading(false) + 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 @@ -321,9 +463,7 @@ export default function HedgehogGenerator({ onGenerated }: { onGenerated?: () =>
        {loading ? ( -
        -
        Generating...
        -
        + ) : ( image && )} From 6fd76e8d2b1f7a6e8c35c661f454ac7abaa88f9d Mon Sep 17 00:00:00 2001 From: Eli Kinsey Date: Fri, 27 Feb 2026 16:24:14 -0800 Subject: [PATCH 31/31] generation duration --- src/components/MediaLibrary/Image.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/MediaLibrary/Image.tsx b/src/components/MediaLibrary/Image.tsx index f2d5275f57cc..2adf3b38187e 100644 --- a/src/components/MediaLibrary/Image.tsx +++ b/src/components/MediaLibrary/Image.tsx @@ -7,6 +7,17 @@ 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}` @@ -26,6 +37,7 @@ interface ImageProps { onMoved?: () => void mediaFolder: any prompt?: string + generationDurationMs?: number } export default function Image({ @@ -41,6 +53,7 @@ export default function Image({ onMoved, mediaFolder, prompt, + generationDurationMs, }: ImageProps): JSX.Element { const { folders, tags: allTags, fetchTags } = useMediaLibraryContext() const { public_id, resource_type } = provider_metadata || {} @@ -52,6 +65,7 @@ export default function Image({ const [uploader] = profiles const [isMoving, setIsMoving] = useState(false) const currentFolderId = mediaFolder?.id + const formattedDuration = generationDurationMs ? formatDuration(generationDurationMs) : null useEffect(() => { setAvailableOptions(allTags) @@ -354,8 +368,15 @@ export default function Image({ {prompt ? ( ( -
        - Prompt: {prompt} +
        +

        + Prompt: {prompt} +

        + {formattedDuration && ( +

        + Duration: {formattedDuration} +

        + )}
        )} >