optimized file upload

This commit is contained in:
marvzhang
2021-07-20 22:27:00 +08:00
parent e05632973d
commit b7a69dcb30
19 changed files with 405 additions and 58 deletions

View File

@@ -37,7 +37,7 @@ RUN chmod 777 /tmp \
&& ln -s /usr/bin/python3 /usr/local/bin/python
# install seaweedfs
RUN wget https://github.com/chrislusf/seaweedfs/releases/download/2.48/linux_amd64.tar.gz \
RUN wget https://github.com/chrislusf/seaweedfs/releases/download/2.59/linux_amd64.tar.gz \
&& tar -zxf linux_amd64.tar.gz \
&& cp weed /usr/local/bin
@@ -46,9 +46,6 @@ RUN pip install scrapy pymongo bs4 requests crawlab-sdk
# add files
COPY ./backend/conf /app/backend/conf
#COPY ./backend/data /app/backend/data
#COPY ./backend/scripts /app/backend/scripts
#COPY ./backend/template /app/backend/template
COPY ./nginx /app/nginx
COPY ./docker_init.sh /app/docker_init.sh

View File

@@ -37,15 +37,16 @@ RUN chmod 777 /tmp \
&& ln -s /usr/bin/pip3 /usr/local/bin/pip \
&& ln -s /usr/bin/python3 /usr/local/bin/python
# install seaweedfs
RUN wget https://github.com/chrislusf/seaweedfs/releases/download/2.59/linux_amd64.tar.gz \
&& tar -zxf linux_amd64.tar.gz \
&& cp weed /usr/local/bin
# install backend
RUN pip install scrapy pymongo bs4 requests crawlab-sdk
# add files
COPY ./backend/conf /app/backend/conf
#COPY ./backend/data /app/backend/data
#COPY ./backend/scripts /app/backend/scripts
#COPY ./backend/template /app/backend/template
COPY ./nginx /app/nginx
COPY ./docker_init.sh /app/docker_init.sh

View File

