Spaces:
Runtime error
Runtime error
import { useEffect, useRef, useState } from 'react'; | |
import { Button } from '@/components/ui/button'; | |
import { | |
DESIGN_RESIZE, | |
HISTORY_UNDO, | |
HISTORY_REDO, | |
dispatcher, | |
useEditorState, | |
} from '@designcombo/core'; | |
import logoDark from '@/assets/logo-dark.png'; | |
import { Icons } from '../shared/icons'; | |
import { | |
Popover, | |
PopoverContent, | |
PopoverTrigger, | |
} from '@/components/ui/popover'; | |
import { ChevronDown, Download } from 'lucide-react'; | |
import { Label } from '../ui/label'; | |
import { Progress } from '../ui/progress'; | |
import { download } from '@/utils/download'; | |
const baseUrl = 'https://renderer.designcombo.dev'; | |
// https://renderer.designcombo.dev/status/{id} | |
export default function Navbar() { | |
const handleUndo = () => { | |
dispatcher.dispatch(HISTORY_UNDO); | |
}; | |
const handleRedo = () => { | |
dispatcher.dispatch(HISTORY_REDO); | |
}; | |
const openLink = (url: string) => { | |
window.open(url, '_blank'); // '_blank' will open the link in a new tab | |
}; | |
return ( | |
<div | |
style={{ | |
display: 'grid', | |
gridTemplateColumns: '320px 1fr 320px', | |
}} | |
className="h-[72px] absolute top-0 left-0 right-0 px-2 z-[205] pointer-events-none flex items-center" | |
> | |
<div className="flex items-center gap-2 pointer-events-auto h-14"> | |
<div className="bg-zinc-950 h-12 w-12 flex items-center justify-center rounded-md"> | |
<img src={logoDark} alt="logo" className="h-5 w-5" /> | |
</div> | |
<div className="bg-zinc-950 px-1.5 h-12 flex items-center"> | |
<Button | |
onClick={handleUndo} | |
className="text-muted-foreground" | |
variant="ghost" | |
size="icon" | |
> | |
<Icons.undo width={20} /> | |
</Button> | |
<Button | |
onClick={handleRedo} | |
className="text-muted-foreground" | |
variant="ghost" | |
size="icon" | |
> | |
<Icons.redo width={20} /> | |
</Button> | |
</div> | |
</div> | |
<div className="pointer-events-auto h-14 flex items-center gap-2 justify-center"> | |
<div className="bg-zinc-950 px-2.5 rounded-md h-12 gap-4 flex items-center"> | |
<div className="font-medium text-sm px-1">Untitled video</div> | |
<ResizeVideo /> | |
</div> | |
</div> | |
<div className="flex items-center gap-2 pointer-events-auto h-14 justify-end"> | |
<div className="flex items-center gap-2 bg-zinc-950 px-2.5 rounded-md h-12"> | |
<Button | |
className="border border-white/10 flex gap-2" | |
onClick={() => openLink('https://discord.gg/jrZs3wZyM5')} | |
size="xs" | |
variant="secondary" | |
> | |
<svg | |
stroke="currentColor" | |
fill="currentColor" | |
strokeWidth="0" | |
viewBox="0 0 640 512" | |
height={16} | |
xmlns="http://www.w3.org/2000/svg" | |
> | |
<path d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"></path> | |
</svg> | |
Discord | |
</Button> | |
<DownloadPopover /> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
interface IDownloadState { | |
renderId: string; | |
progress: number; | |
isDownloading: boolean; | |
} | |
const DownloadPopover = () => { | |
const [open, setOpen] = useState(false); | |
const popoverRef = useRef<HTMLDivElement>(null); | |
const [downloadState, setDownloadState] = useState<IDownloadState>({ | |
progress: 0, | |
isDownloading: false, | |
renderId: '', | |
}); | |
const { | |
trackItemIds, | |
trackItemsMap, | |
transitionIds, | |
transitionsMap, | |
tracks, | |
duration, | |
size, | |
} = useEditorState(); | |
const handleExport = () => { | |
const payload = { | |
trackItemIds, | |
trackItemsMap, | |
transitionIds, | |
transitionsMap, | |
tracks, | |
size: size, | |
duration: duration - 750, | |
fps: 30, | |
projectId: 'main', | |
}; | |
setDownloadState({ | |
...downloadState, | |
isDownloading: true, | |
}); | |
fetch(`${baseUrl}`, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(payload), | |
}) | |
.then((res) => res.json()) | |
.then(({ render }) => { | |
setDownloadState({ | |
...downloadState, | |
renderId: render.id, | |
isDownloading: true, | |
}); | |
}); | |
}; | |
useEffect(() => { | |
console.log('renderId', downloadState.renderId); | |
let interval: NodeJS.Timeout; | |
if (downloadState.renderId) { | |
interval = setInterval(() => { | |
fetch(`${baseUrl}/status/${downloadState.renderId}`) | |
.then((res) => res.json()) | |
.then(({ render: { progress, output } }) => { | |
if (progress === 100) { | |
clearInterval(interval); | |
setDownloadState({ | |
...downloadState, | |
renderId: '', | |
progress: 0, | |
isDownloading: false, | |
}); | |
download(output, `${downloadState.renderId}`); | |
setOpen(false); | |
} else { | |
setDownloadState({ | |
...downloadState, | |
progress, | |
isDownloading: true, | |
}); | |
} | |
}); | |
}, 1000); | |
} | |
return () => { | |
if (interval) clearInterval(interval); | |
}; | |
}, [downloadState.renderId]); | |
return ( | |
<Popover open={open} onOpenChange={setOpen}> | |
<PopoverTrigger asChild> | |
<Button className="flex gap-1 h-8 w-8" size="icon" variant="default"> | |
<Download width={18} /> | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-60 z-[250] flex flex-col gap-4"> | |
{downloadState.isDownloading ? ( | |
<> | |
<Label>Downloading</Label> | |
<div className="flex items-center gap-2"> | |
<Progress | |
className="h-2 rounded-sm" | |
value={downloadState.progress} | |
/> | |
<div className="text-zinc-400 text-sm border border-border p-1 rounded-sm"> | |
{parseInt(downloadState.progress.toString())}% | |
</div> | |
</div> | |
<div> | |
<Button className="w-full" size="xs"> | |
Copy link | |
</Button> | |
</div> | |
</> | |
) : ( | |
<> | |
<Label>Export settings</Label> | |
<Button className="w-full justify-between" variant="outline"> | |
<div>MP4</div> | |
<ChevronDown width={16} /> | |
</Button> | |
<div> | |
<Button onClick={handleExport} className="w-full" size="xs"> | |
Export | |
</Button> | |
</div> | |
</> | |
)} | |
</PopoverContent> | |
</Popover> | |
); | |
}; | |
interface ResizeOptionProps { | |
label: string; | |
icon: string; | |
value: ResizeValue; | |
} | |
interface ResizeValue { | |
width: number; | |
height: number; | |
name: string; | |
} | |
const RESIZE_OPTIONS: ResizeOptionProps[] = [ | |
{ | |
label: '16:9', | |
icon: 'landscape', | |
value: { | |
width: 1920, | |
height: 1080, | |
name: '16:9', | |
}, | |
}, | |
{ | |
label: '9:16', | |
icon: 'portrait', | |
value: { | |
width: 1080, | |
height: 1920, | |
name: '9:16', | |
}, | |
}, | |
{ | |
label: '1:1', | |
icon: 'square', | |
value: { | |
width: 1080, | |
height: 1080, | |
name: '1:1', | |
}, | |
}, | |
]; | |
const ResizeVideo = () => { | |
const handleResize = (payload: ResizeValue) => { | |
dispatcher.dispatch(DESIGN_RESIZE, { | |
payload, | |
}); | |
}; | |
return ( | |
<Popover> | |
<PopoverTrigger asChild> | |
<Button | |
className="border border-white/10" | |
size="xs" | |
variant="secondary" | |
> | |
Resize | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-60 z-[250]"> | |
<div className="grid gap-4 text-sm"> | |
{RESIZE_OPTIONS.map((option, index) => ( | |
<ResizeOption | |
key={index} | |
label={option.label} | |
icon={option.icon} | |
value={option.value} | |
handleResize={handleResize} | |
/> | |
))} | |
</div> | |
</PopoverContent> | |
</Popover> | |
); | |
}; | |
const ResizeOption = ({ | |
label, | |
icon, | |
value, | |
handleResize, | |
}: ResizeOptionProps & { handleResize: (payload: ResizeValue) => void }) => { | |
const Icon = Icons[icon]; | |
return ( | |
<div | |
onClick={() => handleResize(value)} | |
className="flex items-center gap-4 hover:bg-zinc-50/10 cursor-pointer" | |
> | |
<div className="text-muted-foreground"> | |
<Icon /> | |
</div> | |
<div> | |
<div>{label}</div> | |
<div className="text-muted-foreground">Tiktok, Instagram</div> | |
</div> | |
</div> | |
); | |
}; | |