website: download on site instead of opening new tab

This commit is contained in:
bflatastic committed 2026-04-29 11:04:42 +07:00
1 parent 96194ea0d8
commit 3ede4ef8d1
3 files changed
+112 -28

No files matched your search

@@ -11,9 +11,10 @@ import {
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import { Tooltip } from "@storyteller/ui-tooltip";
import { toast } from "@storyteller/ui-toaster";
import { downloadMediaFile } from "../../lib/download-media";
import {
getModelCreatorIconPath,
getModelDisplayName,
@@ -119,6 +120,7 @@ export const GalleryCard = memo(function GalleryCard({
const [ratio, setRatio] = useState<number | undefined>(cached);
const [shareCopied, setShareCopied] = useState(false);
const [isRecreating, setIsRecreating] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const recreateMediaClass: RecreateMediaClass | null = isVideo
@@ -277,9 +279,23 @@ export const GalleryCard = memo(function GalleryCard({
[item, onClick],
);
const downloadHref = item.fullImage
? addCorsParam(item.fullImage) || item.fullImage
: undefined;
const handleDownload = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!item.fullImage || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFile({
url: item.fullImage,
filename: `artcraft-${item.id}`,
mediaClass: item.mediaClass,
});
} finally {
setIsDownloading(false);
}
},
[item.fullImage, item.id, item.mediaClass, isDownloading],
);
return (
<div
@@ -385,19 +401,20 @@ export const GalleryCard = memo(function GalleryCard({
/>
</button>
</Tooltip>
{downloadHref && (
{item.fullImage && (
<Tooltip content="Download" position="top">
<a
href={downloadHref}
download={`artcraft-${item.id}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
aria-label="Download"
>
<FontAwesomeIcon icon={faArrowDownToLine} className="text-sm" />
</a>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</div>
@@ -14,12 +14,12 @@ import {
faImage,
faLink,
faPencil,
faSpinnerThird,
faTrashCan,
faUser,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
@@ -29,6 +29,7 @@ import {
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import { downloadMediaFile } from "../../lib/download-media";
import {
formatAspectRatio,
formatDuration,
@@ -88,6 +89,7 @@ export function LightboxDetails({
}: LightboxDetailsProps) {
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const [isDownloading, setIsDownloading] = useState(false);
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [isPromptClamped, setIsPromptClamped] = useState(false);
@@ -95,6 +97,7 @@ export function LightboxDetails({
useEffect(() => {
setIsPromptExpanded(false);
setIsDownloading(false);
}, [mediaToken]);
useEffect(() => {
@@ -368,23 +371,33 @@ export function LightboxDetails({
>
{shareCopy.copied ? "Copied" : "Share"}
</Button>
<a
<button
type="button"
disabled={!mediaUrl || isDownloading}
onClick={async () => {
if (!mediaUrl || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFile({
url: mediaUrl,
filename: `artcraft-${mediaToken || "media"}`,
});
} finally {
setIsDownloading(false);
}
}}
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
mediaUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
}`}
href={mediaUrl ? addCorsParam(mediaUrl) || mediaUrl : undefined}
download={
mediaUrl ? `artcraft-${mediaToken || "media"}` : undefined
}
aria-disabled={!mediaUrl}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faArrowDownToLine} />
Download
</a>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={isDownloading ? "animate-spin" : ""}
/>
{isDownloading ? "Downloading…" : "Download"}
</button>
</div>
{onRecreate && promptData.hasToken && (
<Button
@@ -0,0 +1,54 @@
import { addCorsParam } from "@storyteller/common";
import { toast } from "@storyteller/ui-toaster";
const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
dimensional: "glb",
};
function extensionForUrl(url: string, mediaClass?: string | null): string {
try {
const pathname = new URL(url, window.location.href).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
if (match) return match[1].toLowerCase();
} catch {
// ignore — fall through to mediaClass default
}
if (mediaClass && EXT_BY_MEDIA_CLASS[mediaClass]) {
return EXT_BY_MEDIA_CLASS[mediaClass];
}
return "bin";
}
export async function downloadMediaFile({
url,
filename,
mediaClass,
}: {
url: string;
filename: string;
mediaClass?: string | null;
}): Promise<void> {
const corsUrl = addCorsParam(url) || url;
let blobUrl: string | null = null;
try {
const response = await fetch(corsUrl, { credentials: "omit" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
blobUrl = window.URL.createObjectURL(blob);
const ext = extensionForUrl(url, mediaClass);
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = `${filename}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
toast.error("Could not download file.");
} finally {
if (blobUrl) window.URL.revokeObjectURL(blobUrl);
}
}