Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions binding/app/service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -10,6 +11,8 @@ import (
"visionflow/database"
)

var WailsContext *context.Context

type Service struct {
WailsJSON string
}
Expand Down
52 changes: 52 additions & 0 deletions binding/database/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"time"
"visionflow/binding/app"
db "visionflow/database"
"visionflow/service/fileserver"
"visionflow/storage"

"github.com/wailsapp/wails/v2/pkg/runtime"
)

type Service struct {
Expand Down Expand Up @@ -142,3 +146,51 @@ func (s *Service) CreateAssetFromFile(name string, data []byte) (*db.Asset, erro
createdAsset.URL = fileserver.GetFileUrl(createdAsset.Path)
return createdAsset, nil
}

func (s *Service) DownloadAssetFile(filename string) error {
// 1. Resolve source path
assetsDir, err := storage.GetAssetsDir()
if err != nil {
return fmt.Errorf("failed to get assets directory: %w", err)
}
sourcePath := filepath.Join(assetsDir, filename)

// Check if source file exists
if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
return fmt.Errorf("source file does not exist: %s", filename)
}

// 2. Open Save Dialog
destPath, err := runtime.SaveFileDialog(*app.WailsContext, runtime.SaveDialogOptions{
DefaultFilename: filename,
Title: "Save Asset File",
})
if err != nil {
return err
}

// User cancelled
if destPath == "" {
return nil
}

// 3. Copy File efficiently
source, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer source.Close()

destination, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer destination.Close()

_, err = io.Copy(destination, source)
if err != nil {
return fmt.Errorf("failed to save file: %w", err)
}

return nil
}
28 changes: 25 additions & 3 deletions frontend/src/components/asset-library.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { database } from "../../wailsjs/go/models";
import { ListAssets, DeleteAsset } from "../../wailsjs/go/database/Service";
import { ListAssets, DeleteAsset, DownloadAssetFile } from "../../wailsjs/go/database/Service";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trash2, FileImage, FileVideo, FileAudio } from "lucide-react";
import { Trash2, FileImage, FileVideo, FileAudio, Download } from "lucide-react";
import { toast } from "sonner";
import { msg } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
Expand Down Expand Up @@ -53,7 +53,16 @@ export function AssetLibrary() {
}
};


const handleSave = async (e: React.MouseEvent, asset: database.Asset) => {
e.stopPropagation();
try {
await DownloadAssetFile(asset.path);
toast.success(_(msg`Asset saved`));
} catch (err) {
console.error("Failed to save asset:", err);
toast.error(_(msg`Failed to save`));
}
};

