Move filemanager/ to frontend/
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<template #dialogs>
|
||||
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
|
||||
<p>{{ fatalError }}</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="extractInProgress" modal :header="$t('filemanager.extractionInProgress')" :closable="false" :closeOnEscape="false">
|
||||
<div style="text-align: center;">
|
||||
<ProgressSpinner style="width: 50px; height: 50px"/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="pasteInProgress" modal :header="$t('filemanager.pasteInProgress')" :closable="false" :closeOnEscape="false">
|
||||
<div style="text-align: center;">
|
||||
<ProgressSpinner style="width: 50px; height: 50px"/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="deleteInProgress" modal :header="$t('filemanager.deleteInProgress')" :closable="false" :closeOnEscape="false">
|
||||
<div style="text-align: center;">
|
||||
<ProgressSpinner style="width: 50px; height: 50px"/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- have to use v-model instead of : bind - https://github.com/primefaces/primevue/issues/815 -->
|
||||
<Dialog v-model:visible="newFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFileDialogNameInput')">
|
||||
<template #header>
|
||||
<label class="dialog-header" for="newFileDialogNameInput">{{ $t('filemanager.newFileDialog.title') }}</label>
|
||||
</template>
|
||||
<template #default>
|
||||
<form @submit="onNewFileDialogSubmit" @submit.prevent>
|
||||
<InputText class="dialog-single-input" id="newFileDialogNameInput" v-model="newFileDialog.name" :disabled="newFileDialog.busy" required/>
|
||||
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFileDialog.busy" :disabled="newFileDialog.busy || !newFileDialog.name"/>
|
||||
</form>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Dialog v-model:visible="newFolderDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFolderDialogNameInput')">
|
||||
<template #header>
|
||||
<label class="dialog-header" for="newFolderDialogNameInput">{{ $t('filemanager.newDirectoryDialog.title') }}</label>
|
||||
</template>
|
||||
<template #default>
|
||||
<form @submit="onNewFolderDialogSubmit" @submit.prevent>
|
||||
<InputText class="dialog-single-input" id="newFolderDialogNameInput" v-model="newFolderDialog.name" :disabled="newFolderDialog.busy" required/>
|
||||
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFolderDialog.busy" :disabled="newFolderDialog.busy || !newFolderDialog.name"/>
|
||||
</form>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<template #header>
|
||||
<TopBar class="navbar">
|
||||
<template #left>
|
||||
<Button icon="pi pi-refresh" @click="onRefresh()" text :loading="busyRefresh" style="margin-right: 5px;"/>
|
||||
<PathBreadcrumbs :path="cwd" :activate-handler="onActivateBreadcrumb"/>
|
||||
</template>
|
||||
<template #right>
|
||||
<Button type="button" :label="$t('filemanager.toolbar.new')" icon="pi pi-plus" @click="onCreateMenu" aria-haspopup="true" aria-controls="create_menu" style="margin-right: 5px" />
|
||||
<Menu ref="createMenu" id="create_menu" :model="createMenuModel" :popup="true" />
|
||||
<Button type="button" :label="$t('filemanager.toolbar.upload')" icon="pi pi-upload" @click="onUploadMenu" aria-haspopup="true" aria-controls="upload_menu" style="margin-right: 5px" />
|
||||
<Menu ref="uploadMenu" id="upload_menu" :model="uploadMenuModel" :popup="true" />
|
||||
<a class="p-button p-button-secondary" style="margin-left: 20px; margin-right: 5px;" :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'"><span class="p-button-icon p-button-icon-left pi pi-align-left"></span> {{ $t('filemanager.toolbar.openLogs') }}</a>
|
||||
<a class="p-button p-button-secondary" style="margin-right: 5px;" :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'"><span class="p-button-icon p-button-icon-left pi pi-desktop"></span> {{ $t('filemanager.toolbar.openTerminal') }}</a>
|
||||
<Button type="button" :label="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="resourceType === 'app'"/>
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="main-view">
|
||||
<div class="main-view-col">
|
||||
<DirectoryView
|
||||
:show-owner="true"
|
||||
:show-size="true"
|
||||
:show-modified="true"
|
||||
@selection-changed="onSelectionChanged"
|
||||
@item-activated="onItemActivated"
|
||||
:delete-handler="deleteHandler"
|
||||
:rename-handler="renameHandler"
|
||||
:change-owner-handler="changeOwnerHandler"
|
||||
:copy-handler="copyHandler"
|
||||
:cut-handler="cutHandler"
|
||||
:paste-handler="pasteHandler"
|
||||
:download-handler="downloadHandler"
|
||||
:extract-handler="extractHandler"
|
||||
:new-file-handler="onNewFile"
|
||||
:new-folder-handler="onNewFolder"
|
||||
:upload-file-handler="onUploadFile"
|
||||
:upload-folder-handler="onUploadFolder"
|
||||
:drop-handler="onDrop"
|
||||
:items="items"
|
||||
:clipboard="clipboard"
|
||||
:owners-model="ownersModel"
|
||||
:fallback-icon="fallbackIcon"
|
||||
:tr="$t"
|
||||
/>
|
||||
</div>
|
||||
<div class="main-view-col" style="max-width: 300px;">
|
||||
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<FileUploader
|
||||
ref="fileUploader"
|
||||
:upload-handler="uploadHandler"
|
||||
@finished="onUploadFinished"
|
||||
:tr="$t"
|
||||
/>
|
||||
<BottomBar />
|
||||
</template>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Menu from 'primevue/menu';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
import { DirectoryView, TopBar, PathBreadcrumbs, BottomBar, MainLayout, FileUploader } from 'pankow';
|
||||
import { sanitize, buildFilePath, sleep } from 'pankow/utils';
|
||||
|
||||
import { ISTATES } from '../constants.js';
|
||||
|
||||
import PreviewPanel from '../components/PreviewPanel.vue';
|
||||
import { createDirectoryModel } from '../models/DirectoryModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||
|
||||
const beforeUnloadListener = (event) => {
|
||||
event.preventDefault();
|
||||
return window.confirm('File operation still in progress. Really close?');
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
BottomBar,
|
||||
Button,
|
||||
Dialog,
|
||||
DirectoryView,
|
||||
FileUploader,
|
||||
InputText,
|
||||
MainLayout,
|
||||
Menu,
|
||||
PathBreadcrumbs,
|
||||
PreviewPanel,
|
||||
ProgressSpinner,
|
||||
TopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fallbackIcon: `${BASE_URL}mime-types/none.svg`,
|
||||
cwd: '/',
|
||||
busyRefresh: false,
|
||||
busyRestart: false,
|
||||
fatalError: false,
|
||||
extractInProgress: false,
|
||||
pasteInProgress: false,
|
||||
deleteInProgress: false,
|
||||
activeItem: null,
|
||||
activeDirectoryItem: {},
|
||||
items: [],
|
||||
selectedItems: [],
|
||||
clipboard: {
|
||||
action: '', // copy or cut
|
||||
files: []
|
||||
},
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
title: 'Cloudron',
|
||||
resourceType: '',
|
||||
resourceId: '',
|
||||
visible: true,
|
||||
newFileDialog: {
|
||||
visible: false,
|
||||
busy: false,
|
||||
name: ''
|
||||
},
|
||||
newFolderDialog: {
|
||||
visible: false,
|
||||
busy: false,
|
||||
name: ''
|
||||
},
|
||||
ownersModel: [{
|
||||
uid: 0,
|
||||
label: 'root'
|
||||
}, {
|
||||
uid: 33,
|
||||
label: 'www-data'
|
||||
}, {
|
||||
uid: 1000,
|
||||
label: 'cloudron'
|
||||
}, {
|
||||
uid: 1001,
|
||||
label: 'git'
|
||||
}],
|
||||
// contextMenuModel will have activeItem attached if any command() is called
|
||||
createMenuModel: [{
|
||||
label: () => this.$t('filemanager.toolbar.newFile'),
|
||||
icon: 'pi pi-file',
|
||||
command: this.onNewFile
|
||||
}, {
|
||||
label: () => this.$t('filemanager.toolbar.newFolder'),
|
||||
icon: 'pi pi-folder',
|
||||
command: this.onNewFolder
|
||||
}],
|
||||
uploadMenuModel: [{
|
||||
label: () => this.$t('filemanager.toolbar.uploadFile'),
|
||||
icon: 'pi pi-file',
|
||||
command: this.onUploadFile
|
||||
}, {
|
||||
label: () => this.$t('filemanager.toolbar.newFolder'),
|
||||
icon: 'pi pi-folder',
|
||||
command: this.onUploadFolder
|
||||
}]
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
cwd(newCwd, oldCwd) {
|
||||
if (this.resourceType && this.resourceId) this.$router.push(`/home/${this.resourceType}/${this.resourceId}${this.cwd}`);
|
||||
this.loadCwd();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCreateMenu(event) {
|
||||
this.$refs.createMenu.toggle(event);
|
||||
},
|
||||
onUploadMenu(event) {
|
||||
this.$refs.uploadMenu.toggle(event);
|
||||
},
|
||||
// generic dialog focus handler
|
||||
onDialogShow(focusElementId) {
|
||||
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
|
||||
},
|
||||
onNewFile() {
|
||||
this.newFileDialog.busy = false;
|
||||
this.newFileDialog.name = '';
|
||||
this.newFileDialog.visible = true;
|
||||
},
|
||||
async onNewFileDialogSubmit() {
|
||||
this.newFileDialog.busy = true;
|
||||
await this.directoryModel.newFile(buildFilePath(this.cwd, this.newFileDialog.name), this.newFileDialog.name);
|
||||
await this.loadCwd();
|
||||
this.newFileDialog.visible = false;
|
||||
},
|
||||
onNewFolder() {
|
||||
this.newFolderDialog.busy = false;
|
||||
this.newFolderDialog.name = '';
|
||||
this.newFolderDialog.visible = true;
|
||||
},
|
||||
async onNewFolderDialogSubmit() {
|
||||
this.newFolderDialog.busy = true;
|
||||
await this.directoryModel.newFolder(buildFilePath(this.cwd, this.newFolderDialog.name));
|
||||
await this.loadCwd();
|
||||
this.newFolderDialog.visible = false;
|
||||
},
|
||||
onUploadFile() {
|
||||
this.$refs.fileUploader.onUploadFile(this.cwd);
|
||||
},
|
||||
onUploadFolder() {
|
||||
this.$refs.fileUploader.onUploadFolder(this.cwd);
|
||||
},
|
||||
onUploadFinished() {
|
||||
this.loadCwd();
|
||||
},
|
||||
onAppChange(event) {
|
||||
this.$router.push(`/home/${event.value.type}/${event.value.id}`);
|
||||
this.cwd = '/';
|
||||
this.loadResource(event.value);
|
||||
},
|
||||
onSelectionChanged(items) {
|
||||
this.activeItem = items[0] || null;
|
||||
this.selectedItems = items;
|
||||
},
|
||||
onActivateBreadcrumb(path) {
|
||||
this.cwd = sanitize(path);
|
||||
},
|
||||
async onRefresh() {
|
||||
this.busyRefresh = true;
|
||||
await this.loadCwd();
|
||||
setTimeout(() => { this.busyRefresh = false; }, 500);
|
||||
},
|
||||
async onDrop(targetFolder, dataTransfer) {
|
||||
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
|
||||
|
||||
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
|
||||
let folderItem;
|
||||
try {
|
||||
folderItem = dataTransfer.items[0].webkitGetAsEntry();
|
||||
if (folderItem.isFile) return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
|
||||
} catch (e) {
|
||||
return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
|
||||
}
|
||||
|
||||
// if we got here we have a folder drop and a modern browser
|
||||
// now traverse the folder tree and create a file list
|
||||
var that = this;
|
||||
function traverseFileTree(item, path) {
|
||||
if (item.isFile) {
|
||||
item.file(function (file) {
|
||||
that.$refs.fileUploader.addFiles([file], sanitize(`${that.cwd}/${targetFolder}`), false);
|
||||
});
|
||||
} else if (item.isDirectory) {
|
||||
// Get folder contents
|
||||
var dirReader = item.createReader();
|
||||
dirReader.readEntries(function (entries) {
|
||||
for (let i in entries) {
|
||||
traverseFileTree(entries[i], item.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
traverseFileTree(folderItem, '');
|
||||
},
|
||||
onItemActivated(item) {
|
||||
if (!item) return;
|
||||
if (item.mimeType === 'inode/symlink') return;
|
||||
|
||||
if (item.type === 'directory') this.cwd = sanitize(this.cwd + '/' + item.name);
|
||||
else this.$router.push(`/viewer/${this.resourceType}/${this.resourceId}${sanitize(this.cwd + '/' + item.name)}`);
|
||||
},
|
||||
async deleteHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
this.deleteInProgress = true;
|
||||
|
||||
for (let i in files) {
|
||||
try {
|
||||
await this.directoryModel.remove(buildFilePath(this.cwd, files[i].name));
|
||||
} catch (e) {
|
||||
console.error(`Failed to remove file ${files[i].name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await this.loadCwd();
|
||||
|
||||
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
this.deleteInProgress = false;
|
||||
},
|
||||
async renameHandler(file, newName) {
|
||||
await this.directoryModel.rename(buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
|
||||
await this.loadCwd();
|
||||
},
|
||||
async changeOwnerHandler(files, newOwnerUid) {
|
||||
if (!files) return;
|
||||
|
||||
for (let i in files) {
|
||||
await this.directoryModel.chown(buildFilePath(this.cwd, files[i].name), newOwnerUid);
|
||||
}
|
||||
|
||||
await this.loadCwd();
|
||||
},
|
||||
async copyHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
this.clipboard = {
|
||||
action: 'copy',
|
||||
files
|
||||
};
|
||||
},
|
||||
async cutHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
this.clipboard = {
|
||||
action: 'cut',
|
||||
files
|
||||
};
|
||||
},
|
||||
async pasteHandler(target) {
|
||||
if (!this.clipboard.files || !this.clipboard.files.length) return;
|
||||
|
||||
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
this.pasteInProgress = true;
|
||||
|
||||
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
|
||||
this.clipboard = {};
|
||||
await this.loadCwd();
|
||||
|
||||
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
this.pasteInProgress = false;
|
||||
},
|
||||
async downloadHandler(file) {
|
||||
await this.directoryModel.download(buildFilePath(this.cwd, file.name));
|
||||
},
|
||||
async extractHandler(file) {
|
||||
this.extractInProgress = true;
|
||||
await this.directoryModel.extract(buildFilePath(this.cwd, file.name));
|
||||
await this.loadCwd();
|
||||
this.extractInProgress = false;
|
||||
},
|
||||
async uploadHandler(targetDir, file, progressHandler) {
|
||||
await this.directoryModel.upload(targetDir, file, progressHandler);
|
||||
await this.loadCwd();
|
||||
},
|
||||
async loadCwd() {
|
||||
this.items = await this.directoryModel.listFiles(this.cwd);
|
||||
|
||||
const tmp = this.cwd.split('/').slice(1);
|
||||
let name = this.title;
|
||||
if (tmp.length >= 1 && tmp[tmp.length-1]) name = tmp[tmp.length-1];
|
||||
|
||||
this.activeDirectoryItem = {
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'directory',
|
||||
mimeType: 'inode/directory',
|
||||
icon: `${BASE_URL}mime-types/inode-directory.svg`
|
||||
};
|
||||
},
|
||||
async onRestartApp() {
|
||||
if (this.resourceType !== 'app') return;
|
||||
|
||||
this.busyRestart = true;
|
||||
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
this.busyRestart = false;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
useConfirm();
|
||||
|
||||
const type = this.$route.params.type || 'app';
|
||||
const resourceId = this.$route.params.resourceId;
|
||||
const cwd = this.$route.params.cwd;
|
||||
|
||||
if (type === 'app') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
|
||||
this.fatalError = `Invalid resource ${type} ${resourceId}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.title = result.body.label || result.body.fqdn;
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
|
||||
this.fatalError = `Invalid resource ${type} ${resourceId}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.title = result.body.name;
|
||||
} else {
|
||||
this.fatalError = `Unsupported type ${type}`;
|
||||
return;
|
||||
}
|
||||
|
||||
window.document.title = this.title + ' - File Manager';
|
||||
|
||||
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
|
||||
this.resourceType = type;
|
||||
this.resourceId = resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
|
||||
this.loadCwd();
|
||||
|
||||
this.$watch(() => this.$route.params, (toParams, previousParams) => {
|
||||
if (toParams.type !== 'app' && toParams.type !== 'volume') {
|
||||
this.fatalError = `Unknown type ${toParams.type}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
|
||||
this.resourceType = toParams.type;
|
||||
this.resourceId = toParams.resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
|
||||
}
|
||||
|
||||
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.main-view {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding: 0 10px
|
||||
}
|
||||
|
||||
.main-view-col {
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.dialog-single-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog-single-input-submit {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
a.p-button:hover {
|
||||
text-decoration: none;
|
||||
background: #0d89ec;
|
||||
color: #ffffff;
|
||||
border-color: #0d89ec;
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user