Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions components/landing/alpha-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";

/**
* AlphaBadge — Consistent alpha/experimental indicator used across model badges.
*
* Single source of truth for the alpha visual treatment.
* Follows the same pattern as NsfwBadge: intentionally small and tasteful.
* Amber tones signal "experimental/preview" without competing with the
* ember-orange primary or the pink NSFW badge.
*
* @param label - Custom text label (default: "Alpha")
* @param className - Additional classes for layout overrides (margin, positioning)
*/
export function AlphaBadge({
label = "Alpha",
className,
}: {
label?: string;
className?: string;
} = {}) {
return (
<span
className={cn(
"inline-flex items-center justify-center shrink-0 px-1.5 py-0.5 rounded-md bg-amber-500/15 border border-amber-500/20 text-[9px] font-bold text-amber-400 uppercase tracking-wider leading-none",
className,
)}
role="status"
aria-label="Alpha — experimental model"
>
{label}
</span>
);
}
4 changes: 4 additions & 0 deletions components/landing/model-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ModelDefinition, isMonochromeLogo } from "@/lib/config/models";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import Image from "next/image";
import { AlphaBadge } from "./alpha-badge";
import { NsfwBadge } from "./nsfw-badge";

export function ModelBadge({
Expand All @@ -12,6 +13,8 @@ export function ModelBadge({
/** When true, shows 18+ badge if this model supports unrestricted generation */
showNsfw?: boolean;
}) {
const isAlpha = model.modelPricing.isAlpha === true;

return (
<div className="group relative flex items-center gap-3 p-2 px-4 rounded-xl bg-white/[0.03] border border-white/5 transition-all duration-300 overflow-hidden hover:border-primary/50 hover:bg-white/[0.06]" role="listitem">
{model.logo ? (
Expand All @@ -25,6 +28,7 @@ export function ModelBadge({
{model.displayName}
</span>

{isAlpha && <AlphaBadge />}
{showNsfw && model.isUnrestricted && <NsfwBadge />}
</div>
);
Expand Down
4 changes: 4 additions & 0 deletions components/landing/models-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { getActiveModels, isMonochromeLogo, type ModelDefinition } from "@/lib/c
import { cn } from "@/lib/utils";
import { ImageIcon, Sparkles, Video } from "lucide-react";
import Image from "next/image";
import { AlphaBadge } from "./alpha-badge";
import { NsfwBadge } from "./nsfw-badge";
import { ScrollReveal } from "./scroll-reveal";

function ModelBadgeDetailed({ model }: { model: ModelDefinition }) {
const isAlpha = model.modelPricing.isAlpha === true;

return (
<div className="group relative flex items-center gap-3 p-3 px-4 rounded-xl bg-white/[0.06] border border-white/10 transition-all duration-300">
{model.logo ? (
Expand All @@ -19,6 +22,7 @@ function ModelBadgeDetailed({ model }: { model: ModelDefinition }) {
<span className="text-[14px] font-bold font-brand text-foreground uppercase tracking-tight truncate block">{model.displayName}</span>
</div>

{isAlpha && <AlphaBadge />}
{model.isUnrestricted && <NsfwBadge />}
</div>
);
Expand Down
78 changes: 77 additions & 1 deletion components/studio/canvas/image-canvas.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { GeneratedImage } from "@/types/pollinations"
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { ImageCanvas } from "./image-canvas"
import { ImageCanvas, type QueueItem } from "./image-canvas"

const mockImage: GeneratedImage = {
id: "test-1",
Expand All @@ -23,6 +23,18 @@ const mockImage: GeneratedImage = {
timestamp: Date.now(),
}

const createQueueItems = (count: number): QueueItem[] =>
Array.from({ length: count }, (_, i) => {
const status: QueueItem["status"] = i === 0 ? "processing" : "pending"
return {
id: `gen-${i + 1}`,
status,
createdAt: Date.now() - (count - i) * 1000,
aspectRatio: 1,
labelIndex: i + 1,
}
})

describe("ImageCanvas", () => {
it("renders the canvas container", () => {
render(<ImageCanvas image={null} />)
Expand Down Expand Up @@ -120,4 +132,68 @@ describe("ImageCanvas", () => {
expect(video).toBeInTheDocument()
expect(video).toHaveAttribute("src", "https://example.com/video.mp4")
})

describe("Queue Cards", () => {
it("renders queue cards when queueItems are provided", () => {
const items = createQueueItems(3)
render(
<ImageCanvas image={null} isGenerating={true} queueItems={items} />
)

const cards = screen.getAllByTestId("queue-card")
expect(cards).toHaveLength(3)
expect(screen.getByTestId("queue-card-grid")).toBeInTheDocument()
})

it("does not render queue grid when queueItems is empty", () => {
render(<ImageCanvas image={null} isGenerating={true} queueItems={[]} />)

expect(screen.queryByTestId("queue-card-grid")).not.toBeInTheDocument()
})

it("renders stop button per queue card with cancel handler", () => {
const onCancelItem = vi.fn()
const items = createQueueItems(2)
render(
<ImageCanvas
image={null}
isGenerating={true}
queueItems={items}
onCancelItem={onCancelItem}
/>
)

const stopButtons = screen.getAllByTestId("queue-card-stop")
expect(stopButtons).toHaveLength(2)

// Click the first stop button — should cancel gen-1
fireEvent.click(stopButtons[0])
expect(onCancelItem).toHaveBeenCalledTimes(1)
expect(onCancelItem).toHaveBeenCalledWith("gen-1")

// Click the second stop button — should cancel gen-2
fireEvent.click(stopButtons[1])
expect(onCancelItem).toHaveBeenCalledTimes(2)
expect(onCancelItem).toHaveBeenCalledWith("gen-2")
})

it("shows 'Generating' label for processing items and 'Queued' for pending", () => {
const items = createQueueItems(2) // first is processing, second is pending
render(
<ImageCanvas image={null} isGenerating={true} queueItems={items} />
)

expect(screen.getByText("Generating")).toBeInTheDocument()
expect(screen.getByText("Queued")).toBeInTheDocument()
})

it("does not render stop buttons when onCancelItem is not provided", () => {
const items = createQueueItems(2)
render(
<ImageCanvas image={null} isGenerating={true} queueItems={items} />
)

expect(screen.queryByTestId("queue-card-stop")).not.toBeInTheDocument()
})
})
})
136 changes: 135 additions & 1 deletion components/studio/canvas/image-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { isVideoContent, MediaPlayer } from "@/components/ui/media-player"
import { cn } from "@/lib/utils"
import type { GeneratedImage } from "@/types/pollinations"
import { AnimatePresence, motion, type Variants } from "framer-motion"
import { ImagePlus, Loader2 } from "lucide-react"
import { ImagePlus, Loader2, X } from "lucide-react"
import * as React from "react"
import { CanvasWave } from "./canvas-wave"

Expand All @@ -39,11 +39,26 @@ const containerVariants: Variants = {
},
}

// --- Queue Item Type ---

export interface QueueItem {
id: string
status: "pending" | "processing"
createdAt: number
aspectRatio: number
labelIndex: number
}

// --- Component ---

export interface ImageCanvasProps {
image: GeneratedImage | null
isGenerating?: boolean
/** Structured queue items for per-generation cards */
queueItems?: QueueItem[]
/** Callback to cancel a specific generation by ID */
onCancelItem?: (id: string) => void

progress?: number
onImageClick?: () => void
children?: React.ReactNode
Expand Down Expand Up @@ -85,11 +100,123 @@ function CapillaryProgress({ progress }: { progress: number }) {
)
}

/**
* QueueCardGrid - Per-item grid/tray of active single generations.
*
* Each card shows an aspect-ratio frame, a spinner, a status label,
* and a per-card Stop button. Cards are laid out in a flex-wrap grid
* anchored to the bottom of the canvas — never stacked on top of each other.
*/
function QueueCardGrid({
items,
onCancel,
}: {
items: QueueItem[]
onCancel?: (id: string) => void
}) {
if (items.length === 0) return null

return (
<div
className="absolute inset-x-4 bottom-4 z-20 pointer-events-none"
data-testid="queue-card-grid"
>
<div className="mx-auto max-w-[800px] max-h-[50%] overflow-y-auto">
<div className="flex flex-wrap items-end justify-center gap-2.5">
{items.map((item) => (
<QueueCard
key={item.id}
item={item}
onCancel={onCancel}
/>
))}
</div>
</div>
</div>
)
}

function QueueCard({
item,
onCancel,
}: {
item: QueueItem
onCancel?: (id: string) => void
}) {
const isProcessing = item.status === "processing"
const statusLabel = isProcessing ? "Generating" : "Queued"

// Size cards relative to count — keep them compact but legible
const targetWidthPx = 140
const heightPx = Math.round(targetWidthPx / Math.max(0.4, item.aspectRatio))
const clampedHeightPx = Math.max(64, Math.min(180, heightPx))

return (
<div
className={cn(
"relative rounded-xl border shadow-sm overflow-hidden pointer-events-auto",
"bg-background/60 backdrop-blur-md",
isProcessing
? "border-primary/30 ring-1 ring-primary/10"
: "border-primary/15"
)}
style={{
height: clampedHeightPx,
aspectRatio: item.aspectRatio,
}}
data-testid="queue-card"
>
{/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] via-transparent to-transparent" />

{/* Content: spinner + label */}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 px-2">
<Loader2
className={cn(
"h-5 w-5 animate-spin",
isProcessing ? "text-primary" : "text-muted-foreground/60"
)}
/>
<span
className={cn(
"text-[10px] font-medium leading-tight text-center",
isProcessing ? "text-primary/90" : "text-muted-foreground/70"
)}
>
{statusLabel}
</span>
</div>

{/* Per-card Stop button — top-right corner */}
{onCancel && (
<button
type="button"
onClick={() => onCancel(item.id)}
className={cn(
"absolute top-1 right-1 z-10 pointer-events-auto",
"flex items-center justify-center",
"h-5 w-5 rounded-full",
"bg-background/80 backdrop-blur-sm border border-white/10",
"text-muted-foreground hover:text-destructive hover:bg-destructive/10",
"transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-destructive"
)}
aria-label={`Stop generation ${item.labelIndex}`}
data-testid="queue-card-stop"
>
<X className="h-3 w-3" />
</button>
)}
</div>
)
}

export const ImageCanvas = React.memo(function ImageCanvas({
image,
isGenerating = false,
queueItems = [],
onCancelItem,

progress,
onImageClick,
children,
Expand Down Expand Up @@ -243,6 +370,13 @@ export const ImageCanvas = React.memo(function ImageCanvas({
)}
</AnimatePresence>
</div>

{queueItems.length > 0 && (
<QueueCardGrid
items={queueItems}
onCancel={onCancelItem}
/>
)}
</div>
)
})
Loading