Unify generation card interactions across devices

This commit is contained in:
WhiteRaven11
2026-07-28 23:11:42 +03:00
committed by jaiprasad04
parent 6aa3372b94
commit e96eb046a2
10 changed files with 392 additions and 183 deletions
@@ -4,6 +4,7 @@ import { useState, useCallback } from "react";
import toast, { Toaster } from "react-hot-toast";
import { generateImage } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
const CDN = "https://cdn.muapi.ai/influencer";
@@ -740,7 +741,7 @@ export default function AiInfluencerStudio({
>
<img src={item.url} alt={`Character ${idx + 1}`} className="w-full h-full object-cover" />
{/* Download on hover */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-2">
<div className="absolute inset-0 hidden md:flex bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity items-end justify-center pb-2">
<div
role="button"
tabIndex={0}
@@ -751,6 +752,15 @@ export default function AiInfluencerStudio({
<DownloadIcon />
</div>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () => downloadImg(item.url),
},
]}
/>
{/* Index badge */}
<div className="absolute top-1 left-1 px-1.5 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-[8px] text-gray-300 font-bold">
#{history.length - idx}
+37 -36
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { generateImage, uploadFile } from "../muapi.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
PromptAspectRatioIcon,
PromptAction,
@@ -724,24 +725,6 @@ export default function CinemaStudio({
[onGenerationError],
);
// ── Load history item ──
const loadHistoryItem = (entry, idx) => {
if (entry.settings) {
setSettings((prev) => ({
...prev,
camera: entry.settings.camera ?? prev.camera,
lens: entry.settings.lens ?? prev.lens,
focal: entry.settings.focal ?? prev.focal,
aperture: entry.settings.aperture ?? prev.aperture,
aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio,
prompt: entry.settings.prompt ?? prev.prompt,
}));
if (entry.settings.resolution) setResolution(entry.settings.resolution);
}
setCanvasUrl(entry.url);
};
const resetToPrompt = () => {
setCanvasUrl(null);
setSettings((prev) => ({ ...prev, prompt: "" }));
@@ -762,7 +745,7 @@ export default function CinemaStudio({
<div
key={entry.timestamp ?? idx}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#22d3ee]/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => loadHistoryItem(entry, idx)}
onClick={() => setFullscreenUrl(entry.url)}
>
<img
src={entry.url}
@@ -771,23 +754,7 @@ export default function CinemaStudio({
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-[#22d3ee] hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Download"
@@ -833,6 +800,40 @@ export default function CinemaStudio({
</svg>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: async () => {
try {
const response = await fetch(entry.url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = blobUrl;
anchor.download = `cinema-shot-${entry.id || idx}.jpg`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(entry.url, "_blank");
}
},
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
setInternalHistory((prev) => prev.filter((_, i) => i !== idx));
}
},
},
]}
/>
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
@@ -5,6 +5,7 @@ import toast, { Toaster } from "react-hot-toast";
import { runClipping, uploadFile } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
PROMPT_CONTROL_LABEL_CLASS,
PROMPT_MEDIA_PREVIEW_CLASS,
@@ -592,17 +593,17 @@ export default function ClippingStudio({
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
onClick={() => handleSelectHistory(entry)}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
>
<div className="aspect-video bg-zinc-950 flex items-center justify-center border-b border-white/5 relative overflow-hidden">
<video
src={entry.videoUrl}
className="w-full h-full object-cover opacity-60 group-hover:opacity-85 transition-opacity cursor-pointer animate-fade-in"
className="w-full h-full object-cover opacity-60 group-hover:opacity-85 transition-opacity animate-fade-in"
preload="metadata"
muted
loop
playsInline
onClick={() => handleSelectHistory(entry)}
onMouseOver={(e) => e.target.play()}
onMouseOut={(e) => {
e.target.pause();
@@ -611,7 +612,7 @@ export default function ClippingStudio({
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<div className="absolute top-2 right-2 z-10 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Delete from history"
@@ -624,11 +625,21 @@ export default function ClippingStudio({
<TrashIcon />
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () =>
setHistory((prev) =>
prev.filter((historyEntry) => historyEntry.id !== entry.id),
),
},
]}
/>
</div>
<div
onClick={() => handleSelectHistory(entry)}
className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2 cursor-pointer"
>
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<div className="flex flex-col gap-1">
<h4 className="text-xs font-bold text-white truncate" title={entry.videoUrl.split('/').pop()}>
{entry.videoUrl.split('/').pop() || "source_video.mp4"}
@@ -775,13 +786,13 @@ export default function ClippingStudio({
{result.clips.map((clipUrl, i) => (
<div
key={i}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
onClick={() => setFullscreenUrl(clipUrl)}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
>
<div className="relative group/vid border-b border-white/5 overflow-hidden bg-black/40">
<video
src={clipUrl}
className={`w-full ${getAspectClass(result.aspectRatio)} object-cover bg-black/40 cursor-pointer hover:opacity-85 transition-opacity`}
onClick={() => setFullscreenUrl(clipUrl)}
className={`w-full ${getAspectClass(result.aspectRatio)} object-cover bg-black/40 hover:opacity-85 transition-opacity`}
controls={false}
loop
muted
@@ -794,23 +805,7 @@ export default function ClippingStudio({
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover/vid:opacity-100 transition-opacity z-10">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(clipUrl);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 z-10 hidden md:flex flex-col gap-2 opacity-0 group-hover/vid:opacity-100 transition-opacity">
<button
type="button"
title="Copy Link"
@@ -834,6 +829,21 @@ export default function ClippingStudio({
<DownloadIcon />
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "copy",
label: "Copy link",
onSelect: () => copyToClipboard(clipUrl),
},
{
kind: "download",
label: "Download",
onSelect: () =>
downloadVideo(clipUrl, `clip-${i + 1}.mp4`),
},
]}
/>
<div className="absolute top-2 left-2 bg-black/60 backdrop-blur-md px-2 py-1 rounded border border-white/5 text-[9px] uppercase font-black tracking-wider text-primary">
Clip #{i + 1}
+25 -20
View File
@@ -6,6 +6,7 @@ import { generateImage, generateI2I, uploadFile } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import DrawModal from "./DrawModal.jsx";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
t2iModels,
i2iModels,
@@ -1301,33 +1302,17 @@ export default function ImageStudio({
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => setFullscreenUrl(entry.url)}
>
<img
src={entry.url}
alt={entry.prompt?.substring(0, 30) || "Generated image"}
className="w-full aspect-square object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
className="w-full aspect-square object-cover bg-black/40 hover:opacity-80 transition-opacity"
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Download"
@@ -1360,6 +1345,26 @@ export default function ImageStudio({
</svg>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () =>
downloadImage(entry.url, `muapi-${entry.id || idx}.jpg`),
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
setLocalHistory((prev) => prev.filter((_, i) => i !== idx));
}
},
},
]}
/>
{/* Prompt & Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
@@ -5,6 +5,7 @@ import toast, { Toaster } from "react-hot-toast";
import { processLipSync, uploadFile } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
lipsyncModels,
imageLipSyncModels,
@@ -757,12 +758,12 @@ export default function LipSyncStudio({
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => setFullscreenUrl(entry.url)}
>
<video
src={entry.url}
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -775,23 +776,7 @@ export default function LipSyncStudio({
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Download"
@@ -824,6 +809,26 @@ export default function LipSyncStudio({
</svg>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () =>
downloadFile(entry.url, `lipsync-${entry.id || idx}.mp4`),
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
setInternalHistory((prev) => prev.filter((_, i) => i !== idx));
}
},
},
]}
/>
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { uploadFile, generateMarketingStudioAd } from "../muapi.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
PROMPT_CONTROL_LABEL_CLASS,
PromptAspectRatioIcon,
@@ -426,32 +427,19 @@ export default function MarketingStudio({
{history.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
{history.map(entry => (
<div key={entry.id} className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col">
<div
key={entry.id}
onClick={() => setFullscreenUrl(entry.url)}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
>
<video
src={entry.url}
className="w-full aspect-video object-cover cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
className="w-full aspect-video object-cover hover:opacity-80 transition-opacity"
muted loop onMouseOver={e => e.target.play()} onMouseOut={e => { e.target.pause(); e.target.currentTime = 0; }}
/>
{/* Actions Overlay */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); downloadFile(entry.url, `marketing-ad-${entry.id}.mp4`); }}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
@@ -480,8 +468,32 @@ export default function MarketingStudio({
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () =>
downloadFile(entry.url, `marketing-ad-${entry.id}.mp4`),
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
if (!historyItems) {
setLocalHistory((prev) =>
prev.filter((item) => item.id !== entry.id),
);
}
}
},
},
]}
/>
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
function ActionIcon({ kind }) {
if (kind === "download") {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<path d="M7 10l5 5 5-5M12 15V3" />
</svg>
);
}
if (kind === "delete") {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
<path d="M10 11v5M14 11v5" />
</svg>
);
}
if (kind === "extend") {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
);
}
if (kind === "remix") {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a3 3 0 013-3h15M7 22l-4-4 4-4" />
<path d="M21 13v2a3 3 0 01-3 3H3" />
</svg>
);
}
if (kind === "copy") {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M15 9V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7a2 2 0 002 2h3" />
</svg>
);
}
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4l3 2" />
</svg>
);
}
export default function MobileGenerationActions({ actions = [] }) {
const [open, setOpen] = useState(false);
const availableActions = actions.filter(Boolean);
if (availableActions.length === 0) return null;
const stopCardClick = (event) => {
event.stopPropagation();
};
const runAction = (event, action) => {
event.stopPropagation();
setOpen(false);
action.onSelect?.();
};
return (
<div className="absolute right-2 top-2 z-40 md:hidden" onClick={stopCardClick}>
{open && (
<button
type="button"
aria-label="Close actions"
className="fixed inset-0 z-40 cursor-default bg-transparent"
onClick={(event) => {
event.stopPropagation();
setOpen(false);
}}
/>
)}
<button
type="button"
aria-label="Generation actions"
aria-expanded={open}
onClick={(event) => {
event.stopPropagation();
setOpen((current) => !current);
}}
className="relative z-50 flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-md active:scale-95"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.7" />
<circle cx="12" cy="12" r="1.7" />
<circle cx="19" cy="12" r="1.7" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-12 z-50 min-w-[178px] overflow-hidden rounded-xl border border-white/15 bg-[#151515]/95 p-1.5 shadow-2xl backdrop-blur-xl">
{availableActions.map((action) => (
<button
key={`${action.kind}-${action.label}`}
type="button"
onClick={(event) => runAction(event, action)}
className={`flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-sm font-semibold transition-colors ${
action.danger
? "text-red-400 hover:bg-red-500/15 active:bg-red-500/20"
: "text-white hover:bg-white/10 active:bg-white/15"
}`}
>
<span className="flex h-6 w-6 items-center justify-center">
<ActionIcon kind={action.kind} />
</span>
<span>{action.label}</span>
</button>
))}
</div>
)}
</div>
);
}
+25 -20
View File
@@ -5,6 +5,7 @@ import toast, { Toaster } from "react-hot-toast";
import { processRecast, uploadFile } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
recastModels,
getRecastModelById,
@@ -790,12 +791,12 @@ export default function RecastStudio({
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => setFullscreenUrl(entry.url)}
>
<video
src={entry.url}
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -808,23 +809,7 @@ export default function RecastStudio({
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Download"
@@ -857,6 +842,26 @@ export default function RecastStudio({
</svg>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () =>
downloadFile(entry.url, `bodyswap-${entry.id || idx}.mp4`),
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
setInternalHistory((prev) => prev.filter((_, i) => i !== idx));
}
},
},
]}
/>
{/* Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
@@ -5,6 +5,7 @@ import toast, { Toaster } from "react-hot-toast";
import { runMotionGraphics, runMotionGraphicsEdit } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
PROMPT_CONTROL_LABEL_CLASS,
PromptAspectRatioIcon,
@@ -307,13 +308,13 @@ export default function VibeMotionStudio({
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
className="relative group rounded overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => setFullscreenUrl(entry.url)}
>
{/* Video thumbnail */}
<video
src={entry.url}
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -332,20 +333,7 @@ export default function VibeMotionStudio({
</div>
{/* ── Hover overlay actions ── */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => { e.stopPropagation(); setFullscreenUrl(entry.url); }}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Download"
@@ -406,6 +394,37 @@ export default function VibeMotionStudio({
</svg>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () =>
downloadFile(entry.url, `motion-${entry.id || idx}.mp4`),
},
entry.requestId &&
entry.canEdit !== false && {
kind: "remix",
label: "Remix",
onSelect: () => {
setEditMode(true);
setEditSourceId(entry.requestId);
setPrompt("");
setTimeout(() => textareaRef.current?.focus(), 50);
},
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
setHistory((prev) => prev.filter((_, i) => i !== idx));
}
},
},
]}
/>
{/* ── Card footer: prompt + metadata ── */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
+33 -20
View File
@@ -6,6 +6,7 @@ import { generateVideo, generateI2V, processV2V, uploadFile } from "../muapi.js"
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
import DrawModal from "./DrawModal.jsx";
import MobileGenerationActions from "./MobileGenerationActions.jsx";
import {
t2vModels,
i2vModels,
@@ -1368,12 +1369,12 @@ export default function VideoStudio({
return (
<div
key={entry.id || idx}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => setFullscreenUrl(entry.url)}
>
<video
src={entry.url}
className="w-full aspect-video object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -1386,23 +1387,7 @@ export default function VideoStudio({
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<div className="absolute top-2 right-2 hidden md:flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Download"
@@ -1451,6 +1436,34 @@ export default function VideoStudio({
</svg>
</button>
</div>
<MobileGenerationActions
actions={[
{
kind: "download",
label: "Download",
onSelect: () =>
downloadFile(entry.url, `video-${entry.id || idx}.mp4`),
},
isSeedance2 && {
kind: "extend",
label: "Extend",
onSelect: () => {
setLastGenerationId(entry.id);
handleExtend();
},
},
{
kind: "delete",
label: "Delete",
danger: true,
onSelect: () => {
if (confirm("Are you sure you want to delete this generated item?")) {
setLocalHistory((prev) => prev.filter((_, i) => i !== idx));
}
},
},
]}
/>
{/* Prompt & Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">