mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 17:58:55 +00:00
website: download on site instead of opening new tab
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user