@@ -7,7 +7,6 @@ import (
"github.com/crawlab-team/crawlab-core/interfaces"
"github.com/crawlab-team/crawlab-core/middlewares"
"github.com/crawlab-team/crawlab-core/routes"
"github.com/crawlab-team/crawlab-db/mongo"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"net"
@@ -25,9 +24,6 @@ type Api struct {
}
func (app *Api) Init() {
// initialize mongo
_ = initModule("mongo", mongo.InitMongo)
// initialize controllers
_ = initModule("controllers", controllers.InitControllers)

View File

@@ -2,8 +2,8 @@ package cmd
import (
"fmt"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -33,20 +33,23 @@ func init() {
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := homedir.Dir()
cobra.CheckErr(err)
// Search config in home directory with name ".cobra" (without extension).
viper.AddConfigPath(home)
viper.SetConfigName(".cobra")
viper.AddConfigPath("./conf")
viper.SetConfigName("config")
}
// file format as yaml
viper.SetConfigType("yaml")
// auto load env
viper.AutomaticEnv()
// env prefix as CRAWLAB
viper.SetEnvPrefix("CRAWLAB")
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
if err := viper.ReadInConfig(); err == nil {
fmt.Println("Using config file:", viper.ConfigFileUsed())
}

View File

@@ -2,13 +2,17 @@ module crawlab
go 1.15
replace (
github.com/crawlab-team/crawlab-core => /Users/marvzhang/projects/crawlab-team/crawlab-core
github.com/crawlab-team/goseaweedfs => /Users/marvzhang/projects/crawlab-team/goseaweedfs
)
require (
github.com/apex/log v1.9.0
github.com/crawlab-team/crawlab-core v0.6.0-beta.20210716
github.com/crawlab-team/crawlab-db v0.1.0
github.com/crawlab-team/crawlab-core v0.6.0-beta.20210716.1817
github.com/crawlab-team/go-trace v0.1.0
github.com/crawlab-team/goseaweedfs v0.2.0
github.com/gin-gonic/gin v1.6.3
github.com/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v1.1.3
github.com/spf13/viper v1.7.1
go.mongodb.org/mongo-driver v1.6.0 // indirect

View File

@@ -29,6 +29,7 @@ github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSi
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -70,11 +71,11 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/crawlab-team/crawlab-core v0.0.1/go.mod h1:6dJHMvrmIJbfYHhYNeGZkGOLEBvur+yGiFzLCRXx92k=
github.com/crawlab-team/crawlab-core v0.6.0-beta.20210716 h1:9P/XryMK4yyIcxLpeI2f0RNqDw3lF6HjgH5DUzywxUk=
github.com/crawlab-team/crawlab-core v0.6.0-beta.20210716/go.mod h1:OXO0hN3YwKN0hnJoa6bJ/DGWZjE4XzVAv+pfmCzOmds=
github.com/crawlab-team/crawlab-core v0.6.0-beta.20210716.1817 h1:YWLhmiDxvDEFjk+n1D5eJUv4I7bGbqb4nvu/zgXRhB0=
github.com/crawlab-team/crawlab-core v0.6.0-beta.20210716.1817/go.mod h1:okTuM1EtQNk6Rl81GSW3rS5U6q4GbzVc3FsNGlvc7r8=
github.com/crawlab-team/crawlab-db v0.0.2/go.mod h1:o7o4rbcyAWlFGHg9VS7V7tM/GqRq+N2mnAXO71cZA78=
github.com/crawlab-team/crawlab-db v0.1.0 h1:6dTVNb5+7cDkH8fkKOkFALk8laWNOuorYm3ZEKsvFLI=
github.com/crawlab-team/crawlab-db v0.1.0/go.mod h1:t0VidSjXKzQgACqNSQV5wusXncFtL6lGEiQTbLfNR04=
github.com/crawlab-team/crawlab-db v0.1.1 h1:156h2fbbFKXAHs1mxprqRFC8zs2nrdyaG9JKG7patVw=
github.com/crawlab-team/crawlab-db v0.1.1/go.mod h1:t0VidSjXKzQgACqNSQV5wusXncFtL6lGEiQTbLfNR04=
github.com/crawlab-team/crawlab-fs v0.0.0/go.mod h1:k2VXprQspLAmbgO5sSpqMjg/xP4iKDkW4RyTWY8eTZM=
github.com/crawlab-team/crawlab-fs v0.1.0 h1:iKSJJY4Wvea8Qss+zC/tLiZ371VeV75Z3cuqlsxydzY=
github.com/crawlab-team/crawlab-fs v0.1.0/go.mod h1:dOE0TeWPDz9krwzt1H72rjj0Fn/aHe53yn7GoOZHD0s=

View File

@@ -4,7 +4,8 @@ services:
image: crawlabteam/crawlab:latest
container_name: crawlab_master
environment:
CRAWLAB_MONGO_HOST: 'mongo'
CRAWLAB_SERVER_MASTER: Y
CRAWLAB_MONGO_HOST: mongo
ports:
- "8080:8080" # frontend port mapping 前端端口映射
depends_on:

View File

@@ -51,4 +51,8 @@ Host *
EOF
# start backend
crawlab-server
if [ "${CRAWLAB_SERVER_MASTER}" = "Y" ];
crawlab-server master
then
crawlab-server worker
fi

View File

@@ -1,5 +1,8 @@
<template>
<div :style="style" class="pie-chart">
<div v-if="isEmpty" class="empty-placeholder">
No Data Available
</div>
<div ref="elRef" class="echarts-element"></div>
</div>
</template>
@@ -46,8 +49,16 @@ export default defineComponent({
const elRef = ref<HTMLDivElement>();
const chart = ref<ECharts>();
const isEmpty = computed<boolean>(() => {
const {config} = props;
const {data} = config;
if (!data) return true;
return data.length === 0;
});
const getSeriesData = (data: StatsResult[], key?: string) => {
const {valueKey, labelKey, config} = props;
const {valueKey, labelKey} = props;
const _valueKey = !key ? valueKey : key;
if (_valueKey) {
@@ -69,7 +80,7 @@ export default defineComponent({
const seriesItem = {
type: 'pie',
data: getSeriesData(data),
data: getSeriesData(data || []),
radius: ['40%', '70%'],
alignTo: 'labelLine',
} as EChartSeries;
@@ -119,6 +130,7 @@ export default defineComponent({
});
return {
isEmpty,
style,
elRef,
render,
@@ -128,7 +140,22 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import "../../styles/variables";
.pie-chart {
position: relative;
.empty-placeholder {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.echarts-element {
width: 100%;
height: 100%;

View File

@@ -0,0 +1,175 @@
<template>
<div class="file-upload">
<div class="mode-select">
<el-radio-group v-model="internalMode" @change="onModeChange">
<el-radio
v-for="{value, label} in modeOptions"
:key="value"
:label="value"
>
{{ label }}
</el-radio>
</el-radio-group>
</div>
<template v-if="mode === FILE_UPLOAD_MODE_FILES">
<el-upload
ref="uploadRef"
:on-change="onFileChange"
:http-request="() => {}"
drag
multiple
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">Drag files here, or <em>click to upload</em></div>
</el-upload>
<input v-bind="getInputProps()">
</template>
<template v-else-if="mode === FILE_UPLOAD_MODE_DIR">
<div class="folder-upload">
<Button @click="open">
<i class="fa fa-folder"></i>
Click to Select Folder to Upload
</Button>
<template v-if="!!dirInfo?.dirName && dirInfo?.fileCount">
<Tag
type="primary"
class="info-tag"
:label="dirInfo?.dirName"
:icon="['fa', 'folder']"
tooltip="Folder Name"
/>
<Tag
type="success"
class="info-tag"
:label="dirInfo?.fileCount"
:icon="['fa', 'hashtag']"
tooltip="Files Count"
/>
</template>
</div>
<input v-bind="getInputProps()" webkitdirectory>
</template>
</div>
</template>
<script lang="ts">
import {defineComponent, onBeforeMount, ref, watch} from 'vue';
import {FILE_UPLOAD_MODE_DIR, FILE_UPLOAD_MODE_FILES} from '@/constants/file';
import {ElUpload} from 'element-plus/lib/el-upload/src/upload.type';
import {UploadFile} from 'element-plus/packages/upload/src/upload.type';
import Button from '@/components/button/Button.vue';
import Tag from '@/components/tag/Tag.vue';
import {plainClone} from '@/utils/object';
export default defineComponent({
name: 'FileUpload',
components: {
Tag,
Button,
},
props: {
mode: {
type: String,
},
getInputProps: {
type: Function,
},
open: {
type: Function,
},
},
emits: [
'mode-change',
'files-change',
],
setup(props: FileUploadProps, {emit}) {
const modeOptions: FileUploadModeOption[] = [
{
label: 'Files',
value: FILE_UPLOAD_MODE_FILES,
},
{
label: 'Folder',
value: FILE_UPLOAD_MODE_DIR,
},
];
const internalMode = ref<string>();
const uploadRef = ref<ElUpload>();
const dirPath = ref<string>();
watch(() => props.mode, () => {
internalMode.value = props.mode;
uploadRef.value?.clearFiles();
});
const onFileChange = (file: UploadFile, fileList: UploadFile[]) => {
emit('files-change', fileList.map(f => f.raw));
};
const clearFiles = () => {
uploadRef.value?.clearFiles();
};
const onModeChange = (mode: string) => {
emit('mode-change', mode);
};
onBeforeMount(() => {
const {mode} = props;
internalMode.value = mode;
});
const dirInfo = ref<FileUploadDirInfo>();
const setDirInfo = (info: FileUploadDirInfo) => {
console.debug(info);
dirInfo.value = plainClone(info);
};
return {
uploadRef,
FILE_UPLOAD_MODE_FILES,
FILE_UPLOAD_MODE_DIR,
modeOptions,
internalMode,
dirPath,
onFileChange,
clearFiles,
onModeChange,
dirInfo,
setDirInfo,
};
},
});
</script>
<style scoped lang="scss">
.file-upload {
.mode-select {
margin-bottom: 20px;
}
.el-upload {
width: 100%;
}
.folder-upload {
display: flex;
align-items: center;
}
}
</style>
<style scoped>
.file-upload >>> .el-upload,
.file-upload >>> .el-upload .el-upload-dragger {
width: 100%;
}
.file-upload >>> .folder-upload .info-tag {
margin-left: 10px;
}
</style>

View File

@@ -15,8 +15,16 @@
<!-- ./Input -->
<!-- Button -->
<Button v-if="buttonLabel" :disabled="disabled" :size="size" :type="buttonType" class="button" no-margin>
<Icon v-if="buttonIcon" :icon="buttonIcon"/>
<Button
v-if="buttonLabel"
:disabled="disabled"
:size="size"
:type="buttonType"
class="button"
no-margin
@click="onClick"
>
<Icon v-if="buttonIcon" :icon="buttonIcon" />
{{ buttonLabel }}
</Button>
<template v-else-if="buttonIcon">
@@ -27,6 +35,7 @@
:size="size"
:type="buttonType"
class="button"
@click="onClick"
/>
<IconButton
v-else
@@ -35,6 +44,7 @@
:size="size"
:type="buttonType"
class="button"
@click="onClick"
/>
</template>
<!-- ./Button -->

View File

@@ -1 +1,4 @@
export const FILE_ROOT = '~';
export const FILE_UPLOAD_MODE_FILES = 'files';
export const FILE_UPLOAD_MODE_DIR = 'dir';

View File

@@ -0,0 +1,15 @@
interface FileUploadProps {
mode?: string;
getInputProps?: Function;
open?: Function;
}
interface FileUploadModeOption {
label: string;
value: string;
}
interface FileUploadDirInfo {
dirName: string;
fileCount: number;
}

View File

@@ -1,4 +1,4 @@
import {createRouter, createWebHistory, RouteRecordRaw} from 'vue-router';
import {createRouter, createWebHashHistory, RouteRecordRaw} from 'vue-router';
import login from '@/router/login';
import home from '@/router/home';
import node from '@/router/node';
@@ -44,7 +44,7 @@ export const menuItems: MenuItem[] = [
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
history: createWebHashHistory(process.env.BASE_URL),
routes,
});

View File

@@ -1,8 +1,27 @@
import axios, {AxiosRequestConfig} from 'axios';
import {ElMessageBox} from 'element-plus';
import router from '@/router';
// TODO: request interception
// TODO: response interception
let msgBoxVisible = false;
axios.interceptors.response.use(res => {
return res;
}, err => {
const status = err?.response?.status;
if (status === 401) {
if (msgBoxVisible) return;
msgBoxVisible = true;
ElMessageBox.confirm('You seem to have been logged-out, try to login again?', 'Unauthorized', {type: 'warning'})
.then(_ => router.push('/login'))
.finally(() => {
msgBoxVisible = false;
});
} else {
console.error(err);
}
});
const useRequest = () => {
// implementation

View File

@@ -228,7 +228,7 @@ export default defineComponent({
// TODO: filter by date range?
const {start, end} = dateRange.value;
const res = await get(`/stats/daily`);
dailyConfig.value.data = spanDateRange(start, end, res.data, 'date');
dailyConfig.value.data = spanDateRange(start, end, res.data || [], 'date');
};
const getTasks = async () => {

View File

@@ -94,12 +94,13 @@
<img alt="github-stars" src="https://img.shields.io/github/stars/crawlab-team/crawlab?logo=github">
</a>
</div>
<div class="lang">
<!-- TODO: implement -->
<div v-if="false" class="lang">
<span :class="lang==='zh'?'active':''" @click="setLang('zh')">中文</span>
|
<span :class="lang==='en'?'active':''" @click="setLang('en')">English</span>
</div>
<div class="documentation">
<div v-if="false" class="documentation">
<a href="https://docs.crawlab.cn" target="_blank">{{ $t('Documentation') }}</a>
</div>
<div class="mobile-warning" v-if="isShowMobileWarning">
@@ -129,8 +130,6 @@ const {
export default defineComponent({
name: 'Login',
setup() {
const {tm} = useI18n();
const route = useRoute();
const router = useRouter();
@@ -150,7 +149,7 @@ export default defineComponent({
const validateUsername = (rule: any, value: any, callback: any) => {
if (!isValidUsername(value)) {
callback(new Error(tm('Please enter the correct username')));
callback(new Error('Please enter the correct username'));
} else {
callback();
}
@@ -158,7 +157,7 @@ export default defineComponent({
const validatePass = (rule: any, value: any, callback: any) => {
if (value.length < 5) {
callback(new Error(tm('Password length should be no shorter than 5')));
callback(new Error('Password length should be no shorter than 5'));
} else {
callback();
}
@@ -167,7 +166,7 @@ export default defineComponent({
const validateConfirmPass = (rule: any, value: any, callback: any) => {
if (!isSignup.value) return callback();
if (value !== loginForm.value.password) {
callback(new Error(tm('Two passwords must be the same')));
callback(new Error('Two passwords must be the same'));
} else {
callback();
}

View File

@@ -11,7 +11,8 @@
<FaIconButton :icon="['far', 'star']" plain tooltip="Favorite" type="warning"/>
</NavActionItem>
</NavActionGroup>
<NavActionGroup>
<!--TODO: implement-->
<NavActionGroup v-if="false">
<NavActionFaIcon :icon="['fab', 'git-alt']"/>
<NavActionItem>
<FaIconButton :icon="['fa', 'upload']" tooltip="Upload File" type="primary"/>

View File

@@ -1,16 +1,33 @@
<template>
<NavActionGroup>
<NavActionFaIcon :icon="['fa', 'laptop-code']" tooltip="File Editor Actions"/>
<NavActionFaIcon :icon="['fa', 'laptop-code']" tooltip="File Editor Actions" />
<NavActionItem>
<FaIconButton :icon="['fa', 'upload']" tooltip="Upload Files" type="primary" @click="onOpenFiles"/>
<input v-bind="getInputProps()">
<FaIconButton :icon="['fa', 'cog']" tooltip="File Editor Settings" type="info" @click="onOpenFilesSettings"/>
<FaIconButton :icon="['fa', 'upload']" tooltip="Upload Files" type="primary" @click="onClickUpload" />
<FaIconButton :icon="['fa', 'cog']" tooltip="File Editor Settings" type="info" @click="onOpenFilesSettings" />
</NavActionItem>
</NavActionGroup>
<Dialog
:visible="fileUploadVisible"
title="Files Upload"
:confirm-loading="confirmLoading"
:confirm-disabled="confirmDisabled"
@close="onUploadClose"
@confirm="onUploadConfirm"
>
<FileUpload
ref="fileUploadRef"
:mode="mode"
:get-input-props="getInputProps"
:open="open"
@mode-change="onModeChange"
@files-change="onFilesChange"
/>
</Dialog>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
import {computed, defineComponent, ref} from 'vue';
import {useStore} from 'vuex';
import NavActionGroup from '@/components/nav/NavActionGroup.vue';
import NavActionItem from '@/components/nav/NavActionItem.vue';
@@ -19,10 +36,16 @@ import NavActionFaIcon from '@/components/nav/NavActionFaIcon.vue';
import {useDropzone} from 'vue3-dropzone';
import useSpiderService from '@/services/spider/spiderService';
import {useRoute} from 'vue-router';
import FileUpload from '@/components/file/FileUpload.vue';
import Dialog from '@/components/dialog/Dialog.vue';
import {ElMessage} from 'element-plus';
import {FILE_UPLOAD_MODE_DIR, FILE_UPLOAD_MODE_FILES} from '@/constants/file';
export default defineComponent({
name: 'SpiderDetailActionsFiles',
components: {
Dialog,
FileUpload,
NavActionFaIcon,
FaIconButton,
NavActionGroup,
@@ -36,33 +59,101 @@ export default defineComponent({
const storeNamespace = 'file';
const store = useStore();
const id = computed<string>(() => route.params.id as string);
const {
listRootDir,
saveFileBinary,
} = useSpiderService(store);
const mode = ref<string>(FILE_UPLOAD_MODE_FILES);
const files = ref<File[]>();
const id = computed<string>(() => route.params.id as string);
const fileUploadRef = ref<typeof FileUpload>();
const confirmLoading = ref<boolean>(false);
const confirmDisabled = computed<boolean>(() => !files.value?.length);
const onOpenFilesSettings = () => {
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, true);
};
const uploadFiles = async () => {
if (!files.value) return;
await Promise.all(files.value.map(f => {
return saveFileBinary(id.value, f.name, f as File);
}));
await listRootDir(id.value);
};
const {
getInputProps,
open: onOpenFiles,
open,
} = useDropzone({
onDrop: async (files: InputFile[]) => {
await Promise.all(files.map(f => {
return saveFileBinary(id.value, f.path as string, f as File);
}));
await listRootDir(id.value);
onDrop: async (fileList: InputFile[]) => {
if (mode.value === FILE_UPLOAD_MODE_DIR) {
if (!fileList.length) return;
const f = fileList[0];
const dirName = f.path?.split('/')[0];
const fileCount = fileList.length;
const dirInfo = {
dirName,
fileCount,
} as FileUploadDirInfo;
console.debug(fileList, dirInfo);
fileUploadRef.value?.setDirInfo(dirInfo);
}
files.value = fileList as File[];
},
});
const fileUploadVisible = ref<boolean>(false);
const onClickUpload = () => {
fileUploadVisible.value = true;
};
const onModeChange = (value: string) => {
mode.value = value;
};
const onFilesChange = (fileList: File[]) => {
files.value = fileList;
};
const onUploadConfirm = async () => {
confirmLoading.value = true;
try {
await uploadFiles();
await ElMessage.success('Uploaded successfully');
} catch (e) {
await ElMessage.error(e);
} finally {
confirmLoading.value = false;
fileUploadVisible.value = false;
fileUploadRef.value?.clearFiles();
}
};
const onUploadClose = () => {
fileUploadVisible.value = false;
};
return {
fileUploadRef,
confirmLoading,
confirmDisabled,
onOpenFilesSettings,
getInputProps,
onOpenFiles,
open,
fileUploadVisible,
onClickUpload,
onUploadClose,
onUploadConfirm,
mode,
files,
onModeChange,
onFilesChange,
};
},
});