Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { Heart, Image, Video } from 'lucide-react'
|
||||
import type { GenSpaceTypeFilter } from '../../lib/genspace-gallery'
|
||||
|
||||
export function GenSpaceFilterEmptyState({
|
||||
typeFilter,
|
||||
showFavorites,
|
||||
hasTypeMatches,
|
||||
}: {
|
||||
typeFilter: GenSpaceTypeFilter
|
||||
showFavorites: boolean
|
||||
hasTypeMatches: boolean
|
||||
}) {
|
||||
if (typeFilter !== 'all' && !hasTypeMatches) {
|
||||
if (typeFilter === 'video') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none">
|
||||
<Video className="h-12 w-12 text-zinc-700 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No videos yet</h3>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
Generate a video or switch the filter to see other media.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none">
|
||||
<Image className="h-12 w-12 text-zinc-700 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No images yet</h3>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
Generate an image or switch the filter to see other media.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showFavorites) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none">
|
||||
<Heart className="h-12 w-12 text-zinc-700 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No favorites yet</h3>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
Click the heart icon on any asset to add it to your favorites.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type GallerySize = 'small' | 'medium' | 'large'
|
||||
|
||||
export const gallerySizeClasses: Record<GallerySize, string> = {
|
||||
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7',
|
||||
medium: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
large: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3',
|
||||
}
|
||||
|
||||
function GridSmallIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="2" y="2" width="4" height="4" rx="0.5" />
|
||||
<rect x="8" y="2" width="4" height="4" rx="0.5" />
|
||||
<rect x="14" y="2" width="4" height="4" rx="0.5" />
|
||||
<rect x="20" y="2" width="2" height="4" rx="0.5" />
|
||||
<rect x="2" y="8" width="4" height="4" rx="0.5" />
|
||||
<rect x="8" y="8" width="4" height="4" rx="0.5" />
|
||||
<rect x="14" y="8" width="4" height="4" rx="0.5" />
|
||||
<rect x="20" y="8" width="2" height="4" rx="0.5" />
|
||||
<rect x="2" y="14" width="4" height="4" rx="0.5" />
|
||||
<rect x="8" y="14" width="4" height="4" rx="0.5" />
|
||||
<rect x="14" y="14" width="4" height="4" rx="0.5" />
|
||||
<rect x="20" y="14" width="2" height="4" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GridMediumIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="2" y="2" width="6" height="6" rx="1" />
|
||||
<rect x="10" y="2" width="6" height="6" rx="1" />
|
||||
<rect x="18" y="2" width="4" height="6" rx="1" />
|
||||
<rect x="2" y="10" width="6" height="6" rx="1" />
|
||||
<rect x="10" y="10" width="6" height="6" rx="1" />
|
||||
<rect x="18" y="10" width="4" height="6" rx="1" />
|
||||
<rect x="2" y="18" width="6" height="4" rx="1" />
|
||||
<rect x="10" y="18" width="6" height="4" rx="1" />
|
||||
<rect x="18" y="18" width="4" height="4" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GridLargeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1.5" />
|
||||
<rect x="13" y="2" width="9" height="9" rx="1.5" />
|
||||
<rect x="2" y="13" width="9" height="9" rx="1.5" />
|
||||
<rect x="13" y="13" width="9" height="9" rx="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenSpaceGallerySizeMenu({
|
||||
gallerySize,
|
||||
onGallerySizeChange,
|
||||
}: {
|
||||
gallerySize: GallerySize
|
||||
onGallerySizeChange: (size: GallerySize) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open])
|
||||
|
||||
const CurrentIcon = gallerySize === 'small'
|
||||
? GridSmallIcon
|
||||
: gallerySize === 'medium'
|
||||
? GridMediumIcon
|
||||
: GridLargeIcon
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
aria-label={`Gallery size: ${gallerySize}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
open ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<CurrentIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-zinc-800 border border-zinc-700 rounded-md p-2 min-w-[160px] shadow-xl z-50">
|
||||
{([
|
||||
{ value: 'small' as const, label: 'Small', icon: GridSmallIcon },
|
||||
{ value: 'medium' as const, label: 'Medium', icon: GridMediumIcon },
|
||||
{ value: 'large' as const, label: 'Large', icon: GridLargeIcon },
|
||||
]).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => { onGallerySizeChange(option.value); setOpen(false) }}
|
||||
className={`w-full flex items-center justify-between px-2 py-2.5 rounded-md transition-colors text-left ${gallerySize === option.value ? 'bg-white/20 hover:bg-white/25' : 'hover:bg-zinc-700'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<option.icon className={`h-4 w-4 ${gallerySize === option.value ? 'text-white' : 'text-zinc-500'}`} />
|
||||
<span className={`text-sm ${gallerySize === option.value ? 'text-white font-medium' : 'text-zinc-400'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
{gallerySize === option.value && (
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Heart, Sparkles } from 'lucide-react'
|
||||
import type { GenSpaceSortDir, GenSpaceSortKey, GenSpaceTypeFilter } from '../../lib/genspace-gallery'
|
||||
import { GenSpaceGallerySizeMenu, type GallerySize } from './GenSpaceGallerySizeMenu'
|
||||
import { GenSpaceSortMenu } from './GenSpaceSortMenu'
|
||||
import { GenSpaceTypeFilter as TypeFilter } from './GenSpaceTypeFilter'
|
||||
|
||||
export function GenSpaceGalleryToolbar({
|
||||
showBrowseLoras,
|
||||
onBrowseLoras,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSortKeyChange,
|
||||
onToggleSortDir,
|
||||
showFavorites,
|
||||
favoriteCount,
|
||||
onToggleFavorites,
|
||||
gallerySize,
|
||||
onGallerySizeChange,
|
||||
}: {
|
||||
showBrowseLoras: boolean
|
||||
onBrowseLoras: () => void
|
||||
typeFilter: GenSpaceTypeFilter
|
||||
onTypeFilterChange: (value: GenSpaceTypeFilter) => void
|
||||
sortKey: GenSpaceSortKey
|
||||
sortDir: GenSpaceSortDir
|
||||
onSortKeyChange: (key: GenSpaceSortKey) => void
|
||||
onToggleSortDir: () => void
|
||||
showFavorites: boolean
|
||||
favoriteCount: number
|
||||
onToggleFavorites: () => void
|
||||
gallerySize: GallerySize
|
||||
onGallerySizeChange: (size: GallerySize) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between pb-2 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{showBrowseLoras && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowseLoras}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> Browse LoRAs
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeFilter value={typeFilter} onChange={onTypeFilterChange} />
|
||||
<GenSpaceSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSortKeyChange={onSortKeyChange}
|
||||
onToggleSortDir={onToggleSortDir}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFavorites}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
showFavorites
|
||||
? 'bg-red-500/20 text-red-400 border border-red-500/30'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${showFavorites ? 'fill-current' : ''}`} />
|
||||
Favorites
|
||||
{favoriteCount > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
showFavorites ? 'bg-red-500/30 text-red-300' : 'bg-zinc-800 text-zinc-500'
|
||||
}`}>
|
||||
{favoriteCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<GenSpaceGallerySizeMenu
|
||||
gallerySize={gallerySize}
|
||||
onGallerySizeChange={onGallerySizeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { SettingsDropdown } from '../../components/SettingsDropdown'
|
||||
import {
|
||||
GENSPACE_SORT_OPTIONS,
|
||||
type GenSpaceSortDir,
|
||||
type GenSpaceSortKey,
|
||||
} from '../../lib/genspace-gallery'
|
||||
|
||||
const toolbarTriggerClass = 'px-3 py-1.5 text-sm font-medium text-zinc-400 hover:text-white rounded-r-none'
|
||||
|
||||
export function GenSpaceSortMenu({
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSortKeyChange,
|
||||
onToggleSortDir,
|
||||
}: {
|
||||
sortKey: GenSpaceSortKey
|
||||
sortDir: GenSpaceSortDir
|
||||
onSortKeyChange: (key: GenSpaceSortKey) => void
|
||||
onToggleSortDir: () => void
|
||||
}) {
|
||||
const currentLabel = GENSPACE_SORT_OPTIONS.find(option => option.value === sortKey)?.label ?? 'Date'
|
||||
const DirectionIcon = sortDir === 'desc' ? ChevronDown : ChevronUp
|
||||
const directionLabel = sortDir === 'desc' ? 'Descending' : 'Ascending'
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<SettingsDropdown
|
||||
placement="below"
|
||||
title="Sort"
|
||||
value={sortKey}
|
||||
onChange={next => onSortKeyChange(next as GenSpaceSortKey)}
|
||||
triggerClassName={toolbarTriggerClass}
|
||||
trigger={
|
||||
<>
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
options={GENSPACE_SORT_OPTIONS}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSortDir}
|
||||
title={directionLabel}
|
||||
aria-label={`Sort ${directionLabel.toLowerCase()}`}
|
||||
className="p-1.5 rounded-md rounded-l-none border-l border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<DirectionIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { SettingsDropdown } from '../../components/SettingsDropdown'
|
||||
import {
|
||||
GENSPACE_TYPE_FILTER_OPTIONS,
|
||||
type GenSpaceTypeFilter,
|
||||
} from '../../lib/genspace-gallery'
|
||||
|
||||
const toolbarTriggerClass = 'px-3 py-1.5 text-sm font-medium text-zinc-400 hover:text-white'
|
||||
|
||||
export function GenSpaceTypeFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: GenSpaceTypeFilter
|
||||
onChange: (value: GenSpaceTypeFilter) => void
|
||||
}) {
|
||||
const currentLabel = GENSPACE_TYPE_FILTER_OPTIONS.find(option => option.value === value)?.label ?? 'All'
|
||||
|
||||
return (
|
||||
<SettingsDropdown
|
||||
placement="below"
|
||||
title="Type"
|
||||
value={value}
|
||||
onChange={next => onChange(next as GenSpaceTypeFilter)}
|
||||
triggerClassName={toolbarTriggerClass}
|
||||
trigger={
|
||||
<>
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
options={GENSPACE_TYPE_FILTER_OPTIONS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Asset } from '../../types/project-model'
|
||||
import {
|
||||
defaultSortDir,
|
||||
filterGenSpaceAssets,
|
||||
sortGenSpaceAssets,
|
||||
type GenSpaceSortDir,
|
||||
type GenSpaceSortKey,
|
||||
type GenSpaceTypeFilter,
|
||||
} from '../../lib/genspace-gallery'
|
||||
|
||||
export function useGenSpaceGallery(assets: Asset[]) {
|
||||
const [typeFilter, setTypeFilter] = useState<GenSpaceTypeFilter>('all')
|
||||
const [sortKey, setSortKey] = useState<GenSpaceSortKey>('createdAt')
|
||||
const [sortDir, setSortDir] = useState<GenSpaceSortDir>(() => defaultSortDir('createdAt'))
|
||||
const [showFavorites, setShowFavorites] = useState(false)
|
||||
|
||||
const filteredAssets = useMemo(
|
||||
() => sortGenSpaceAssets(
|
||||
filterGenSpaceAssets(assets, typeFilter, showFavorites),
|
||||
sortKey,
|
||||
sortDir,
|
||||
),
|
||||
[assets, typeFilter, showFavorites, sortKey, sortDir],
|
||||
)
|
||||
|
||||
const favoriteCount = useMemo(
|
||||
() => filterGenSpaceAssets(assets, 'all', true).length,
|
||||
[assets],
|
||||
)
|
||||
|
||||
const hasTypeMatches = useMemo(
|
||||
() => filterGenSpaceAssets(assets, typeFilter, false).length > 0,
|
||||
[assets, typeFilter],
|
||||
)
|
||||
|
||||
const onSortKeyChange = useCallback((key: GenSpaceSortKey) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir(direction => (direction === 'asc' ? 'desc' : 'asc'))
|
||||
return
|
||||
}
|
||||
setSortKey(key)
|
||||
setSortDir(defaultSortDir(key))
|
||||
}, [sortKey])
|
||||
|
||||
const onToggleSortDir = useCallback(() => {
|
||||
setSortDir(direction => (direction === 'asc' ? 'desc' : 'asc'))
|
||||
}, [])
|
||||
|
||||
const onToggleFavorites = useCallback(() => {
|
||||
setShowFavorites(value => !value)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
typeFilter,
|
||||
setTypeFilter,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSortKeyChange,
|
||||
onToggleSortDir,
|
||||
showFavorites,
|
||||
onToggleFavorites,
|
||||
filteredAssets,
|
||||
favoriteCount,
|
||||
hasTypeMatches,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { PanelImperativeHandle } from 'react-resizable-panels'
|
||||
import {
|
||||
DEFAULT_PROMPT_BAR_HEIGHT,
|
||||
PROMPT_BAR_HEIGHT_LIMITS,
|
||||
clampPromptBarHeight,
|
||||
loadPromptBarHeight,
|
||||
savePromptBarHeight,
|
||||
} from '../../lib/genspace-layout'
|
||||
|
||||
export function useGenSpacePromptBarHeight() {
|
||||
const initialHeightRef = useRef(loadPromptBarHeight())
|
||||
const heightRef = useRef(initialHeightRef.current)
|
||||
const panelRef = useRef<PanelImperativeHandle | null>(null)
|
||||
const [promptBarHeight, setPromptBarHeight] = useState(initialHeightRef.current)
|
||||
|
||||
const persistHeight = useCallback((inPixels: number) => {
|
||||
const next = clampPromptBarHeight(inPixels)
|
||||
if (next === heightRef.current) return
|
||||
heightRef.current = next
|
||||
setPromptBarHeight(next)
|
||||
savePromptBarHeight(next)
|
||||
}, [])
|
||||
|
||||
const resetHeight = useCallback(() => {
|
||||
heightRef.current = DEFAULT_PROMPT_BAR_HEIGHT
|
||||
setPromptBarHeight(DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
savePromptBarHeight(DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
panelRef.current?.resize(DEFAULT_PROMPT_BAR_HEIGHT)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
initialHeight: initialHeightRef.current,
|
||||
promptBarHeight,
|
||||
panelRef,
|
||||
persistHeight,
|
||||
resetHeight,
|
||||
limits: PROMPT_BAR_HEIGHT_LIMITS,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user