Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 additions & 1 deletion apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -4059,7 +4059,11 @@
"select-power": "Quality boost",
"finishing": "Your image is generated and finishing upload. Tap below to fetch it — you won't be charged again.",
"finishing-retry": "Fetch image",
"still-generating": "Your image is still generating. It will be fetched automatically with no extra charge."
"still-generating": "Your image is still generating. It will be fetched automatically with no extra charge.",
"tab-generate": "Generate",
"tab-history": "History",
"history-error": "Could not load your generation history. Please try again later.",
"history-login-required": "Log in to see your generation history."
},
"ai-usage": {
"menu": "AI usage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ import { getAccessToken, ensureValidToken } from "@/utils";
import {
getAiGeneratePriceQueryOptions,
getPointsQueryOptions,
QueryKeys,
useAddImage,
useGenerateImage,
type AiImagePowerTier,
} from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import clsx from "clsx";
import i18next from "i18next";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { AiImageHistory } from "./ai-image-history";
import { downloadImage } from "./download-image";

interface Props {
onInsert?: (url: string) => void;
showInsertAction?: boolean;
Expand Down Expand Up @@ -60,6 +64,7 @@ const AUTO_FETCH_DEFAULT_DELAY_S = 5;
export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedPrompt }: Props) {
const { activeUser } = useActiveAccount();
const username = activeUser?.username;
const queryClient = useQueryClient();

const accessToken = username ? getAccessToken(username) : "";

Expand All @@ -84,6 +89,7 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP

const { mutateAsync: addToGallery } = useAddImage(username, accessToken);

const [activeTab, setActiveTab] = useState<"generate" | "history">("generate");
const [prompt, setPrompt] = useState("");
const [selectedRatio, setSelectedRatio] = useState<string | null>(null);
const [selectedPower, setSelectedPower] = useState<AiImagePowerTier | null>(null);
Expand Down Expand Up @@ -235,6 +241,13 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP
const status = err?.status;
const data = err?.data;

// Whatever the client saw, the generation may have completed server-side (that is
// the entire recovery design). Mark the history stale so an open or next-opened
// History tab shows what the server actually delivered.
if (username) {
queryClient.invalidateQueries({ queryKey: QueryKeys.ai.images(username) });
}

// 202 = paid, upload finishing. 409 in_progress = the prediction is still running
// server-side. Both mean: keep the key (a retry only fetches, never re-bills) and
// poll automatically at the backend's suggested cadence, up to a bounded budget.
Expand Down Expand Up @@ -282,7 +295,7 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP
inFlightRef.current = false;
}
}, [selectedRatio, selectedPower, prompt, username, generateImage, addToGallery, cost,
clearAutoFetch]);
clearAutoFetch, queryClient]);

handleGenerateRef.current = handleGenerate;

Expand All @@ -292,14 +305,48 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP

const handleDownload = useCallback(() => {
if (generatedUrl) {
window.open(generatedUrl, "_blank");
downloadImage(generatedUrl);
}
}, [generatedUrl]);

// Switching tabs never touches the generate state: a pending attempt keeps its key
// and timers, and the result view is still there when the user switches back.
const tabBar = (
<div className="flex items-center gap-1 border-b border-[--border-color]">
{(["generate", "history"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={clsx(
"px-3 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
activeTab === tab
? "border-blue-dark-sky text-blue-dark-sky"
: "border-transparent opacity-60 hover:opacity-100"
)}
>
{i18next.t(`ai-image-generator.tab-${tab}`)}
</button>
))}
</div>
);

// History view: where an already-paid image is found again when the client never
// saw the success response (timeout, closed tab, delivery finished later).
if (activeTab === "history") {
return (
<div className="flex flex-col gap-4">
{tabBar}
<AiImageHistory onInsert={onInsert} showInsertAction={showInsertAction} />
</div>
);
}

