Compare commits

...
Author SHA1 Message Date
bflatastic 557ec5a1f1 activity card improvements to share lightbox with gallery 2025-04-17 02:40:03 +07:00
6 changed files with 286 additions and 250 deletions
@@ -0,0 +1,122 @@
import { CloseButton, Button } from "~/components";
import { Transition } from "@headlessui/react";
import { createPortal } from "react-dom";
import dayjs from "dayjs";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
interface LightboxModalProps {
isOpen: boolean;
onClose: () => void;
imageUrl?: string | null;
imageAlt?: string;
onImageError?: () => void;
title?: string;
createdAt?: string;
additionalInfo?: React.ReactNode;
downloadUrl?: string;
}
export function LightboxModal({
isOpen,
onClose,
imageUrl,
imageAlt = "",
onImageError,
title,
createdAt,
additionalInfo,
downloadUrl,
}: LightboxModalProps) {
return createPortal(
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={onClose}
/>
</Transition.Child>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={onClose}
>
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<CloseButton
onClick={onClose}
className="absolute right-4 top-4 z-10"
/>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/40">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
) : (
<img
src={imageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="flex h-full flex-col py-5 pe-5">
<div className="flex-1 space-y-4">
<div className="text-xl font-medium">
{title || "Image Generation"}
</div>
{createdAt && (
<div className="text-sm text-white/60">
Created:{" "}
{dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
</div>
)}
{additionalInfo}
</div>
{downloadUrl && (
<div className="flex justify-end">
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
className="no-underline"
>
<Button
icon={faDownToLine}
onClick={(e) => e.stopPropagation()}
>
Download
</Button>
</a>
</div>
)}
</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Transition>,
document.body,
);
}
@@ -27,16 +27,16 @@ export const TopBar = ({ pageName }: Props) => {
return (
<>
<header className="fixed left-0 top-0 z-30 w-full border-b border-white/5 bg-ui-background">
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
<nav
className="mx-auto grid h-[56px] w-screen grid-cols-3 items-center justify-between px-3"
aria-label="Global"
>
<div className="flex gap-4">
<a href="/" className="">
<a href="/">
<span className="sr-only">ArtCraft</span>
<img
className="h-[30px] w-auto"
className="h-[28px] w-auto"
src="/resources/images/artcraft-logo-3.png"
alt="Logo ArtCraft"
/>
@@ -9,7 +9,7 @@ import { activeImageGenerationJobs, toasts, userMovies } from "~/signals";
import { useEffect, useState } from "react";
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
import { toast } from "sonner";
import { ActiveJob } from "~/pages/PageEnigma/models";
// TODO ensure we de-dupe all this extra code.
interface CompletedItem {
token: string;
@@ -18,17 +18,6 @@ interface CompletedItem {
updated_at: string;
}
interface ActiveJob {
job_token: string;
request: {
maybe_model_title: string;
};
status: {
status: string;
progress_percentage: number;
};
}
// {
// "job_token": "jinf_421nnr6rj8wy2sj0s11v9fns32s",
// "request": {
@@ -80,65 +69,78 @@ export function Activity() {
try {
const jobsApi = new JobsApi();
const jobsResponse = await jobsApi.ListRecentJobs();
if (jobsResponse.success && jobsResponse.data) {
// Process active jobs
const activeJobs = jobsResponse.data
.filter(job => {
.filter((job) => {
const status = job.status?.status?.toLowerCase();
return status && status !== "complete_success" && status !== "failed";
return (
status && status !== "complete_success" && status !== "failed"
);
})
.map(job => ({
.map((job) => ({
job_token: job.job_token,
request: {
maybe_model_title: job.request?.maybe_model_title || "Image Generation"
maybe_model_title:
job.request?.maybe_model_title || "Image Generation",
},
status: {
status: job.status?.status?.toUpperCase() || "",
progress_percentage: job.status?.progress_percentage || 0
}
progress_percentage: job.status?.progress_percentage || 0,
},
}));
// Process completed items
// Get completed items and filter out any that are already in our list
const successfulJobs = jobsResponse.data
.filter(job => job.status?.status?.toLowerCase() === "complete_success");
const successfulJobs = jobsResponse.data.filter(
(job) => job.status?.status?.toLowerCase() === "complete_success",
);
// Create a set of existing tokens for faster lookup
const existingTokens = new Set(completedItems.map(item => item.token));
const existingTokens = new Set(
completedItems.map((item) => item.token),
);
// Filter out jobs we already have in our list
const newCompletedItems = successfulJobs
.filter(job => !existingTokens.has(job.job_token))
.map(job => ({
.filter((job) => !existingTokens.has(job.job_token))
.map((job) => ({
token: job.job_token,
maybe_title: job.request?.maybe_model_title || "Image Generation",
public_bucket_path: job.maybe_result?.maybe_public_bucket_media_path || "",
updated_at: job.updated_at || new Date().toISOString()
maybe_title:
job.request?.maybe_model_title || "Image Generation",
public_bucket_path:
job.maybe_result?.maybe_public_bucket_media_path || "",
updated_at: job.updated_at || new Date().toISOString(),
}));
// Show toast notification for newly completed items
if (newCompletedItems.length > 0) {
const count = newCompletedItems.length;
const message = count === 1
? `${newCompletedItems[0].maybe_title} completed successfully`
: `${count} images completed successfully`;
const message =
count === 1
? `${newCompletedItems[0].maybe_title} completed successfully`
: `${count} images completed successfully`;
toast.success(message);
}
// Update completed items with a maximum limit of 50 items
setCompletedItems(prev => {
setCompletedItems((prev) => {
const combined = [...newCompletedItems, ...prev];
// Remove duplicates based on token
const uniqueItems = Array.from(
new Map(combined.map(item => [item.token, item])).values()
new Map(combined.map((item) => [item.token, item])).values(),
);
// Sort by updated_at in descending order and limit to 50 items
return uniqueItems
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.slice(0, 50);
});
setJobs(activeJobs);
setJobs(activeJobs as ActiveJob[]);
setLoading(false);
}
} catch (error) {
@@ -158,6 +160,7 @@ export function Activity() {
}, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@@ -170,23 +173,26 @@ export function Activity() {
align="end"
triggerIcon={
<div>
<FontAwesomeIcon icon={faBell} />
{activeImageGenerationJobs.value &&
activeImageGenerationJobs.value.length > 0 && (
<div className="bg-blue-500 absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full" />
)}
{jobs && jobs.length > 0 ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<FontAwesomeIcon icon={faBell} />
)}
{jobs && jobs.length > 0 && (
<div className="bg-blue-500 absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full" />
)}
</div>
}
>
<div className="max-h-[480px] overflow-y-auto">
{/* In progress */}
{jobs && jobs.length > 0 && (
<div>
{jobs.map((job) => (
<InProgressCard key={job.job_token} movie={job} />
))}
</div>
)}
<div>
{jobs.map((job) => (
<InProgressCard key={job.job_token} job={job} />
))}
</div>
)}
{!userMovies.value && !activeImageGenerationJobs.value ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
@@ -212,16 +218,10 @@ export function Activity() {
{/* Completed */}
<div className="flex flex-col">
{completedItems.map((item) => (
<CompletedCard
key={item.token}
movie={item}
setMovieId={(item_id) => {
console.log("item_id", item_id);
}}
/>
<CompletedCard key={item.token} job={item} />
))}
</div>
</div>
</div>
)}
</div>
</PopoverMenu>
@@ -1,98 +1,84 @@
import { MediaInfo } from "~/pages/PageEnigma/models/movies";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDownToLine } from "@fortawesome/pro-solid-svg-icons";
import { useRef, useState } from "react";
import { BucketConfig } from "~/api/BucketConfig";
import { useState } from "react";
import dayjs from "dayjs";
import environmentVariables from "~/Classes/EnvironmentVariables";
import { useSignals } from "@preact/signals-react/runtime";
import { downloadFileImage } from "~/pages/PageEnigma/comps/GenerateModals/utils/downloadFile";
import { styleList } from "~/pages/PageEnigma/styleList";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
import { LightboxModal } from "~/components/reusable/LightboxModal";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
export function getStyleName(typeInput: string) {
const foundStyle = styleList.find((style) => {
return style.type === typeInput;
});
return foundStyle ? foundStyle.label : "Unknown Style";
}
interface Props {
movie: MediaInfo;
setMovieId?: (id: string) => void;
job: {
token: string;
maybe_title: string;
updated_at: string;
public_bucket_path: string;
maybe_style_name?: string;
};
}
export function CompletedCard({ movie, setMovieId }: Props) {
useSignals();
const bucketConfig = useRef<BucketConfig>(new BucketConfig());
export function CompletedCard({ job }: Props) {
const [loadError, setLoadError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const cdnOrigin = GetCdnOrigin();
const mediaPath = job?.public_bucket_path || "";
const downloadLink = `${cdnOrigin}${mediaPath}`;
const imageUrl = downloadLink;
const fullImageUrl = downloadLink;
const downloadLink = `${cdnOrigin}${movie.public_bucket_path}`;
// console.log(downloadLink);
// console.log("This is the completed item", movie);
// console.log("item completed card", movie);
// to do use the thumbnail image
const imageUrl = downloadLink; // bucketConfig.current.getCdnUrl(
// movie.public_bucket_path + "-thumb.gif",
// 360,
// 20,
// );
const styleName =
movie.maybe_style_name && getStyleName(movie.maybe_style_name);
const handleClick = () => {
setMovieId?.(movie.token);
};
if (!job) return null;
return (
<div
className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:cursor-pointer hover:bg-white/10"
onClick={handleClick}
>
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
<img
src={
loadError ? "/resources/images/movie-placeholder.png" : imageUrl
}
className="h-full w-full object-cover"
alt={movie.maybe_title ?? "unknown"}
crossOrigin="anonymous"
onError={() => setLoadError(true)}
loading="lazy"
/>
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">{movie.maybe_title || "Untitled"}</div>
<div>
{/* {styleName && (
<div className="text-sm text-white/60">{styleName}</div>
)} */}
<>
<div
className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:cursor-pointer hover:bg-white/10"
onClick={() => setIsModalOpen(true)}
>
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
<img
src={
loadError ? "/resources/images/movie-placeholder.png" : imageUrl
}
className="h-full w-full object-cover"
alt={job.maybe_title ?? "unknown"}
crossOrigin="anonymous"
onError={() => setLoadError(true)}
loading="lazy"
/>
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">{job.maybe_title || "Untitled"}</div>
<div className="text-sm text-white/60">
{dayjs(movie.updated_at).format("MMM D, YYYY HH:mm:ss")}
{dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")}
</div>
</div>
</div>
</div>
<div className="pr-5">
<button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const title =
movie.maybe_title !== null ? movie.maybe_title : "Untitled";
downloadFileImage({ url: downloadLink, title });
}}
className="text-[15px] font-medium text-white/50 transition-all duration-150 hover:text-white/100"
<a
href={downloadLink}
download
onClick={(e) => e.stopPropagation()}
className="flex h-9 w-9 items-center justify-center text-lg text-white/60 transition-all hover:text-white"
>
<FontAwesomeIcon icon={faArrowDownToLine} className="mr-2" />
Download
</button>
<FontAwesomeIcon icon={faDownToLine} />
</a>
</div>
</div>
<LightboxModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
imageUrl={fullImageUrl}
imageAlt={job.maybe_title ?? "unknown"}
onImageError={() => setLoadError(true)}
title={job.maybe_title}
createdAt={job.updated_at}
downloadUrl={downloadLink}
additionalInfo={
job.maybe_style_name && (
<div className="text-sm text-white/60">
Style: {job.maybe_style_name}
</div>
)
}
/>
</>
);
}
@@ -1,22 +1,15 @@
import { useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClose,
faCircleExclamation,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { faClose, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { ToastTypes } from "~/enums";
import { addToast } from "~/signals";
import { JobState } from "~/pages/PageEnigma/enums";
import { ActiveJob } from "~/pages/PageEnigma/models";
import { Tooltip } from "~/components";
import { JobsApi } from "~/Classes/ApiManager";
import { PollRecentJobs } from "~/hooks/useActiveJobs/utilities";
interface Props {
movie: ActiveJob;
job: ActiveJob;
}
function getPercent(status: JobState, percentage: number) {
@@ -33,16 +26,16 @@ function getPercent(status: JobState, percentage: number) {
}
}
export function InProgressCard({ movie }: Props) {
export function InProgressCard({ job }: Props) {
const completePercent = getPercent(
movie.status.status as JobState,
movie.status.progress_percentage,
job.status.status as JobState,
job.status.progress_percentage,
);
const deleteJob = useCallback(async (movieJob: ActiveJob) => {
const deleteJob = useCallback(async (job: ActiveJob) => {
const jobsApi = new JobsApi();
const response = await jobsApi.DeleteJobByToken(movieJob.job_token);
const response = await jobsApi.DeleteJobByToken(job.job_token);
if (response.success) {
PollRecentJobs();
addToast(ToastTypes.SUCCESS, "File successfully deleted.");
@@ -58,32 +51,33 @@ export function InProgressCard({ movie }: Props) {
return (
<div className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:bg-white/10">
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 items-center justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
{(movie.status.status === JobState.STARTED ||
movie.status.status === JobState.PENDING) && (
<FontAwesomeIcon icon={faSpinnerThird} spin size={"lg"} />
<div className="flex aspect-square h-14 w-14 items-center justify-center rounded-lg border border-[#A9A9A9]/50 bg-black/60 text-white">
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
{/* {(job.status.status === JobState.STARTED ||
job.status.status === JobState.PENDING) && (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
)}
{(movie.status.status === JobState.COMPLETE_FAILURE ||
movie.status.status === JobState.ATTEMPT_FAILED) && (
{(job.status.status === JobState.COMPLETE_FAILURE ||
job.status.status === JobState.ATTEMPT_FAILED) && (
<FontAwesomeIcon icon={faCircleExclamation} size={"lg"} />
)}
)} */}
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">
{movie.request.maybe_model_title || "Untitled"}
{job.request.maybe_model_title || "Image Generation"}
</div>
<div className="text-sm capitalize text-white/60">
{movie.status.status.replaceAll("_", " ")}... {completePercent}%
{job.status.status.replaceAll("_", " ")}... {completePercent}%
</div>
</div>
</div>
{movie.status.status !== JobState.STARTED && (
{job.status.status !== JobState.STARTED && (
<div className="pr-5">
<Tooltip content="Cancel" position="left">
<button
onClick={() => deleteJob(movie)}
onClick={() => deleteJob(job)}
className="text-[15px] font-medium text-white/50 transition-all duration-150 hover:text-white/100"
>
<FontAwesomeIcon icon={faClose} />
@@ -3,19 +3,12 @@ import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, LoadingSpinner, CloseButton } from "~/components";
import { TabSelector } from "~/components/reusable/TabSelector";
import React, {
useState,
useEffect,
Fragment,
useCallback,
useMemo,
} from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { LibraryModalApi } from "./LibraryModalApi";
import { FilterMediaClasses } from "../../../../enums";
import { BucketConfig } from "../../../../api/BucketConfig";
import { twMerge } from "tailwind-merge";
import { createPortal } from "react-dom";
import { Transition } from "@headlessui/react";
import { LightboxModal } from "~/components/reusable/LightboxModal";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
export interface LibraryItem {
id: string;
@@ -64,7 +57,8 @@ export const LibraryModal = React.memo(
);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const bucketConfig = useMemo(() => new BucketConfig(), []);
const cdnOrigin = GetCdnOrigin();
const imageUrl = lightboxImage?.fullImage || "";
const api = useMemo(() => new LibraryModalApi(), []);
const formatDate = useCallback((date: string) => {
@@ -109,10 +103,11 @@ export const LibraryModal = React.memo(
const getImageUrl = useCallback(
(path: string | undefined | null) => {
if (!path) return null;
const url = bucketConfig.getGcsUrl(path);
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = `${cdnOrigin}${normalizedPath}`;
return failedImageUrls.has(url) ? null : url;
},
[bucketConfig, failedImageUrls],
[cdnOrigin, failedImageUrls],
);
useEffect(() => {
@@ -125,11 +120,8 @@ export const LibraryModal = React.memo(
if (response.success && response.data) {
const newItems = response.data.map((item) => ({
id: item.token,
label: item.maybe_title || "Untitled",
thumbnail: getImageUrl(
item.cover_image.maybe_cover_image_public_bucket_path ||
item.public_bucket_path,
),
label: item.maybe_title || "Image Generation",
thumbnail: getImageUrl(item.public_bucket_path),
fullImage: getImageUrl(item.public_bucket_path),
createdAt: item.created_at,
}));
@@ -318,74 +310,16 @@ export const LibraryModal = React.memo(
</div>
</TransitionDialogue>
{/* Lightbox Modal */}
{lightboxImage &&
createPortal(
<Transition appear show={isLightboxVisible} as={Fragment}>
<div className="fixed inset-0 z-[100]">
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={handleCloseLightbox}
/>
</Transition.Child>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={handleCloseLightbox}
>
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<CloseButton
onClick={handleCloseLightbox}
className="absolute right-4 top-4 z-10"
/>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden bg-black/40">
{!lightboxImage.fullImage ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={lightboxImage.fullImage}
alt={lightboxImage.label}
className="h-full w-full object-contain"
onError={() =>
handleImageError(lightboxImage.fullImage!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="py-5">Prompt</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Transition>,
document.body,
)}
<LightboxModal
isOpen={isLightboxVisible}
onClose={handleCloseLightbox}
imageUrl={imageUrl}
imageAlt={lightboxImage?.label || ""}
onImageError={() => imageUrl && handleImageError(imageUrl)}
title={lightboxImage?.label}
createdAt={lightboxImage?.createdAt}
downloadUrl={imageUrl}
/>
</>
);
},