Skip to content

Commit 201da93

Browse files
committed
add audio upload
1 parent 891d632 commit 201da93

File tree

11 files changed

+282
-32
lines changed

11 files changed

+282
-32
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<template>
2+
<div class="preview-container">
3+
<div class="erase" @click="$emit('delete')" :title="t('app.base.delete_audio')"></div>
4+
<div class="audio-container">
5+
<div class="audio-icon">
6+
<fa-icon :icon="faMusic" />
7+
</div>
8+
</div>
9+
<div class="info-container">
10+
<div class="code">{{ hash.substring(0, Math.min(7, hash.length)) }} / {{ convertToHumanReadableFileSize(value.size) }}</div>
11+
<strong :title="value.name">{{ value.name }}</strong>
12+
<div class="button-container">
13+
<button
14+
class="btn btn-paste mx-1"
15+
type="button"
16+
v-if="showPaste"
17+
@click="$emit('paste', getMarkdownString())"
18+
:title="t('app.base.insert_audio')"
19+
>
20+
<fa-icon :icon="faFileImport"></fa-icon>
21+
</button>
22+
<button class="btn btn-softdelete mx-1" type="button" v-if="showDelete" @click="$emit('softdelete')" :title="t('app.base.remove')">
23+
<fa-icon :icon="faEraser"></fa-icon>
24+
</button>
25+
</div>
26+
</div>
27+
</div>
28+
</template>
29+
<style scoped lang="scss">
30+
.erase {
31+
width: 16px;
32+
height: 16px;
33+
right: -7px;
34+
top: -7px;
35+
36+
display: block;
37+
position: absolute;
38+
z-index: 999;
39+
background-color: #333;
40+
border: 1px solid #555;
41+
border-radius: 50%;
42+
cursor: pointer;
43+
transition-duration: 0.4s;
44+
45+
&:hover {
46+
transition-duration: 0.4s;
47+
background-color: #a00;
48+
}
49+
50+
&:before {
51+
position: absolute;
52+
top: 0;
53+
left: calc(50% - 3px);
54+
z-index: 1000;
55+
content: "\d7";
56+
line-height: 12px;
57+
font-size: 12px;
58+
padding: 0;
59+
margin: 0;
60+
transform: scale(1.25);
61+
color: #fff;
62+
text-align: center;
63+
}
64+
}
65+
66+
.preview-container {
67+
width: 150px;
68+
margin: 0;
69+
position: relative;
70+
display: inline-block;
71+
text-align: center;
72+
background-color: #f8f9fa55;
73+
border-radius: 0.375rem;
74+
border: 1px solid #ccc;
75+
76+
.btn-paste {
77+
color: #222;
78+
79+
&:hover {
80+
color: #22222255;
81+
}
82+
}
83+
84+
.btn-softdelete {
85+
color: #f18901;
86+
87+
&:hover {
88+
color: #ffa836;
89+
}
90+
}
91+
92+
&:not(:last-child) {
93+
margin-right: 15px;
94+
}
95+
96+
.audio-container {
97+
display: flex;
98+
width: 148px;
99+
height: auto;
100+
aspect-ratio: 1/1;
101+
align-items: center;
102+
justify-content: center;
103+
padding: 0;
104+
margin: 0;
105+
}
106+
107+
.audio-icon {
108+
display: flex;
109+
align-items: center;
110+
justify-content: center;
111+
width: 80px;
112+
height: 80px;
113+
background-color: #e9ecef;
114+
border: 2px solid #6c757d;
115+
border-radius: 50%;
116+
color: #6c757d;
117+
font-size: 2rem;
118+
transition-duration: 0.3s;
119+
120+
&:hover {
121+
background-color: #dee2e6;
122+
border-color: #495057;
123+
color: #495057;
124+
}
125+
}
126+
127+
.info-container {
128+
font-size: 0.75em;
129+
margin-bottom: 7px;
130+
padding-top: 5px;
131+
132+
strong {
133+
font-weight: bold;
134+
text-overflow: ellipsis;
135+
white-space: nowrap;
136+
overflow: clip;
137+
display: block;
138+
padding: 0 5px 7px 5px;
139+
}
140+
141+
.code {
142+
font-family: monospace;
143+
font-size: 10px;
144+
margin-bottom: 5px;
145+
}
146+
147+
.button-container {
148+
display: flex;
149+
justify-content: center;
150+
align-items: space-between;
151+
}
152+
}
153+
}
154+
</style>
155+
156+
<script setup lang="ts">
157+
import { t } from "@client/plugins/i18n.js";
158+
import { faFileImport, faEraser, faMusic } from "@fortawesome/free-solid-svg-icons";
159+
import { convertToHumanReadableFileSize, escapeMarkdownAltText } from "@fumix/fu-blog-common";
160+
import type { PropType } from "vue";
161+
162+
const getMarkdownString = () => {
163+
return `![${escapeMarkdownAltText(props.value.name)}](${props.hash})`;
164+
};
165+
166+
const props = defineProps({
167+
hash: {
168+
type: String,
169+
required: true,
170+
},
171+
value: {
172+
type: Object as PropType<File>,
173+
required: true,
174+
},
175+
showDelete: {
176+
type: Boolean,
177+
default: true,
178+
},
179+
showPaste: {
180+
type: Boolean,
181+
default: true,
182+
},
183+
});
184+
185+
const emits = defineEmits(["paste", "delete", "softdelete"]);
186+
</script>

