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
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