Skip to content

Commit 4f06ce1

Browse files
authored
Merge pull request #51 from kamilsss655/file-browse
json api: file and directory listing for sd card
2 parents 0dffd4f + 420593d commit 4f06ce1

File tree

15 files changed

+606
-160
lines changed

15 files changed

+606
-160
lines changed

frontend/package-lock.json

Lines changed: 121 additions & 108 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
"dependencies": {
1212
"@quasar/extras": "^1.16.9",
1313
"axios": "^1.6.8",
14+
"lodash": "^4.17.21",
1415
"pinia": "^2.1.7",
1516
"quasar": "^2.15.1",
1617
"vue": "^3.4.21",
1718
"vue-router": "^4.3.0"
1819
},
1920
"devDependencies": {
2021
"@quasar/vite-plugin": "^1.6.0",
22+
"@types/lodash": "^4.17.5",
2123
"@vitejs/plugin-vue": "^5.0.4",
2224
"sass": "^1.72.0",
2325
"typescript": "^5.2.2",
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<template>
2+
<q-table
3+
title="Directories"
4+
:dense="$q.screen.lt.lg"
5+
:rows="listing.directories"
6+
:columns="columnsDirectories"
7+
row-key="name"
8+
:loading="loading"
9+
flat
10+
bordered
11+
/>
12+
<br />
13+
<q-table
14+
title="Files"
15+
:dense="$q.screen.lt.lg"
16+
:rows="listing.files"
17+
:columns="columnsFiles"
18+
row-key="name"
19+
:loading="loading"
20+
flat
21+
bordered
22+
>
23+
<template v-slot:body="tableProps">
24+
<q-tr :props="tableProps">
25+
<q-td key="name" :props="tableProps">
26+
{{ tableProps.row.name }}
27+
</q-td>
28+
<q-td key="size" :props="tableProps">
29+
{{ tableProps.row.size }}
30+
</q-td>
31+
<q-td key="actions" :props="tableProps">
32+
<q-btn
33+
dense
34+
flat
35+
icon="ion-play"
36+
color="white"
37+
@click="notImplemented()"
38+
>
39+
<q-tooltip> Play {{ tableProps.row.name }} </q-tooltip>
40+
</q-btn>
41+
<q-btn
42+
dense
43+
flat
44+
icon="ion-call"
45+
color="white"
46+
@click="notImplemented()"
47+
>
48+
<q-tooltip> Transmit {{ tableProps.row.name }} </q-tooltip>
49+
</q-btn>
50+
<q-btn
51+
dense
52+
flat
53+
icon="ion-cloud-download"
54+
color="white"
55+
@click="
56+
downloadFile(
57+
props.prefix + props.path,
58+
tableProps.row.name
59+
)
60+
"
61+
>
62+
<q-tooltip> Download {{ tableProps.row.name }} </q-tooltip>
63+
</q-btn>
64+
<q-btn
65+
dense
66+
flat
67+
icon="ion-trash"
68+
color="white"
69+
@click="notImplemented()"
70+
>
71+
<q-tooltip> Delete {{ tableProps.row.name }} </q-tooltip>
72+
</q-btn>
73+
</q-td>
74+
</q-tr>
75+
</template>
76+
</q-table>
77+
</template>
78+
79+
<script setup lang="ts">
80+
import { onMounted, ref, watch } from "vue";
81+
import { Notify } from "quasar";
82+
import axios from "axios";
83+
import { debounce } from "lodash";
84+
import { Listing } from "../../types/Filesystem";
85+
86+
const props = defineProps({
87+
prefix: {
88+
type: String,
89+
required: true
90+
},
91+
path: {
92+
type: String,
93+
required: true
94+
}
95+
});
96+
97+
const axiosInstance = axios.create();
98+
axiosInstance.defaults.timeout = 600;
99+
100+
const loading = ref(false);
101+
102+
const listing = ref<Listing>({
103+
files: [],
104+
directories: []
105+
});
106+
107+
const columnsFiles = [
108+
{ name: "name", label: "Name", field: "name", sortable: true },
109+
{
110+
name: "size",
111+
label: "Size (bytes)",
112+
field: "size",
113+
sortable: true,
114+
sort: (a: string, b: string, _rowA: any, _rowB: any) =>
115+
parseInt(a, 10) - parseInt(b, 10)
116+
},
117+
{ name: "actions", label: "Actions", field: "actions" }
118+
];
119+
120+
const columnsDirectories = [
121+
{ name: "name", label: "Name", field: "name", sortable: true }
122+
];
123+
124+
const notImplemented = async () => {
125+
alert("Not implemented.");
126+
};
127+
128+
const fetchData = async () => {
129+
try {
130+
const response = await axios.get(props.prefix+props.path);
131+
listing.value = response.data;
132+
Notify.create({
133+
message: `Fetched listing for the ${props.prefix+props.path} directory.`,
134+
color: "positive"
135+
});
136+
} catch (error) {
137+
Notify.create({
138+
message: `Failed to fetch listing for the ${props.prefix+props.path} directory`,
139+
color: "negative"
140+
});
141+
console.error(error);
142+
} finally {
143+
loading.value = false;
144+
}
145+
};
146+
147+
const downloadFile = async (relativePath: string, filename: string) => {
148+
Notify.create({
149+
message: `Downloading file. Please wait.`,
150+
color: "positive"
151+
});
152+
fetch(`${relativePath}/${filename}`)
153+
.then((response) => response.blob())
154+
.then((blob) => {
155+
const url = window.URL.createObjectURL(new Blob([blob]));
156+
const link = document.createElement("a");
157+
link.href = url;
158+
link.setAttribute("download", filename);
159+
document.body.appendChild(link);
160+
link.click();
161+
if (link.parentNode === null) {
162+
return;
163+
}
164+
link.parentNode.removeChild(link);
165+
})
166+
.catch((error) => {
167+
console.error("Error downloading file:", error);
168+
});
169+
};
170+
171+
// Debounced version of the fetchData() function - can only be called once per 500ms
172+
const debouncedFetchData = debounce(async () => {
173+
fetchData();
174+
}, 500);
175+
176+
// On component render we fetch data
177+
onMounted(() => {
178+
debouncedFetchData();
179+
});
180+
181+
// If current path changes we fetch data
182+
watch(
183+
() => [props.prefix, props.path],
184+
([currentPath, storage]) => {
185+
if (currentPath || storage) {
186+
loading.value = true;
187+
debouncedFetchData();
188+
}
189+
}
190+
);
191+
</script>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<template>
2+
<div class="row">
3+
<div class="col-12 col-md-2">
4+
<q-select
5+
v-model="storagePath.prefix"
6+
:options="storageOptions"
7+
label="Prefix"
8+
behavior="dialog"
9+
emit-value
10+
map-options
11+
>
12+
<template v-slot:option="scope">
13+
<q-item v-bind="scope.itemProps">
14+
<q-item-section avatar>
15+
<q-icon :name="scope.opt.icon" />
16+
</q-item-section>
17+
<q-item-section>
18+
<q-item-label>{{ scope.opt.label }}</q-item-label>
19+
<q-item-label caption>{{ scope.opt.description }}</q-item-label>
20+
</q-item-section>
21+
</q-item>
22+
</template>
23+
</q-select>
24+
</div>
25+
<div class="col-12 col-md-10">
26+
<q-input v-model="storagePath.path" label="Path"
27+
:rules="[ (val) => endsWithSlash(val) || 'Please make sure the path ends with a slash']"/>
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script setup lang="ts">
33+
import { ref } from "vue";
34+
import { FilesystemBasePath, StoragePath } from "../../types/Filesystem";
35+
36+
// TODO: Fix this typescript error and remove ignore
37+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
38+
// @ts-ignore
39+
const storagePath: StoragePath = defineModel();
40+
41+
const storageOptions = ref([
42+
{
43+
label: FilesystemBasePath.Flash,
44+
value: FilesystemBasePath.Flash,
45+
description: "Device FLASH memory. Limited in size and can be unreliable.",
46+
icon: "ion-bug"
47+
},
48+
{
49+
label: FilesystemBasePath.SdCard,
50+
value: FilesystemBasePath.SdCard,
51+
description: "Removable SD card memory. Larger size and reliable.",
52+
icon: "ion-save"
53+
}
54+
]);
55+
56+
function endsWithSlash(str :string) {
57+
return str.endsWith("/");
58+
}
59+
</script>

frontend/src/components/system/FileUploader.vue renamed to frontend/src/components/files/Uploader.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@
1717
import { Notify, QUploaderFactoryFn } from "quasar";
1818
import { ApiPaths, ApiResponse, GetApiResponseFromJson } from "../../types/Api";
1919
20+
const props = defineProps({
21+
prefix: {
22+
type: String,
23+
required: true
24+
},
25+
path: {
26+
type: String,
27+
required: true
28+
}
29+
});
30+
2031
function onUploaded(info: {files: readonly any[], xhr: XMLHttpRequest}) {
2132
const response: ApiResponse = GetApiResponseFromJson(info.xhr.response);
2233
@@ -49,7 +60,7 @@ function onFailed(info: {files: readonly any[], xhr: XMLHttpRequest}) {
4960
// Resolve upload URL
5061
const factoryFn: QUploaderFactoryFn = (files: readonly File[]) => {
5162
return {
52-
url: ApiPaths.FileUpload + "/" + files[0].name,
63+
url: ApiPaths.FileUpload + props.prefix + props.path + files[0].name,
5364
method: "POST"
5465
};
5566
};

frontend/src/types/Filesystem.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export enum FilesystemBasePath {
2+
Flash = "/storage",
3+
SdCard = "/sd"
4+
}
5+
6+
export interface File {
7+
name: string;
8+
size: string;
9+
}
10+
11+
export interface Directory {
12+
name: string;
13+
}
14+
15+
export interface Listing {
16+
files: File[];
17+
directories: Directory[];
18+
}
19+
20+
export interface StoragePath {
21+
prefix :string;
22+
path :string;
23+
}

frontend/src/views/Beacon.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,14 @@
4848
filled
4949
/>
5050

51-
<q-banner v-if="beaconMode == BeaconMode.WAV" inline-actions rounded class="bg-info text-white">
52-
For flash storage use <b>/storage</b> prefix. For SD card storage use <b>/sd</b> prefix.
51+
<q-banner
52+
v-if="beaconMode == BeaconMode.WAV"
53+
inline-actions
54+
rounded
55+
class="bg-info text-white"
56+
>
57+
For flash storage use <b>{{ FilesystemBasePath.Flash }}</b> prefix.
58+
For SD card storage use <b>{{ FilesystemBasePath.SdCard }}</b> prefix.
5359
</q-banner>
5460

5561
<q-field
@@ -189,6 +195,7 @@
189195
import { computed, onMounted, ref } from "vue";
190196
import { useSettingsStore } from "../stores/settings";
191197
import { BeaconMode } from "../types/Settings";
198+
import { FilesystemBasePath } from "../types/Filesystem";
192199
193200
const settingsStore = useSettingsStore();
194201

frontend/src/views/Files.vue

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,29 @@
66
</q-card-section>
77
<q-separator />
88
<q-card-section>
9-
<FileUploader />
9+
<PathSelector v-model="storagePath"/>
10+
</q-card-section>
11+
<q-card-section>
12+
<Browser :prefix="storagePath.prefix" :path="storagePath.path" />
13+
</q-card-section>
14+
<q-card-section>
15+
<Uploader :prefix="storagePath.prefix" :path="storagePath.path"/>
1016
</q-card-section>
1117
<q-card-section>
1218
<q-banner inline-actions rounded class="bg-info text-white">
13-
File should be named <b>sample.wav</b>. Have resolution of <b>16-bit signed</b>, and <b>32kHz</b> sample rate.
19+
Files should have resolution of <b>16-bit signed</b>, and <b>32kHz</b> sample rate.
1420
</q-banner>
1521
</q-card-section>
1622
</q-card>
1723
</div>
1824
</template>
1925

2026
<script setup lang="ts">
21-
import FileUploader from "../components/system/FileUploader.vue";
27+
import Uploader from "../components/files/Uploader.vue";
28+
import Browser from "../components/files/Browser.vue";
29+
import PathSelector from "../components/files/PathSelector.vue";
30+
import { FilesystemBasePath, StoragePath } from "../types/Filesystem";
31+
import { ref } from "vue";
32+
33+
const storagePath = ref<StoragePath>({ prefix: FilesystemBasePath.SdCard, path: "/" });
2234
</script>

frontend/vite.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ export default defineConfig({
4141
target: "http://" + espIpAddress, // IP address for the ESP web server
4242
changeOrigin: true,
4343
secure: false,
44+
},
45+
"/storage": {
46+
target: "http://" + espIpAddress, // IP address for the ESP web server
47+
changeOrigin: true,
48+
secure: false,
49+
},
50+
"/sd": {
51+
target: "http://" + espIpAddress, // IP address for the ESP web server
52+
changeOrigin: true,
53+
secure: false,
4454
}
4555
}
4656
},

0 commit comments

Comments
 (0)