return (
<div className="p-6 h-full overflow-y-auto">
Expand Down Expand Up @@ -89,6 +98,14 @@ export function AssetLibrary() {
<CardContent className="p-0 relative">
<AssetPreview asset={asset} />
<div className="absolute bottom-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-primary hover:bg-primary/10 bg-background/50 backdrop-blur-sm"
onClick={(e) => handleSave(e, asset)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -153,6 +170,11 @@ export function AssetLibrary() {
)}
</div>
<div className="p-4 border-t flex justify-end gap-2 bg-muted/30">
<Button variant="outline" onClick={(e) => {
if (previewAsset) {
handleSave(e as any, previewAsset);
}
}}><Trans>Save</Trans></Button>
<Button variant="outline" onClick={() => setPreviewAsset(null)}><Trans>Close</Trans></Button>
<Button variant="destructive" onClick={(e) => {
if (previewAsset) {
Expand Down
51 changes: 48 additions & 3 deletions frontend/src/components/nodes/audio-node.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { memo } from "react";
import { type NodeProps, useReactFlow } from "@xyflow/react";
import { Music } from "lucide-react";
import {
type NodeProps,
useReactFlow,
NodeToolbar,
Position,
} from "@xyflow/react";
import { Music, Download } from "lucide-react";
import type { AudioNodeData } from "./types";
import { GenerateAudio } from "../../../wailsjs/go/ai/Service";
import { DownloadAssetFile } from "../../../wailsjs/go/database/Service";
import { Skeleton } from "@/components/ui/skeleton";
import { BaseNode } from "./base-node";
import { useNodeRun } from "../../hooks/use-node-run";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
import { msg } from "@lingui/core/macro";
import { ButtonGroup } from "../ui/button-group";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";

export const AudioNode = memo((props: NodeProps) => {
const { id, data } = props;
const isSelected = props.selected;
const nodeData = data as unknown as AudioNodeData;
const { updateNodeData } = useReactFlow();
const { _ } = useLingui();
Expand All @@ -28,6 +38,21 @@ export const AudioNode = memo((props: NodeProps) => {
},
});

const handleSave = async () => {
if (!nodeData.audioUrl) {
toast.error(_(msg`No audio to save`));
return;
}
try {
const filename = nodeData.audioUrl.split("/").pop() || "";
await DownloadAssetFile(filename);
toast.success(_(msg`Asset saved`));
} catch (err) {
console.error("Failed to save asset:", err);
toast.error(_(msg`Failed to save`));
}
};

return (
<BaseNode
{...props}
Expand All @@ -38,6 +63,24 @@ export const AudioNode = memo((props: NodeProps) => {
minWidth={200}
minHeight={75}
>
<NodeToolbar
isVisible={isSelected}
position={Position.Top}
align="center"
offset={30}
>
<ButtonGroup>
<Button
onClick={handleSave}
title="Download"
size={"icon"}
variant={"outline"}
>
<Download className="h-4 w-4" />
</Button>
</ButtonGroup>
</NodeToolbar>

<div className="p-4 flex items-center justify-center bg-muted/20 w-full flex-1">
{nodeData.processing ? (
<div className="size-full space-y-2">
Expand All @@ -46,7 +89,9 @@ export const AudioNode = memo((props: NodeProps) => {
) : nodeData.audioUrl ? (
<audio src={nodeData.audioUrl} controls className="size-full" />
) : (
<div className="text-xs text-muted-foreground italic"><Trans>No audio yet</Trans></div>
<div className="text-xs text-muted-foreground italic">
<Trans>No audio yet</Trans>
</div>
)}
</div>
</BaseNode>
Expand Down
46 changes: 44 additions & 2 deletions frontend/src/components/nodes/image-node.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { memo } from "react";
import { type NodeProps, useReactFlow } from "@xyflow/react";
import { Image as ImageIcon } from "lucide-react";
import {
type NodeProps,
NodeToolbar,
Position,
useReactFlow,
} from "@xyflow/react";
import { Download, Image as ImageIcon } from "lucide-react";
import type { ImageNodeData } from "./types";
import { GenerateImage } from "../../../wailsjs/go/ai/Service";
import { DownloadAssetFile } from "../../../wailsjs/go/database/Service";
import { Skeleton } from "@/components/ui/skeleton";
import { BaseNode } from "./base-node";
import { useNodeRun } from "../../hooks/use-node-run";
import { useLingui } from "@lingui/react";
import { msg } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ButtonGroup } from "../ui/button-group";
import { Button } from "../ui/button";
import { toast } from "sonner";

export const ImageNode = memo((props: NodeProps) => {
const { id, data } = props;
const nodeData = data as unknown as ImageNodeData;
const isSelected = props.selected;
const { updateNodeData } = useReactFlow();
const { _ } = useLingui();

Expand All @@ -28,6 +38,20 @@ export const ImageNode = memo((props: NodeProps) => {
},
});

const handleSave = async () => {
if (nodeData.imageUrl == null) {
toast.error(_(msg`No image to save`));
return;
}
try {
await DownloadAssetFile(nodeData.imageUrl.split("/").pop() || "");
toast.success(_(msg`Asset saved`));
} catch (err) {
console.error("Failed to save asset:", err);
toast.error(_(msg`Failed to save`));
}
};

return (
<BaseNode
{...props}
Expand All @@ -38,6 +62,24 @@ export const ImageNode = memo((props: NodeProps) => {
minWidth={200}
minHeight={200}
>
<NodeToolbar
isVisible={isSelected}
position={Position.Top}
align="center"
offset={30}
>
<ButtonGroup>
<Button
onClick={handleSave}
title="Download"
size={"icon"}
variant={"outline"}
>
<Download className="h-4 w-4" />
</Button>
</ButtonGroup>
</NodeToolbar>

<div className="p-0 overflow-hidden bg-muted/20 w-full flex-1 flex items-center justify-center">
{nodeData.processing ? (
<div className="size-full p-4 space-y-2 flex flex-col justify-center">
Expand Down
Loading