client/src/components/MarkDown.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
max-width: 100% !important;
2020
height: auto !important;
2121
}
22+
23+
audio {
24+
max-width: 100% !important;
25+
margin: 10px 0 !important;
26+
}
2227
}
2328
</style>
2429

@@ -34,7 +39,7 @@ const props = defineProps({
3439
markdown: {
3540
type: String as PropType<string | null>,
3641
},
37-
customImageUrls: {
42+
customFileUrls: {
3843
type: Object as PropType<{ [sha256: string]: File }>,
3944
default: () => {},
4045
},
@@ -47,11 +52,11 @@ watch(props, async () => {
4752
emits("loading", true);
4853
sanitizedHtml.value = await MarkdownConverterClient.Instance.convert(props.markdown ?? "", async (token: string) => {
4954
if (!token || token.length < 10) {
50-
return Promise.reject("Image hash too short");
55+
return Promise.reject("File hash too short");
5156
}
52-
const urls = Object.keys(props.customImageUrls).filter((it) => it.startsWith(token));
57+
const urls = Object.keys(props.customFileUrls).filter((it) => it.startsWith(token));
5358
if (urls && urls.length === 1) {
54-
const customFile = props.customImageUrls[urls[0]];
59+
const customFile = props.customFileUrls[urls[0]];
5560
return blobToArray(customFile).then((it) => bytesToDataUrl(customFile.type, it));
5661
}
5762
return Promise.reject("No custom file found");

client/src/i18n/de.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@
2424
"cancel": "Abbrechen",
2525
"edit": "Bearbeiten",
2626
"insert_image": "Bild einfügen",
27+
"insert_audio": "Audio einfügen",
2728
"delete": "Löschen",
2829
"delete_image": "Bild löschen",
29-
"remove": "Bildrefernz aus Text entfernen",
30+
"delete_audio": "Audio löschen",
31+
"remove": "Dateireferenz aus Text entfernen",
3032
"read": "Lesen",
3133
"create_post": "Post erstellen",
3234
"roles": "Rollen",
@@ -62,8 +64,8 @@
6264
"tags": "Schlagworte",
6365
"enter": "Geben Sie Schlagworte ein..."
6466
},
65-
"imageupload": "keine angehängten Bilder | ein angehängtes Bild | {count} angehängte Bilder",
66-
"imageupload_hint": "(Bild per Drag & Drop in den Text ziehen)"
67+
"fileupload": "keine angehängten Dateien | eine angehängte Datei | {count} angehängte Dateien",
68+
"fileupload_hint": "(Datei per Drag & Drop in den Text ziehen)"
6769
},
6870
"confirm": {
6971
"title": "Löschen bestätigen",

client/src/i18n/en.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
"cancel": "Cancel",
2525
"edit": "Edit",
2626
"insert_image": "Insert image",
27+
"insert_audio": "Insert audio",
2728
"delete_image": "Delete image",
28-
"remove": "Remove image reference from text",
29+
"delete_audio": "Delete audio",
30+
"remove": "Remove file reference from text",
2931
"read": "Read",
3032
"create_post": "Create Post",
3133
"roles": "Roles",
@@ -57,8 +59,8 @@
5759
"preview": {
5860
"title": "Preview"
5961
},
60-
"imageupload": "Upload image",
61-
"imageupload_hint": "(drag onto textarea to insert)",
62+
"fileupload": "Upload files",
63+
"fileupload_hint": "(drag onto textarea to insert)",
6264
"tags": "Tags"
6365
},
6466
"confirm": {

client/src/util/api-client.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ import type {
88
LoggedInUserInfo,
99
NewPostRequestDto,
1010
PublicPost,
11-
SupportedImageMimeType,
11+
SupportedFileMimeType,
1212
} from "@fumix/fu-blog-common";
1313
import { HttpHeader, imageBytesToDataUrl } from "@fumix/fu-blog-common";
1414

1515
export type ApiUrl = `/api/${string}`;
1616

1717
async function callServer<
1818
RequestType,
19-
ResponseMimeType extends SupportedImageMimeType | JsonMimeType,
20-
ResponseType = ResponseMimeType extends JsonMimeType ? any : ResponseMimeType extends SupportedImageMimeType ? ArrayBuffer : any,
19+
ResponseMimeType extends SupportedFileMimeType | JsonMimeType,
20+
ResponseType = ResponseMimeType extends JsonMimeType ? any : ResponseMimeType extends SupportedFileMimeType ? ArrayBuffer : any,
2121
>(
2222
url: ApiUrl,
2323
method: "GET" | "POST",
@@ -101,7 +101,7 @@ export class OpenAiEndpoints {
101101
return callServer<string, JsonMimeType, AiSummaryData>("/api/utility/chatGptSummarize", "POST", "application/json", { json: text });
102102
}
103103
static async dallEGenerateImage(prompt: string): Promise<DataUrl> {
104-
return callServer<string, SupportedImageMimeType, string>("/api/utility/dallEGenerateImage", "POST", "image/png", {
104+
return callServer<string, SupportedFileMimeType, string>("/api/utility/dallEGenerateImage", "POST", "image/png", {
105105
json: prompt,
106106
})
107107
.then((it) => {

client/src/views/PostFormView.vue

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,32 @@
4848

4949
<div class="form-floating mb-3 uploadCard">
5050
<h4>
51-
{{ tc("posts.form.imageupload", Object.keys(files).length) }}
51+
{{ tc("posts.form.fileupload", Object.keys(files).length) }}
5252
<small class="text-body-secondary f-4" v-if="Object.keys(files).length > 0">
5353
({{ convertToHumanReadableFileSize(totalBytesInFiles) }})
5454
</small>
5555

56-
<span class="tiny">{{ t("posts.form.imageupload_hint") }}</span>
56+
<span class="tiny">{{ t("posts.form.fileupload_hint") }}</span>
5757
</h4>
5858
<!-- Hidden file input, used to open the file dialog, when the dropzone is clicked -->
5959
<input style="display: none" type="file" id="file" multiple v-on:change="handleFileChange($event)"
60-
accept=".png, .gif, .jpg, .jpeg, .webp,image/png, image/jpeg, image/gif, image/webp" />
60+
accept=".png, .gif, .jpg, .jpeg, .webp, .mp3, .wav, image/png, image/jpeg, image/gif, image/webp, audio/mp3, audio/wav" />
6161

62-
<div class="imagesPreviewContaier" v-if="Object.keys(files).length">
62+
<div class="filesPreviewContainer" v-if="Object.keys(files).length">
6363
<div class="inner">
6464
<Suspense v-for="hash in Object.keys(files).reverse()" v-bind:key="hash">
65-
<ImagePreview :value="files[hash]" :hash="hash"
66-
@paste="pasteImageFileToMarkdown($event, 'afterCursor')" @delete="
67-
removeImageFileFromMarkdown(files[hash]);
65+
<ImagePreview v-if="isImageFile(files[hash])" :value="files[hash]" :hash="hash"
66+
@paste="pasteFileToMarkdown($event, 'afterCursor')" @delete="
67+
removeFileFromMarkdown(files[hash]);
6868
delete files[hash];
69-
" @softdelete="removeImageFileFromMarkdown(files[hash])">
69+
" @softdelete="removeFileFromMarkdown(files[hash])">
7070
</ImagePreview>
71+
<AudioPreview v-else-if="isAudioFile(files[hash])" :value="files[hash]" :hash="hash"
72+
@paste="pasteFileToMarkdown($event, 'afterCursor')" @delete="
73+
removeFileFromMarkdown(files[hash]);
74+
delete files[hash];
75+
" @softdelete="removeFileFromMarkdown(files[hash])">
76+
</AudioPreview>
7177
</Suspense>
7278
</div>
7379
</div>
@@ -100,7 +106,7 @@
100106
class="text-primary">
101107
<loading-spinner />
102108
</div>
103-
<mark-down :markdown="md" v-bind:custom-image-urls="files" @loading="loading = $event"
109+
<mark-down :markdown="md" v-bind:custom-file-urls="files" @loading="loading = $event"
104110
:style="loading ? 'opacity:0.2' : 'opacity:1'"></mark-down>
105111
</div>
106112
</div>
@@ -147,7 +153,7 @@
147153
background-color: #dee2e6;
148154
}
149155
150-
.imagesPreviewContaier {
156+
.filesPreviewContainer {
151157
width: 100%;
152158
max-width: 100%;
153159
min-width: 100%;
@@ -309,6 +315,7 @@
309315

310316
<script setup lang="ts">
311317
import AiSummaries from "@client/components/AiSummaries.vue";
318+
import AudioPreview from "@client/components/AudioPreview.vue";
312319
import ImagePreview from "@client/components/ImagePreview.vue";
313320
import LoadingSpinner from "@client/components/LoadingSpinner.vue";
314321
import MarkDown from "@client/components/MarkDown.vue";
@@ -410,6 +417,26 @@ const removeImageFileFromMarkdown = (file: File) => {
410417
}, 0);
411418
};
412419
420+
const pasteFileToMarkdown = (markdown: string, insertPosition: SupportedInsertPositionType = "afterCursor") => {
421+
form.markdown = insertIntoTextarea(markdown, markdownArea.value as unknown as HTMLTextAreaElement, insertPosition);
422+
};
423+
424+
const removeFileFromMarkdown = (file: File) => {
425+
const strToRemove = `![${file.name}](${Object.keys(files).find((key) => files[key] === file)})`.trim();
426+
setTimeout(() => {
427+
// give the preview time to update
428+
form.markdown = form.markdown.split(strToRemove).join("");
429+
}, 0);
430+
};
431+
432+
const isImageFile = (file: File): boolean => {
433+
return file.type.startsWith('image/');
434+
};
435+
436+
const isAudioFile = (file: File): boolean => {
437+
return file.type.startsWith('audio/');
438+
};
439+
413440
const openFileDialog = (): void => {
414441
document.getElementById("file")?.click();
415442
};

0 commit comments

Comments
 (0)