Skip to content

Commit 39b8c13

Browse files
authored
feat: implement asset file download functionality (#33)
* feat: implement asset file download functionality * feat: add download functionality for audio, image, and video nodes
1 parent 35b1a63 commit 39b8c13

File tree

9 files changed

+231
-18
lines changed

9 files changed

+231
-18
lines changed

binding/app/service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package app
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"io"
@@ -10,6 +11,8 @@ import (
1011
"visionflow/database"
1112
)
1213

14+
var WailsContext *context.Context
15+
1316
type Service struct {
1417
WailsJSON string
1518
}

binding/database/service.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import (
44
"crypto/md5"
55
"encoding/hex"
66
"fmt"
7+
"io"
78
"os"
89
"path/filepath"
910
"time"
11+
"visionflow/binding/app"
1012
db "visionflow/database"
1113
"visionflow/service/fileserver"
1214
"visionflow/storage"
15+
16+
"github.com/wailsapp/wails/v2/pkg/runtime"
1317
)
1418

1519
type Service struct {
@@ -142,3 +146,51 @@ func (s *Service) CreateAssetFromFile(name string, data []byte) (*db.Asset, erro
142146
createdAsset.URL = fileserver.GetFileUrl(createdAsset.Path)
143147
return createdAsset, nil
144148
}
149+
150+
func (s *Service) DownloadAssetFile(filename string) error {
151+
// 1. Resolve source path
152+
assetsDir, err := storage.GetAssetsDir()
153+
if err != nil {
154+
return fmt.Errorf("failed to get assets directory: %w", err)
155+
}
156+
sourcePath := filepath.Join(assetsDir, filename)
157+
158+
// Check if source file exists
159+
if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
160+
return fmt.Errorf("source file does not exist: %s", filename)
161+
}
162+
163+
// 2. Open Save Dialog
164+
destPath, err := runtime.SaveFileDialog(*app.WailsContext, runtime.SaveDialogOptions{
165+
DefaultFilename: filename,
166+
Title: "Save Asset File",
167+
})
168+
if err != nil {
169+
return err
170+
}
171+
172+
// User cancelled
173+
if destPath == "" {
174+
return nil
175+
}
176+
177+
// 3. Copy File efficiently
178+
source, err := os.Open(sourcePath)
179+
if err != nil {
180+
return fmt.Errorf("failed to open source file: %w", err)
181+
}
182+
defer source.Close()
183+
184+
destination, err := os.Create(destPath)
185+
if err != nil {
186+
return fmt.Errorf("failed to create destination file: %w", err)
187+
}
188+
defer destination.Close()
189+
190+
_, err = io.Copy(destination, source)
191+
if err != nil {
192+
return fmt.Errorf("failed to save file: %w", err)
193+
}
194+
195+
return nil
196+
}

frontend/src/components/asset-library.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useState, useEffect, useRef } from "react";
22
import { database } from "../../wailsjs/go/models";
3-
import { ListAssets, DeleteAsset } from "../../wailsjs/go/database/Service";
3+
import { ListAssets, DeleteAsset, DownloadAssetFile } from "../../wailsjs/go/database/Service";
44
import { Card, CardContent, CardFooter } from "@/components/ui/card";
55
import { Button } from "@/components/ui/button";
6-
import { Trash2, FileImage, FileVideo, FileAudio } from "lucide-react";
6+
import { Trash2, FileImage, FileVideo, FileAudio, Download } from "lucide-react";
77
import { toast } from "sonner";
88
import { msg } from "@lingui/core/macro";
99
import { Trans } from "@lingui/react/macro";
@@ -53,7 +53,16 @@ export function AssetLibrary() {
5353
}
5454
};
5555

