mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-08-31 01:40:10 +08:00
Unify generation card interactions across devices
This commit is contained in:
committed by
jaiprasad04
parent
6aa3372b94
commit
e96eb046a2
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user