initial attachment improvements w/ preview on reload
deci committed
Jun 23, 2025 at 15:37 UTC
f85e0707f9b6e4ddbf128d313681604933fed07c
7 files changed
+381
-169
python/api/image_get.py
+76
-10
@@ -16,19 +16,85 @@ class ImageGet(ApiHandler):
16
if not files.is_in_base_dir(path):
17
raise ValueError("Path is outside of allowed directory")
18
19
- # check if file has an image extension
20
- # list of allowed image extensions
21
- allowed_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
19
# get file extension
20
file_ext = os.path.splitext(path)[1].lower()
24
- if file_ext not in allowed_extensions:
25
- raise ValueError(f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}")
21
27
- # check if file exists
28
- if not os.path.exists(path):
29
- raise ValueError("File not found")
22
+ # list of allowed image extensions
23
+ image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
24
+
25
+ if file_ext in image_extensions:
26
+ # Handle image files
27
+ if not os.path.exists(path):
28
+ # If image doesn't exist, return default image icon
29
+ return self._get_fallback_icon("image")
30
+
31
+ # send actual image file
32
+ return send_file(path)
33
+ else:
34
+ # Handle non-image files with fallback icons
35
+ return self._get_file_type_icon(file_ext)
36
+
37
+ def _get_file_type_icon(self, file_ext):
38
+ """Return appropriate icon for file type"""
39
+ # Map file extensions to icon names
40
+ icon_mapping = {
41
+ # Archive files
42
+ '.zip': 'archive',
43
+ '.rar': 'archive',
44
+ '.7z': 'archive',
45
+ '.tar': 'archive',
46
+ '.gz': 'archive',
47
+
48
+ # Document files
49
+ '.pdf': 'document',
50
+ '.doc': 'document',
51
+ '.docx': 'document',
52
+ '.txt': 'document',
53
+ '.rtf': 'document',
54
+ '.odt': 'document',
55
+
56
+ # Code files
57
+ '.py': 'code',
58
+ '.js': 'code',
59
+ '.html': 'code',
60
+ '.css': 'code',
61
+ '.json': 'code',
62
+ '.xml': 'code',
63
+ '.md': 'code',
64
+ '.yml': 'code',
65
+ '.yaml': 'code',
66
+ '.sql': 'code',
67
+ '.sh': 'code',
68
+ '.bat': 'code',
69
+
70
+ # Spreadsheet files
71
+ '.xls': 'document',
72
+ '.xlsx': 'document',
73
+ '.csv': 'document',
74
31
- # send file
32
- return send_file(path)
75
+ # Presentation files
76
+ '.ppt': 'document',
77
+ '.pptx': 'document',
78
+ '.odp': 'document',
79
+ }
80
+
81
+ # Get icon name, default to 'file' if not found
82
+ icon_name = icon_mapping.get(file_ext, 'file')
83
+ return self._get_fallback_icon(icon_name)
84
+
85
+ def _get_fallback_icon(self, icon_name):
86
+ """Return fallback icon from public directory"""
87
+ # Path to public icons
88
+ icon_path = files.get_abs_path(f"webui/public/{icon_name}.svg")
89
+
90
+ # Check if specific icon exists, fallback to generic file icon
91
+ if not os.path.exists(icon_path):
92
+ icon_path = files.get_abs_path("webui/public/file.svg")
93
+
94
+ # Final fallback if file.svg doesn't exist
95
+ if not os.path.exists(icon_path):
96
+ raise ValueError(f"Fallback icon not found: {icon_path}")
97
+
98
+ return send_file(icon_path, mimetype='image/svg+xml')
99
100
\ No newline at end of file
webui/components/chat/attachments/attachmentsStore.js
new
+195
@@ -0,0 +1,195 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+
3
+const model = {
4
+ // State properties
5
+ attachments: [],
6
+ hasAttachments: false,
7
+ dragDropOverlayVisible: false,
8
+
9
+ // Initialize the store
10
+ async initialize() {
11
+ // Setup event listeners for drag and drop
12
+ this.setupDragDropHandlers();
13
+ // Setup paste event listener for clipboard images
14
+ this.setupPasteHandler();
15
+ },
16
+
17
+ // Basic attachment management methods
18
+ addAttachment(attachment) {
19
+ // Validate for duplicates
20
+ if (this.validateDuplicates(attachment)) {
21
+ this.attachments.push(attachment);
22
+ this.updateAttachmentState();
23
+ }
24
+ },
25
+
26
+ removeAttachment(index) {
27
+ if (index >= 0 && index < this.attachments.length) {
28
+ this.attachments.splice(index, 1);
29
+ this.updateAttachmentState();
30
+ }
31
+ },
32
+
33
+ clearAttachments() {
34
+ this.attachments = [];
35
+ this.updateAttachmentState();
36
+ },
37
+
38
+ validateDuplicates(newAttachment) {
39
+ // Check if attachment already exists based on name and size
40
+ const isDuplicate = this.attachments.some(existing =>
41
+ existing.name === newAttachment.name &&
42
+ existing.file && newAttachment.file &&
43
+ existing.file.size === newAttachment.file.size
44
+ );
45
+ return !isDuplicate;
46
+ },
47
+
48
+ updateAttachmentState() {
49
+ this.hasAttachments = this.attachments.length > 0;
50
+ },
51
+
52
+ // Drag drop overlay control methods
53
+ showDragDropOverlay() {
54
+ this.dragDropOverlayVisible = true;
55
+ },
56
+
57
+ hideDragDropOverlay() {
58
+ this.dragDropOverlayVisible = false;
59
+ },
60
+
61
+ // Setup drag and drop event handlers
62
+ setupDragDropHandlers() {
63
+ let dragCounter = 0;
64
+
65
+ // Prevent default drag behaviors
66
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
67
+ document.addEventListener(eventName, (e) => {
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ }, false);
71
+ });
72
+
73
+ // Handle drag enter
74
+ document.addEventListener('dragenter', (e) => {
75
+ dragCounter++;
76
+ if (dragCounter === 1) {
77
+ this.showDragDropOverlay();
78
+ }
79
+ }, false);
80
+
81
+ // Handle drag leave
82
+ document.addEventListener('dragleave', (e) => {
83
+ dragCounter--;
84
+ if (dragCounter === 0) {
85
+ this.hideDragDropOverlay();
86
+ }
87
+ }, false);
88
+
89
+ // Handle drop
90
+ document.addEventListener('drop', (e) => {
91
+ dragCounter = 0;
92
+ this.hideDragDropOverlay();
93
+
94
+ const files = e.dataTransfer.files;
95
+ this.handleFiles(files);
96
+ }, false);
97
+ },
98
+
99
+ // Setup paste event handler for clipboard images
100
+ setupPasteHandler() {
101
+ document.addEventListener('paste', (e) => {
102
+ const items = e.clipboardData.items;
103
+ for (let i = 0; i < items.length; i++) {
104
+ const item = items[i];
105
+ if (item.type.indexOf('image') !== -1) {
106
+ const blob = item.getAsFile();
107
+ this.handleClipboardImage(blob);
108
+ }
109
+ }
110
+ });
111
+ },
112
+
113
+ // Handle clipboard image pasting
114
+ async handleClipboardImage(blob) {
115
+ // Generate unique filename
116
+ const guid = this.generateGUID();
117
+ const filename = `clipboard-${guid}.png`;
118
+
119
+ // Create file object from blob
120
+ const file = new File([blob], filename, { type: 'image/png' });
121
+
122
+ // Create attachment object
123
+ const attachment = {
124
+ file: file,
125
+ type: 'image',
126
+ name: filename,
127
+ extension: 'png'
128
+ };
129
+
130
+ // Read as data URL for preview
131
+ const reader = new FileReader();
132
+ reader.onload = (e) => {
133
+ attachment.url = e.target.result;
134
+ this.addAttachment(attachment);
135
+ };
136
+ reader.readAsDataURL(file);
137
+ },
138
+
139
+ // File handling logic (moved from index.js)
140
+ handleFiles(files) {
141
+ Array.from(files).forEach(file => {
142
+ const ext = file.name.split('.').pop().toLowerCase();
143
+ const isImage = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp'].includes(ext);
144
+
145
+ const attachment = {
146
+ file: file,
147
+ type: isImage ? 'image' : 'file',
148
+ name: file.name,
149
+ extension: ext
150
+ };
151
+
152
+ if (isImage) {
153
+ // Read image as data URL for preview
154
+ const reader = new FileReader();
155
+ reader.onload = (e) => {
156
+ attachment.url = e.target.result;
157
+ this.addAttachment(attachment);
158
+ };
159
+ reader.readAsDataURL(file);
160
+ } else {
161
+ // For non-image files, add directly
162
+ this.addAttachment(attachment);
163
+ }
164
+ });
165
+ },
166
+
167
+ // Get attachments for sending message
168
+ getAttachmentsForSending() {
169
+ return this.attachments.map(attachment => {
170
+ if (attachment.type === 'image') {
171
+ return {
172
+ ...attachment,
173
+ url: URL.createObjectURL(attachment.file)
174
+ };
175
+ } else {
176
+ return {
177
+ ...attachment
178
+ };
179
+ }
180
+ });
181
+ },
182
+
183
+ // Generate GUID for unique filenames
184
+ generateGUID() {
185
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
186
+ const r = Math.random() * 16 | 0;
187
+ const v = c == 'x' ? r : (r & 0x3 | 0x8);
188
+ return v.toString(16);
189
+ });
190
+ }
191
+};
192
+
193
+const store = createStore("chatAttachments", model);
194
+
195
+export { store };
\ No newline at end of file
webui/components/chat/attachments/dragDropOverlay.html
new
+29
@@ -0,0 +1,29 @@
1
+<html>
2
+
3
+<head>
4
+ <title>Drag Drop Overlay</title>
5
+
6
+ <script type="module">
7
+ import { store } from "/components/chat/attachments/attachmentsStore.js";
8
+ </script>
9
+</head>
10
+
11
+<body>
12
+ <!-- Drag and Drop Overlay -->
13
+ <div id="dragdrop-overlay"
14
+ x-cloak
15
+ x-show="$store.chatAttachments.dragDropOverlayVisible"
16
+ x-transition:enter="transition ease-out duration-300"
17
+ x-transition:enter-start="opacity-0"
18
+ x-transition:enter-end="opacity-100"
19
+ x-transition:leave="transition ease-in duration-300"
20
+ x-transition:leave-start="opacity-100"
21
+ x-transition:leave-end="opacity-0"
22
+ class="dragdrop-overlay">
23
+ <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
24
+ <div class="dragdrop-text">Drop files to attach them to your message</div>
25
+ <div class="dragdrop-subtext"></div>
26
+ </div>
27
+</body>
28
+
29
+</html>
\ No newline at end of file
webui/index.html
+8
-58
@@ -101,6 +101,7 @@
101
<script type="module" src="js/scheduler.js"></script>
102
<script type="module" src="js/speech.js"></script>
103
<script type="module" src="js/history.js"></script>
104
+ <script type="module" src="components/chat/attachments/attachmentsStore.js"></script>
105
<script type="module" src="index.js"></script>
106
107
<!-- Then load Alpine.js -->
@@ -370,49 +371,12 @@
371
</h4>
372
</div>
373
<div id="input-section" x-data="{
373
- paused: false,
374
- attachments: [],
375
- hasAttachments: false,
376
-
377
- handleFileUpload(event) {
378
- const files = event.target.files;
379
-
380
- Array.from(files).forEach(file => {
381
- const ext = file.name.split('.').pop().toLowerCase();
382
-
383
- const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
384
-
385
- if (isImage) {
386
- // Handle image preview
387
- const reader = new FileReader();
388
- reader.onload = e => {
389
- this.attachments.push({
390
- file: file,
391
- url: e.target.result,
392
- type: 'image',
393
- name: file.name,
394
- extension: ext
395
- });
396
- this.hasAttachments = true;
397
- };
398
- reader.readAsDataURL(file);
399
- } else {
400
- // Handle other file types
401
- this.attachments.push({
402
- file: file,
403
- type: 'file',
404
- name: file.name,
405
- extension: ext
406
- });
407
- this.hasAttachments = true;
408
- }
409
- });
410
- }
374
+ paused: false
375
}">
376
377
<!-- Preview section -->
414
- <div x-show="hasAttachments" class="preview-section">
415
- <template x-for="(attachment, index) in attachments" :key="index">
378
+ <div x-show="$store.chatAttachments.hasAttachments" class="preview-section">
379
+ <template x-for="(attachment, index) in $store.chatAttachments.attachments" :key="index">
380
<div class="preview-item" :class="{'image-preview': attachment.type === 'image'}">
381
<template x-if="attachment.type === 'image'">
382
<img :src="attachment.url" :alt="attachment.name">
@@ -423,7 +387,7 @@
387
<span class="extension" x-text="attachment.extension.toUpperCase()"></span>
388
</div>
389
</template>
426
- <button @click="attachments.splice(index, 1); hasAttachments = attachments.length > 0"
390
+ <button @click="$store.chatAttachments.removeAttachment(index)"
391
class="remove-attachment">×</button>
392
</div>
393
</template>
@@ -441,7 +405,7 @@
405
d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
406
</svg>
407
</label>
444
- <input type="file" id="file-input" accept="*" multiple style="display: none" @change="handleFileUpload($event)">
408
+ <input type="file" id="file-input" accept="*" multiple style="display: none" @change="window.handleFileUpload($event)">
409
410
<div x-show="showTooltip" class="tooltip">
411
Add attachments to the message
@@ -1680,22 +1644,8 @@
1644
</template>
1645
</div>
1646
1683
- <!-- Drag and Drop Overlay -->
1684
- <div id="dragdrop-overlay"
1685
- x-cloak
1686
- x-data="{ isVisible: false }"
1687
- x-show="isVisible"
1688
- x-transition:enter="transition ease-out duration-300"
1689
- x-transition:enter-start="opacity-0"
1690
- x-transition:enter-end="opacity-100"
1691
- x-transition:leave="transition ease-in duration-300"
1692
- x-transition:leave-start="opacity-100"
1693
- x-transition:leave-end="opacity-0"
1694
- class="dragdrop-overlay">
1695
- <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
1696
- <div class="dragdrop-text">Drop files to attach them to your message</div>
1697
- <div class="dragdrop-subtext"></div>
1698
- </div>
1647
+ <!-- Drag and Drop Overlay Component -->
1648
+ <x-component path="chat/attachments/dragDropOverlay.html"></x-component>
1649
1650
</body>
1651
webui/index.js
+12
-92
@@ -84,9 +84,9 @@ document.addEventListener('DOMContentLoaded', setupSidebarToggle);
84
export async function sendMessage() {
85
try {
86
const message = chatInput.value.trim();
87
- const inputAD = Alpine.$data(inputSection);
88
- const attachments = inputAD.attachments;
89
- const hasAttachments = attachments && attachments.length > 0;
87
+ const attachmentsStore = Alpine.store('chatAttachments');
88
+ const attachments = attachmentsStore ? attachmentsStore.attachments : [];
89
+ const hasAttachments = attachmentsStore ? attachmentsStore.hasAttachments : false;
90
91
if (message || hasAttachments) {
92
let response;
@@ -94,18 +94,7 @@ export async function sendMessage() {
94
95
// Include attachments in the user message
96
if (hasAttachments) {
97
- const attachmentsWithUrls = attachments.map(attachment => {
98
- if (attachment.type === 'image') {
99
- return {
100
- ...attachment,
101
- url: URL.createObjectURL(attachment.file)
102
- };
103
- } else {
104
- return {
105
- ...attachment
106
- };
107
- }
108
- });
97
+ const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
98
99
// Render user message with attachments
100
setMessage(messageId, 'user', '', message, false, {
@@ -159,8 +148,9 @@ export async function sendMessage() {
148
149
// Clear input and attachments
150
chatInput.value = '';
162
- inputAD.attachments = [];
163
- inputAD.hasAttachments = false;
151
+ if (attachmentsStore) {
152
+ attachmentsStore.clearAttachments();
153
+ }
154
adjustTextareaHeight();
155
}
156
} catch (e) {
@@ -1079,84 +1069,14 @@ async function startPolling() {
1069
1070
document.addEventListener("DOMContentLoaded", startPolling);
1071
1082
-document.addEventListener('DOMContentLoaded', () => {
1083
- const dragDropOverlay = document.getElementById('dragdrop-overlay');
1084
- const inputSection = document.getElementById('input-section');
1085
- let dragCounter = 0;
1086
-
1087
- // Prevent default drag behaviors
1088
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
1089
- document.addEventListener(eventName, (e) => {
1090
- e.preventDefault();
1091
- e.stopPropagation();
1092
- }, false);
1093
- });
1094
-
1095
- // Handle drag enter
1096
- document.addEventListener('dragenter', (e) => {
1097
- dragCounter++;
1098
- if (dragCounter === 1) {
1099
- Alpine.$data(dragDropOverlay).isVisible = true;
1100
- }
1101
- }, false);
1102
-
1103
- // Handle drag leave
1104
- document.addEventListener('dragleave', (e) => {
1105
- dragCounter--;
1106
- if (dragCounter === 0) {
1107
- Alpine.$data(dragDropOverlay).isVisible = false;
1108
- }
1109
- }, false);
1110
-
1111
- // Handle drop
1112
- dragDropOverlay.addEventListener('drop', (e) => {
1113
- dragCounter = 0;
1114
- Alpine.$data(dragDropOverlay).isVisible = false;
1072
+// Drag and drop functionality has been moved to attachmentsStore.js
1073
1116
- const inputAD = Alpine.$data(inputSection);
1117
- const files = e.dataTransfer.files;
1118
- handleFiles(files, inputAD);
1119
- }, false);
1120
-});
1121
-
1122
-// Separate file handling logic to be used by both drag-drop and file input
1123
-function handleFiles(files, inputAD) {
1124
- Array.from(files).forEach(file => {
1125
- const ext = file.name.split('.').pop().toLowerCase();
1126
-
1127
- const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
1128
-
1129
- if (isImage) {
1130
- const reader = new FileReader();
1131
- reader.onload = e => {
1132
- inputAD.attachments.push({
1133
- file: file,
1134
- url: e.target.result,
1135
- type: 'image',
1136
- name: file.name,
1137
- extension: ext
1138
- });
1139
- inputAD.hasAttachments = true;
1140
- };
1141
- reader.readAsDataURL(file);
1142
- } else {
1143
- inputAD.attachments.push({
1144
- file: file,
1145
- type: 'file',
1146
- name: file.name,
1147
- extension: ext
1148
- });
1149
- inputAD.hasAttachments = true;
1150
- }
1151
-
1152
- });
1153
-}
1154
-
1155
-// Modify the existing handleFileUpload to use the new handleFiles function
1074
+// Update handleFileUpload to use the attachments store
1075
window.handleFileUpload = function(event) {
1076
const files = event.target.files;
1158
- const inputAD = Alpine.$data(inputSection);
1159
- handleFiles(files, inputAD);
1077
+ if (Alpine.store('chatAttachments')) {
1078
+ Alpine.store('chatAttachments').handleFiles(files);
1079
+ }
1080
}
1081
1082
// Setup event handlers once the DOM is fully loaded
webui/js/initFw.js
+12
@@ -3,6 +3,9 @@ import * as _components from "./components.js";
3
4
await import("./alpine.min.js");
5
6
+// Import attachments store
7
+import { store as attachmentsStore } from "../components/chat/attachments/attachmentsStore.js";
8
+
9
// add x-destroy directive
10
Alpine.directive(
11
"destroy",
@@ -11,3 +14,12 @@ Alpine.directive(
14
cleanup(() => onDestroy());
15
}
16
);
17
+
18
+// Initialize attachments store when Alpine.js is ready
19
+document.addEventListener('alpine:init', () => {
20
+ // Store is already created via createStore in attachmentsStore.js
21
+ // Just call initialize to set up event handlers
22
+ if (Alpine.store('chatAttachments')) {
23
+ Alpine.store('chatAttachments').initialize();
24
+ }
25
+});
webui/js/messages.js
+49
-9
@@ -318,25 +318,65 @@ export function drawMessageUser(
318
const attachmentDiv = document.createElement("div");
319
attachmentDiv.classList.add("attachment-item");
320
321
+ // Helper function to generate server-side image URL
322
+ const getServerImageUrl = (filename) => {
323
+ return `/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}`;
324
+ };
325
+
326
+ // Helper function to check if file is an image
327
+ const isImageFile = (filename) => {
328
+ const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
329
+ const extension = filename.split('.').pop().toLowerCase();
330
+ return imageExtensions.includes(extension);
331
+ };
332
+
333
if (typeof attachment === "string") {
322
- // attachment is filename
334
+ // attachment is filename only (from persistent storage)
335
const filename = attachment;
336
const extension = filename.split(".").pop().toUpperCase();
337
326
- attachmentDiv.classList.add("file-type");
327
- attachmentDiv.innerHTML = `
328
- <div class="file-preview">
338
+ if (isImageFile(filename)) {
339
+ // Render as image with server URL
340
+ const imgWrapper = document.createElement("div");
341
+ imgWrapper.classList.add("image-wrapper");
342
+
343
+ const img = document.createElement("img");
344
+ img.src = getServerImageUrl(filename);
345
+ img.alt = filename;
346
+ img.classList.add("attachment-preview");
347
+
348
+ const fileInfo = document.createElement("div");
349
+ fileInfo.classList.add("file-info");
350
+ fileInfo.innerHTML = `
351
<span class="filename">${filename}</span>
352
<span class="extension">${extension}</span>
331
- </div>
332
- `;
353
+ `;
354
+
355
+ imgWrapper.appendChild(img);
356
+ attachmentDiv.appendChild(imgWrapper);
357
+ attachmentDiv.appendChild(fileInfo);
358
+ } else {
359
+ // Render as file icon
360
+ attachmentDiv.classList.add("file-type");
361
+ attachmentDiv.innerHTML = `
362
+ <div class="file-preview">
363
+ <span class="filename">${filename}</span>
364
+ <span class="extension">${extension}</span>
365
+ </div>
366
+ `;
367
+ }
368
} else if (attachment.type === "image") {
334
- // Existing logic for images
369
+ // attachment is object (from current session)
370
const imgWrapper = document.createElement("div");
371
imgWrapper.classList.add("image-wrapper");
372
373
const img = document.createElement("img");
339
- img.src = attachment.url;
374
+ // Use server URL if we have filename, otherwise fall back to blob URL for current session
375
+ if (attachment.name && !attachment.url.startsWith('blob:')) {
376
+ img.src = getServerImageUrl(attachment.name);
377
+ } else {
378
+ img.src = attachment.url;
379
+ }
380
img.alt = attachment.name;
381
img.classList.add("attachment-preview");
382
@@ -351,7 +391,7 @@ export function drawMessageUser(
391
attachmentDiv.appendChild(imgWrapper);
392
attachmentDiv.appendChild(fileInfo);
393
} else {
354
- // Existing logic for non-image files
394
+ // attachment is object but not image (from current session)
395
attachmentDiv.classList.add("file-type");
396
attachmentDiv.innerHTML = `
397
<div class="file-preview">