56-
56+
const handleSave = async (e: React.MouseEvent, asset: database.Asset) => {
57+
e.stopPropagation();
58+
try {
59+
await DownloadAssetFile(asset.path);
60+
toast.success(_(msg`Asset saved`));
61+
} catch (err) {
62+
console.error("Failed to save asset:", err);
63+
toast.error(_(msg`Failed to save`));
64+
}
65+
};
5766

5867
return (
5968
<div className="p-6 h-full overflow-y-auto">
@@ -89,6 +98,14 @@ export function AssetLibrary() {
8998
<CardContent className="p-0 relative">
9099
<AssetPreview asset={asset} />
91100
<div className="absolute bottom-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
101+
<Button
102+
variant="ghost"
103+
size="icon"
104+
className="h-6 w-6 hover:text-primary hover:bg-primary/10 bg-background/50 backdrop-blur-sm"
105+
onClick={(e) => handleSave(e, asset)}
106+
>
107+
<Download className="h-4 w-4" />
108+
</Button>
92109
<Button
93110
variant="ghost"
94111
size="icon"
@@ -153,6 +170,11 @@ export function AssetLibrary() {
153170
)}
154171
</div>
155172
<div className="p-4 border-t flex justify-end gap-2 bg-muted/30">
173+
<Button variant="outline" onClick={(e) => {
174+
if (previewAsset) {
175+
handleSave(e as any, previewAsset);
176+
}
177+
}}><Trans>Save</Trans></Button>
156178
<Button variant="outline" onClick={() => setPreviewAsset(null)}><Trans>Close</Trans></Button>
157179
<Button variant="destructive" onClick={(e) => {
158180
if (previewAsset) {

frontend/src/components/nodes/audio-node.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import { memo } from "react";
2-
import { type NodeProps, useReactFlow } from "@xyflow/react";
3-
import { Music } from "lucide-react";
2+
import {
3+
type NodeProps,
4+
useReactFlow,
5+
NodeToolbar,
6+
Position,
7+
} from "@xyflow/react";
8+
import { Music, Download } from "lucide-react";
49
import type { AudioNodeData } from "./types";
510
import { GenerateAudio } from "../../../wailsjs/go/ai/Service";
11+
import { DownloadAssetFile } from "../../../wailsjs/go/database/Service";
612
import { Skeleton } from "@/components/ui/skeleton";
713
import { BaseNode } from "./base-node";
814
import { useNodeRun } from "../../hooks/use-node-run";
915
import { Trans } from "@lingui/react/macro";
1016
import { useLingui } from "@lingui/react";
1117
import { msg } from "@lingui/core/macro";
18+
import { ButtonGroup } from "../ui/button-group";
19+
import { Button } from "@/components/ui/button";
20+
import { toast } from "sonner";
1221

1322
export const AudioNode = memo((props: NodeProps) => {
1423
const { id, data } = props;
24+
const isSelected = props.selected;
1525
const nodeData = data as unknown as AudioNodeData;
1626
const { updateNodeData } = useReactFlow();
1727
const { _ } = useLingui();
@@ -28,6 +38,21 @@ export const AudioNode = memo((props: NodeProps) => {
2838
},
2939
});
3040

41+
const handleSave = async () => {
42+
if (!nodeData.audioUrl) {
43+
toast.error(_(msg`No audio to save`));
44+
return;
45+
}
46+
try {
47+
const filename = nodeData.audioUrl.split("/").pop() || "";
48+
await DownloadAssetFile(filename);
49+
toast.success(_(msg`Asset saved`));
50+
} catch (err) {
51+
console.error("Failed to save asset:", err);
52+
toast.error(_(msg`Failed to save`));
53+
}
54+
};
55+
3156
return (
3257
<BaseNode
3358
{...props}
@@ -38,6 +63,24 @@ export const AudioNode = memo((props: NodeProps) => {
3863
minWidth={200}
3964
minHeight={75}
4065
>
66+
<NodeToolbar
67+
isVisible={isSelected}
68+
position={Position.Top}
69+
align="center"
70+
offset={30}
71+
>
72+
<ButtonGroup>
73+
<Button
74+
onClick={handleSave}
75+
title="Download"
76+
size={"icon"}
77+
variant={"outline"}
78+
>
79+
<Download className="h-4 w-4" />
80+
</Button>
81+
</ButtonGroup>
82+
</NodeToolbar>
83+
4184
<div className="p-4 flex items-center justify-center bg-muted/20 w-full flex-1">
4285
{nodeData.processing ? (
4386
<div className="size-full space-y-2">
@@ -46,7 +89,9 @@ export const AudioNode = memo((props: NodeProps) => {
4689
) : nodeData.audioUrl ? (
4790
<audio src={nodeData.audioUrl} controls className="size-full" />
4891
) : (
49-
<div className="text-xs text-muted-foreground italic"><Trans>No audio yet</Trans></div>
92+
<div className="text-xs text-muted-foreground italic">
93+
<Trans>No audio yet</Trans>
94+
</div>
5095
)}
5196
</div>
5297
</BaseNode>

frontend/src/components/nodes/image-node.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { memo } from "react";
2-
import { type NodeProps, useReactFlow } from "@xyflow/react";
3-
import { Image as ImageIcon } from "lucide-react";
2+
import {
3+
type NodeProps,
4+
NodeToolbar,
5+
Position,
6+
useReactFlow,
7+
} from "@xyflow/react";
8+
import { Download, Image as ImageIcon } from "lucide-react";
49
import type { ImageNodeData } from "./types";
510
import { GenerateImage } from "../../../wailsjs/go/ai/Service";
11+
import { DownloadAssetFile } from "../../../wailsjs/go/database/Service";
612
import { Skeleton } from "@/components/ui/skeleton";
713
import { BaseNode } from "./base-node";
814
import { useNodeRun } from "../../hooks/use-node-run";
915
import { useLingui } from "@lingui/react";
1016
import { msg } from "@lingui/core/macro";
1117
import { Trans } from "@lingui/react/macro";
18+
import { ButtonGroup } from "../ui/button-group";
19+
import { Button } from "../ui/button";
20+
import { toast } from "sonner";
1221

1322
export const ImageNode = memo((props: NodeProps) => {
1423
const { id, data } = props;
1524
const nodeData = data as unknown as ImageNodeData;
25+
const isSelected = props.selected;
1626
const { updateNodeData } = useReactFlow();
1727
const { _ } = useLingui();
1828

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

41+
const handleSave = async () => {
42+
if (nodeData.imageUrl == null) {
43+
toast.error(_(msg`No image to save`));
44+
return;
45+
}
46+
try {
47+
await DownloadAssetFile(nodeData.imageUrl.split("/").pop() || "");
48+
toast.success(_(msg`Asset saved`));
49+
} catch (err) {
50+
console.error("Failed to save asset:", err);
51+
toast.error(_(msg`Failed to save`));
52+
}
53+
};
54+
3155
return (
3256
<BaseNode
3357
{...props}
@@ -38,6 +62,24 @@ export const ImageNode = memo((props: NodeProps) => {
3862
minWidth={200}
3963
minHeight={200}
4064
>
65+
<NodeToolbar
66+
isVisible={isSelected}
67+
position={Position.Top}
68+
align="center"
69+
offset={30}
70+
>
71+
<ButtonGroup>
72+
<Button
73+
onClick={handleSave}
74+
title="Download"
75+
size={"icon"}
76+
variant={"outline"}
77+
>
78+
<Download className="h-4 w-4" />
79+
</Button>
80+
</ButtonGroup>
81+
</NodeToolbar>
82+
4183
<div className="p-0 overflow-hidden bg-muted/20 w-full flex-1 flex items-center justify-center">
4284
{nodeData.processing ? (
4385
<div className="size-full p-4 space-y-2 flex flex-col justify-center">

0 commit comments

Comments
 (0)