// Result view
if (generatedUrl) {
return (
<div className="animate-fade-in-up flex flex-col gap-4">
{tabBar}
<div className="font-semibold">{i18next.t("ai-image-generator.result-title")}</div>
<div className="border border-[--border-color] rounded-xl overflow-hidden">
<Image
Expand Down Expand Up @@ -331,6 +378,7 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP
// Generator form
return (
<div className="flex flex-col gap-4">
{tabBar}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="opacity-50">{i18next.t("ai-image-generator.balance-label")}:</div>
Expand Down
116 changes: 116 additions & 0 deletions apps/web/src/features/shared/ai-image-generator/ai-image-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

import { useActiveAccount } from "@/core/hooks/use-active-account";
import { Button } from "@/features/ui";
import { ensureValidToken, getAccessToken } from "@/utils";
import { getAiImagesQueryOptions } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";
import i18next from "i18next";
import Image from "next/image";
import { useEffect, useState } from "react";

import { downloadImage } from "./download-image";

interface Props {
onInsert?: (url: string) => void;
showInsertAction?: boolean;
}

/**
* The user's recent successful generations, served by the backend. A generation can
* complete and be billed while the client saw a timeout or an error, so this list is
* where such an already-paid image is found again.
*/
export function AiImageHistory({ onInsert, showInsertAction = true }: Props) {
const { activeUser } = useActiveAccount();
const username = activeUser?.username;

// getAccessToken can hand back an EXPIRED token while a background refresh runs; the
// query would then 401 and sit on the error state until remount. Resolve a valid
// token first and only enable the query once it exists.
const [accessToken, setAccessToken] = useState<string | null>(null);
useEffect(() => {
let alive = true;
if (!username) {
setAccessToken(null);
return;
}
ensureValidToken(username)
.then((token) => {
if (alive) setAccessToken(token ?? getAccessToken(username) ?? null);
})
.catch(() => {
if (alive) setAccessToken(getAccessToken(username) ?? null);
});
return () => {
alive = false;
};
}, [username]);

const { data, isError } = useQuery(getAiImagesQueryOptions(username, accessToken ?? ""));

// A disabled query is not an empty history: without an account there was no request.
if (!username) {
return (
<div className="opacity-50 py-4">
{i18next.t("ai-image-generator.history-login-required")}
</div>
);
}

if (!data) {
if (isError) {
return (
<div className="opacity-50 py-4">{i18next.t("ai-image-generator.history-error")}</div>
);
}
// Token resolution or the fetch itself is still in flight.
return <div className="opacity-50 py-4">...</div>;
}

if (data.length === 0) {
return (
<div className="opacity-50 py-4">{i18next.t("ai-image-generator.history-empty")}</div>
);
}

return (
<div className="flex flex-col gap-3">
{data.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 border border-[--border-color] rounded-xl p-2"
>
<a href={item.url} target="_blank" rel="noopener noreferrer" className="shrink-0">
<Image
src={item.url}
alt={item.prompt}
width={96}
height={96}
className="w-24 h-24 object-cover rounded-lg"
unoptimized={true}
/>
</a>
<div className="min-w-0 flex-1">
<div className="text-sm truncate" title={item.prompt}>
{item.prompt}
</div>
<div className="text-xs opacity-50 mt-0.5">
{new Date(item.created).toLocaleDateString()}
</div>
<div className="flex items-center gap-2 mt-2">
{showInsertAction && onInsert && (
<Button size="xs" onClick={() => onInsert(item.url)}>
{i18next.t("ai-image-generator.insert-button")}
</Button>
)}
<Button size="xs" appearance="gray" onClick={() => downloadImage(item.url)}>
{i18next.t("ai-image-generator.download-button")}
</Button>
</div>
</div>
</div>
))}
</div>
);
}
25 changes: 25 additions & 0 deletions apps/web/src/features/shared/ai-image-generator/download-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Downloads an image to a file. Cross-origin `download` attributes are ignored by
* browsers, so the bytes are fetched as a blob first; when even that fails (CORS,
* network) fall back to opening the image so the user can still save it manually.
*/
export async function downloadImage(url: string, filename = "ai-generated"): Promise<void> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`download failed with status ${response.status}`);
}
const blob = await response.blob();
const ext = blob.type.split("/")[1]?.split("+")[0] || "webp";
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${filename}.${ext}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, "_blank");
}
}
Loading
Loading