first commit

This commit is contained in:
2026-02-08 21:16:11 +01:00
commit e17b7026fd
8881 changed files with 1160453 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<access component="com_ffexplorer">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
</section>
</access>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
/*!
* Vue.js v2.6.14
* (c) 2014-2021 Evan You
* Released under the MIT License.
*/
/*!
* vuex v3.6.2
* (c) 2021 Evan You
* @license MIT
*/
/**!
* Sortable 1.10.2
* @author RubaXa <trash@rubaxa.org>
* @author owenm <owen23355@gmail.com>
* @license MIT
*/

View File

@@ -0,0 +1,5 @@
{
"include": [
"./src/**/*"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "explorer",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "webpack --env dev=true",
"build": "webpack"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.15.5",
"babel-loader": "^8.2.2",
"babel-plugin-component": "^1.1.1",
"css-loader": "^6.2.0",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.2.2",
"node-sass": "^6.0.1",
"sass-loader": "^12.1.0",
"vue-loader": "^15.9.8",
"vue-template-compiler": "^2.6.14",
"webpack": "^5.52.0",
"webpack-cli": "^4.8.0"
},
"dependencies": {
"element-ui": "^2.15.6",
"lodash": "^4.17.21",
"vue": "^2.6.14",
"vue-context": "^5.1.0",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2"
}
}

View File

@@ -0,0 +1,117 @@
<template>
<div class="ffexplorer-app">
<el-button type="primary" icon="el-icon-folder" @click="setApp('explorer')">File Manager</el-button>
<el-button type="primary" icon="el-icon-coin" @click="setApp('database')">Database</el-button>
<el-dialog
width="99%"
class="ffexplorer-dialog explorer-app-dialog"
:close-on-click-modal="false"
:close-on-press-escape="false"
:append-to-body="true"
:visible.sync="explorerDialog"
:before-close="handleClose"
@opened="opened">
<EApp v-show="app === 'explorer'"/>
</el-dialog>
<el-dialog
width="99%"
class="ffexplorer-dialog database-app-dialog"
:close-on-click-modal="false"
:close-on-press-escape="false"
:append-to-body="true"
:visible.sync="databaseDialog"
:before-close="handleClose"
@opened="opened">
<DApp v-show="app === 'database'"/>
</el-dialog>
</div>
</template>
<script>
import EApp from './components/EApp.vue';
import DApp from './components/DApp.vue';
export default {
components: {
EApp,
DApp,
},
data() {
return {
explorerDialog: false,
databaseDialog: false,
}
},
computed: {
app() {
return this.$store.state.app;
}
},
methods: {
opened() {
jQuery(window).trigger('resize.ffexplorer');
},
setApp(app) {
if (app === 'explorer') {
this.explorerDialog = true;
}
if (app === 'database') {
this.databaseDialog = true;
}
this.$store.dispatch('setApp', app);
},
handleClose() {
this.explorerDialog = false;
this.databaseDialog = false;
this.$store.dispatch('setApp', '');
}
}
}
</script>
<style lang="scss">
.container-fluid.container-main {
padding-bottom: 0;
}
.el-message-box__input input {
height: 30px !important;
}
.ffexplorer-dialog.el-dialog__wrapper {
overflow: hidden;
.el-dialog {
margin-top: 0 !important;
width: 99%;
height: 98%;
top: 1%;
}
.el-dialog__body {
padding: 0 20px;
}
.el-dialog__headerbtn {
top: 4px;
right: 4px;
}
.el-dialog__header {
padding: 0px 20px 10px;
}
}
.ffexplorer-dialog.database-app-dialog {
.el-dialog__header {
padding: 20px 20px 10px;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div class="d-app">
<DSidebar />
<DContent />
</div>
</template>
<script>
import DSidebar from './DSidebar.vue';
import DContent from './DContent.vue';
export default {
components: {
DSidebar,
DContent,
}
}
</script>
<style>
.d-app {
display: flex;
}
</style>

View File

@@ -0,0 +1,603 @@
<template>
<div class="d-content"
v-loading="loading">
<div class="d-content-header">
<div class="header-toolbar" v-if="table">
<div class="header-toolbar-col" style="margin-right: 10px; flex-grow: 1;">
<el-button
size="small"
type="success"
@click="openDialogInsert">Insert</el-button>
<el-button
size="small"
type="danger"
@click="openDialogDelete">Delete</el-button>
</div>
<div class="header-toolbar-col filter-bar">
<div class="filter-col" style="margin-right: 5px; display: flex;">
<el-select size="small" clearable v-model="filterCol">
<el-option
v-for="col in filterColumns"
:key="col"
:value="col" >{{col}}</el-option>
</el-select>
<el-select size="small" v-model="filterMethod">
<el-option :key="'equal'" :value="'equal'" :label="'='"></el-option>
<el-option :key="'like_both'" :value="'like_both'" :label="'like %...%'"></el-option>
<el-option :key="'like_start'" :value="'like_start'" :label="'like ...%'"></el-option>
<el-option :key="'like_end'" :value="'like_end'" :label="'like %...'"></el-option>
</el-select>
<el-input size="small" placeholder="value of column" v-model="filterValue" />
</div>
<div class="filter-col">
<el-button size="small" type="primary" @click="doFilter">Filter</el-button>
<el-button size="small" @click="clearFilter">Clear</el-button>
</div>
</div>
</div>
</div>
<div class="d-content-inner" :style="{height, overflow: loading ? 'hidden' : 'auto'}">
<table class="d-content-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.name">{{column.name}}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, row) in items"
:key="row"
:class="{selected: activeRow === row}"
@click="selectRow(row)">
<td v-for="(value, column) in item"
:key="column"
:class="{selected: activeNode === (row + column + '')}"
@click="selectNode(row + column + '', item, column)">{{value ? ('' + value).substring(0, 100) : ''}}</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top: 5px; text-align: right;">
<el-pagination
layout="jumper, prev, pager, next"
:page-size="50"
:hide-on-single-page="true"
:current-page.sync="currentPage"
:total="total"
@current-change="changePage">
</el-pagination>
</div>
<el-dialog
width="60%"
:append-to-body="true"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
:destroy-on-close="true"
:custom-class="'dialog-edit'"
:title="activeColumn + ' - Editor'"
:visible.sync="dialogEdit">
<textarea
v-model="dialogValue"
style="width: 100%; box-sizing: border-box;"
rows="15"
:disabled="saving"></textarea>
<span slot="footer" class="dialog-footer">
<el-button
size="small"
:disabled="saving"
@click="dialogEdit = false">Close</el-button>
<el-button
size="small"
type="primary"
:loading="saving"
@click="saveNode">Save</el-button>
</span>
</el-dialog>
<el-dialog
width="60%"
:append-to-body="true"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
:destroy-on-close="true"
:custom-class="'dialog-insert'"
:title="table + ' - Insert record'"
:visible.sync="dialogInsert">
<form class="form-insert" v-loading="saving">
<table>
<tr v-for="column in cloneColumns" :key="column.name">
<th>{{column.name}}</th>
<td>{{column.type}}</td>
<td>
<span v-if="column.extra === 'auto_increment'">Auto Increment</span>
<span v-else-if="column.default === 'CURRENT_TIMESTAMP'">Current Timestamp</span>
<input
type="text"
v-else
v-model="column.default"
:name="column.name">
</td>
</tr>
</table>
</form>
<span slot="footer" class="dialog-footer">
<el-button
size="small"
:disabled="saving"
@click="dialogInsert = false">Close</el-button>
<el-button
size="small"
type="primary"
:loading="saving"
@click="insertRecord">Insert</el-button>
</span>
</el-dialog>
<el-dialog
width="50%"
:append-to-body="true"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
:destroy-on-close="true"
:custom-class="'dialog-insert'"
:title="'Table `' + table + '`: Do you want to delete this record?'"
:visible.sync="dialogDelete">
<table>
<tr v-for="(value, key) in activeItem" :key="key">
<th>{{key}}</th>
<td>{{value ? ('' + value).substring(0, 100) : ''}}</td>
</tr>
</table>
<span slot="footer" class="dialog-footer">
<el-button
size="small"
:disabled="saving"
@click="dialogDelete = false">Close</el-button>
<el-button
size="small"
type="primary"
:loading="saving"
@click="deleteRecord">Delete</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import Vue from 'vue';
import debounce from 'lodash/debounce';
export default {
data() {
return {
loading: false,
columns: [],
cloneColumns: [],
total: 0,
items: [],
height: '0px',
currentPage: 1,
activeNode: '',
activeColumn: '',
activeRow: -1,
activeItem: {},
dialogValue: '',
dialogEdit: false,
saving: false,
dialogInsert: false,
dialogDelete: false,
filterCol: '',
filterValue: '',
filterMethod: 'equal',
}
},
mounted() {
setTimeout(() => {
this.setContentHeight();
});
jQuery(window).on('resize.ffexplorer', () => {
this.setContentHeight();
});
},
computed: {
table() {
return this.$store.state.activeTable;
},
filterColumns() {
return this.columns.map(col => col.name);
},
},
watch: {
table(name) {
this.currentPage = 1;
this.resetFilter();
this.resetActiveNode();
this.initTable(name);
this.resetScrollPosition();
},
},
methods: {
clearFilter() {
this.currentPage = 1;
this.resetActiveNode();
this.resetFilter();
this.resetScrollPosition();
this.initTable();
},
doFilter() {
this.currentPage = 1;
this.initTable();
},
openDialogDelete() {
if (this.activeRow === -1) {
return alert('You need select a record to delete');
}
this.dialogDelete = true;
},
deleteRecord() {
this.saving = true;
const cols = this.getConditionColumns();
const condition = {};
cols.forEach(col => {
condition[col] = this.activeItem[col]
});
this.$ajax({
task: 'db.deleteRecord',
table: this.table,
condition: JSON.stringify(condition),
})
.then(res => {
if (res.error) {
return alert(res.error);
}
if (res.success) {
this.resetActiveNode();
this.$message({
type: 'success',
message: 'delete succesfully'
});
return this.initTable(this.table, this.currentPage);
}
})
.catch(error => {
alert('delete error');
})
.finally(() => {
this.saving = false;
this.dialogDelete = false;
});
},
openDialogInsert() {
Vue.set(this, 'cloneColumns', JSON.parse(JSON.stringify(this.columns)));
this.dialogInsert = true;
},
insertRecord() {
this.saving = true;
const data = jQuery('form.form-insert').serializeArray();
this.$ajax({
task: 'db.insertRecord',
table: this.table,
data: JSON.stringify(data),
})
.then(res => {
if (res.error) {
return alert(res.error);
}
if (res.success) {
this.$message({
type: 'success',
message: 'insert succesfully'
});
return this.initTable(this.table, this.currentPage);
}
})
.catch(error => {
alert('insert error');
})
.finally(() => {
this.saving = false;
this.dialogInsert = false;
});
},
saveNode() {
this.saving = true;
const cols = this.getConditionColumns();
const condition = {};
cols.forEach(col => {
condition[col] = this.activeItem[col];
});
this.$ajax({
task: 'db.saveNode',
table: this.table,
condition: JSON.stringify(condition),
column: this.activeColumn,
value: this.dialogValue,
})
.then(res => {
if (res.error) {
return alert(res.error);
}
this.$message({
type: 'success',
message: 'Save successfully',
});
return this.initTable(this.table, this.currentPage);
})
.catch(error => {
alert('save error');
})
.finally(() => {
this.saving = false;
});
},
getConditionColumns() {
const priCols = this.columns.filter(col => {
return col.key === 'PRI';
})
.map(col => {
return col.name;
});
if (priCols.length) {
return priCols;
} else {
return this.columns.map(col => col.name);
}
},
resetActiveNode() {
this.activeNode = '';
this.activeColumn = '';
this.activeRow = -1;
Vue.set(this, 'activeItem', {});
},
resetFilter() {
this.filterCol = '';
this.filterValue = '';
this.filterMethod = 'equal';
},
changePage(page) {
this.resetActiveNode();
this.initTable(this.table, page).then(() => {
this.resetScrollPosition();
});
},
selectRow(row) {
this.activeRow = row;
},
selectNode(nodeId, item, column) {
if (this.activeNode !== nodeId) {
this.activeNode = nodeId;
this.activeColumn = column;
this.dialogValue = item[column];
Vue.set(this, 'activeItem', item);
} else {
this.dialogEdit = true;
}
},
setContentHeight: debounce(function() {
const $ = jQuery;
const wHeight = $(window).height();
if ($('.d-content-inner').length) {
this.height = (wHeight - $('.d-content-inner').offset().top - 50) + 'px';
}
}, 100),
initTable(name, page) {
return new Promise((resolve, reject) => {
name = name ? name : this.table;
page = page ? page : 1;
this.loading = true;
const {filterCol, filterValue, filterMethod} = this;
this.$ajax({
task: 'db.initTable',
name,
page,
filterCol,
filterValue,
filterMethod,
})
.then(res => {
if (res.error) {
return alert(res.error);
}
if (res.data) {
this.total = +res.data.total;
Vue.set(this, 'columns', res.data.columns);
Vue.set(this, 'items', res.data.items);
this.setContentHeight();
}
})
.catch(error => {
alert('init table error');
})
.finally(() => {
this.loading = false;
resolve();
});
});
},
resetScrollPosition() {
const $inner = this.$el.querySelector('.d-content-inner');
$inner.scrollTop = 0;
$inner.scrollLeft = 0;
}
},
}
</script>
<style lang="scss">
.d-content {
flex: 1;
margin-left: 10px;
overflow: hidden;
.el-loading-mask {
z-index: 1000;
}
.d-content-header {
min-height: 38px;
display: flex;
.header-toolbar {
flex: 1;
display: flex;
flex-wrap: wrap;
.header-toolbar-col {
margin-bottom: 5px;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
.filter-col {
margin-bottom: 5px;
}
}
select {
width: unset;
margin: 0;
}
input {
margin: 0;
width: 150px;
}
}
}
.d-content-inner {
overflow: auto;
border: solid 1px #ddd;
}
.d-content-table {
position: relative;
th {
text-align: unset;
max-width: 300px;
padding: 3px 10px;
word-break: normal;
position: sticky;
top: 0;
background-color: #eee;
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.4);
border-right: solid 1px #ddd;
border-bottom: solid 1px #ddd;
}
tr {
transition: background-color 300ms;
&.selected {
background-color: rgba(0, 0, 0, 0.1);
}
> td {
max-width: 300px;
padding: 3px 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
transition: background-color 300ms;
border-right: solid 1px #ddd;
border-bottom: solid 1px #ddd;
&.selected {
background-color: rgba(103, 186, 224, 0.73);
outline: dashed 1px #3a8ee6;
}
}
}
}
.el-select {
.el-input__inner {
background-color: unset;
}
}
.el-button + .el-button {
margin-left: 0;
}
}
.dialog-edit {
.el-dialog__body {
padding: 10px;
}
.el-dialog__footer {
padding: 10px;
}
}
.dialog-insert {
table {
width: 100%;
> tr {
border-bottom: dashed 1px #ddd;
th {
text-align: unset;
padding: 5px;
white-space: nowrap;
}
td {
padding: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
input {
margin: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="d-sidebar">
<div class="d-sidebar-header">
<el-input
class="d-sidebar-table-search"
type="text"
placeholder="search table..."
size="small"
v-model="keyword"
@input="searchDb" />
</div>
<DSidbarItems />
</div>
</template>
<script>
import DSidbarItems from './DSidebarItems.vue';
import debounce from 'lodash/debounce';
export default {
components: {
DSidbarItems,
},
data() {
return {
app: 'database',
keyword: '',
};
},
methods: {
changeApp() {
this.$store.dispatch('setApp', this.app);
},
searchDb: debounce(function() {
this.$store.commit('searchDb', this.keyword);
}),
}
}
</script>
<style lang="scss">
.d-sidebar {
width: 300px;
.d-sidebar-header {
display: flex;
min-height: 40px;
font-size: 14px;
line-height: 40px;
font-weight: bold;
padding-bottom: 3px;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div class="d-sidebar-items" :style="{ height }">
<table class="d-item-list">
<tr
class="d-item"
v-for="item in filtedList"
:key="item.name"
:class="{active: item.name === activeTable}"
@click="selectTable(item.name)">
<td class="item-label">{{item.name}}</td>
<td class="item-size">
<span>{{parseSize(item.size)}}</span>
<div class="item-size-percent" :style="{width: getSizePercent(item.size) + '%' }"></div>
</td>
</tr>
</table>
</div>
</template>
<script>
import debounce from 'lodash/debounce';
import Vue from 'vue';
export default {
data() {
return {
height: '0px',
list: [],
maxSize: 0,
}
},
mounted() {
setTimeout(() => {
this.setListHeight();
});
jQuery(window).on('resize.ffexplorer', () => {
this.setListHeight();
});
this.$ajax({
task: 'db.tableList'
})
.then(res => {
if (res.error) {
return alert(res.error);
}
if (res.data) {
Vue.set(this, 'list', res.data);
const sizes = res.data.map(item => +item.size);
this.maxSize = sizes.reduce((a, b) => {
return Math.max(a, b);
});
}
});
},
computed: {
filtedList() {
const keyword = this.$store.state.db_keyword.toLowerCase();
return this.list.filter(item => {
return item.name.toLowerCase().indexOf(keyword) > -1;
});
},
activeTable() {
return this.$store.state.activeTable;
}
},
methods: {
selectTable(name) {
this.$store.commit('setActiveTable', name);
},
setListHeight: debounce(function() {
const $ = jQuery;
const wHeight = $(window).height();
if ($('.d-sidebar-items').length) {
this.height = (wHeight - $('.d-sidebar-items').offset().top - 45) + 'px';
}
}, 100),
parseSize(size) {
const kb = Math.round(size / 1024);
if (kb < 1024) {
return kb + ' kB';
}
const mb = Math.round(size / 1024 / 1024);
if (mb < 1024) {
return mb + ' mB';
}
const gB = Math.round(size / 1024 / 1024);
return gB + ' gB';
},
getSizePercent(size) {
const rouned = Math.round(size * 100 * 100 / this.maxSize);
return rouned / 100;
}
}
}
</script>
<style lang="scss">
.d-sidebar-items {
overflow: auto;
border: solid 1px #ddd;
padding: 5px;
.d-item-list {
min-width: 100%;
> tr {
border-bottom: dashed 1px #ddd;
cursor: pointer;
user-select: none;
transition: background-color 300ms;
&:hover {
background-color: rgba(221, 221, 221, 0.32);
}
&.active {
background-color: #ccc;
.item-size-percent {
background-color: #03A9F4;
}
}
> td {
padding: 3px;
}
}
.item-size {
position: relative;
text-align: right;
white-space: nowrap;
border-left: dashed 1px #eaeaea;
span {
position: relative;
z-index: 1;
}
.item-size-percent {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: #d4d4d4;
opacity: 0.5;
border-radius: 3px;
z-index: 0;
transition: background-color 300ms;
}
}
.item-label {
word-break: normal;
}
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div class="e-app">
<ESidebar />
<EContent />
</div>
</template>
<script>
import ESidebar from './ESidebar.vue';
import EContent from './EContent.vue';
export default {
components: {
ESidebar,
EContent,
},
}
</script>
<style>
.e-app {
display: flex;
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<div class="e-content">
<draggable
class="e-content-tabs"
v-model="files"
v-bind="dragOptions"
>
<div
class="e-content-tab-item"
v-for="file in files"
:key="file.path"
:class="{active: file.path === current, deleted: file.status === 'deleted'}"
:title="file.path"
@click="open(file)"
>
<div class="file-status is-saving" v-if="file.status === 'saving'"></div>
<div class="file-status is-dirty" v-if="file.status === 'dirty'"></div>
<div class="tab-item-title">{{file.name}}</div>
<svg
aria-hidden="true"
focusable="false"
data-prefix="fas"
data-icon="times"
class="svg-inline--fa fa-times fa-w-11"
role="img"
xmlns="http://www.w3.org/2000/svg"
license="https://fontawesome.com/license"
viewBox="0 0 352 512"
:style="{visibility: file.path === current ? 'visible' : 'hidden'}"
@click.prevent.stop="close(file.path)"
>
<path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path>
</svg>
</div>
</draggable>
<EContentEditor
ref="editor"
@removeFile="close"
@statusChange="onStatusChange" />
</div>
</template>
<script>
import draggable from "vuedraggable";
import {EventBus} from '../event-bus';
import EContentEditor from "./EContentEditor.vue";
export default {
components: {
EContentEditor,
draggable,
},
data() {
return {
files: [],
dragOptions: {
ghostClass: "ghost",
},
current: '',
};
},
mounted() {
const $tabs = this.$el.querySelector('.e-content-tabs');
$tabs.addEventListener('wheel', e => {
if (e.deltaY > 0) {
$tabs.scrollLeft += 20;
} else {
$tabs.scrollLeft -= 20;
}
});
EventBus.$on("openFileEditor", ({item, force}) => {
const inList = this.files.find(file => file.path === item.path);
const currentIdx = this.files.findIndex(file => file.path === this.current);
if (!inList) {
const idx = currentIdx === -1 ? 0 : currentIdx + 1;
this.files.splice(idx, 0, {
name: item.name,
path: item.path,
status: 'opening',
});
}
this.current = item.path;
this.open(item, force);
if (force) {
setTimeout(() => {
const $active = $tabs.querySelector('.active');
if (!$active) {
return;
}
if ($tabs.scrollLeft + $tabs.offsetWidth < $active.offsetLeft + $active.offsetWidth) {
$tabs.scrollLeft = $active.offsetLeft + $active.offsetWidth - $tabs.offsetWidth;
}
if ($tabs.scrollLeft > $active.offsetLeft + $active.offsetWidth) {
$tabs.scrollLeft = $active.offsetLeft;
}
}, 100);
}
});
EventBus.$on('fileNameChanged', (newFile, oldFile) => {
const item = this.files.find(file => file.path === oldFile.path);
if (item) {
item.path = newFile.path;
item.name = newFile.name;
if (this.current === oldFile.path) {
this.current = newFile.path;
}
}
});
EventBus.$on('fileDeleted', deletedFile => {
const item = this.files.find(file => file.path === deletedFile.path);
if (item) {
item.status = 'deleted';
}
});
EventBus.$on('folderNameChanged', (newFolder, oldFolder) => {
this.files.forEach(file => {
const idx = file.path.indexOf(oldFolder.path);
if (idx !== 0) {
return;
}
const tail = file.path.slice(oldFolder.path.length - file.path.length);
if (this.current === file.path) {
this.current = newFolder.path + tail;
}
file.path = newFolder.path + tail;
});
});
EventBus.$on('folderDeleted', deletedFolder => {
this.files.forEach(file => {
const idx = file.path.indexOf(deletedFolder.path);
if (idx !== 0) {
return;
}
file.status = 'deleted';
});
});
},
methods: {
open(file, force) {
this.current = file.path;
this.$refs.editor.initEditor(file, force);
this.$store.commit('setHistory', file.path);
},
close(path) {
const current = this.files.find(file => file.path === path);
if (current.status === 'saving') {
alert('Saving. Can not close for now.')
return;
}
if (current.status === 'opening') {
alert('Opening. Can not close for now.')
return;
}
if (current.status === 'dirty') {
const ok = confirm("Your content haven't saved yet. Do you want to close without saving?");
if (!ok) {
return;
}
}
const idx = this.files.findIndex(file => file.path === path);
if (idx !== -1) {
this.files.splice(idx, 1);
this.$refs.editor.removeFile(path);
this.$store.commit('deleteHistory', path);
const {history} = this.$store.state;
if (this.current === path && history.length) {
const prevFile = this.files.find(file => file.path === history[history.length - 1]);
if (prevFile) {
this.open(prevFile);
} else {
this.current = '';
}
}
if (!this.files.length) {
this.current = '';
this.$refs.editor.resetEditor();
}
}
},
onStatusChange({status, path}) {
const file = this.files.find(file => file.path === path);
if (file) {
file.status = status;
}
}
}
}
</script>
<style lang="scss">
.e-content {
flex: 1;
margin-left: 10px;
overflow: hidden;
.e-content-tabs {
position: relative;
min-height: 43px;
line-height: 15px;
display: flex;
overflow: hidden;
.e-content-tab-item {
position: relative;
padding-top: 12px;
padding-bottom: 12px;
padding-left: 10px;
padding-right: 17px;
user-select: none;
white-space: nowrap;
cursor: pointer;
.file-status {
position: absolute;
top: 5px;
left: 2px;
font-size: 18px;
&.is-dirty {
color: #ff0000;
}
&.is-saving {
color:#9e9e9e;
}
}
svg.fa-times {
position: absolute;
top: 12px;
right: 3px;
width: 10px;
height: 10px;
opacity: 0.7;
&:hover {
outline: solid 1px;
}
}
&:hover {
border-bottom: solid 1px rgb(199, 199, 199);
svg {
visibility: visible !important;
}
}
&.active {
color: #409eff !important;
border-bottom: solid 1px #409eff;
}
&.deleted {
text-decoration: line-through;
}
}
}
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
}
</style>

View File

@@ -0,0 +1,461 @@
<template>
<div class="e-content-editor" :style="{height: editorHeight}"></div>
</template>
<script>
import {EventBus} from '../event-bus';
import debounce from 'lodash/debounce';
let editor = false;
let eData = {};
export default {
data() {
return {
editorHeight: '0px',
current: '',
initedSnippet: false,
}
},
mounted() {
eData = {};
editor = false;
EventBus.$on('fileNameChanged', (newFile, oldFile) => {
if (eData[oldFile.path]) {
const data = eData[oldFile.path];
data.path = newFile.path;
eData[newFile.path] = data;
delete eData[oldFile.path];
if (this.current === oldFile.path) {
this.current = newFile.path;
}
}
});
EventBus.$on('fileDeleted', deletedFile => {
const data = eData[deletedFile.path];
if (!data) {
return;
}
data.readOnly = true;
if (this.current === deletedFile.path) {
editor.updateOptions({readOnly: true});
}
});
EventBus.$on('folderNameChanged', (newFolder, oldFolder) => {
for (const key in eData) {
const idx = key.indexOf(oldFolder.path);
if (idx !== 0) {
return;
}
const tail = key.slice(oldFolder.path.length - key.length);
const newKey = newFolder.path + tail;
eData[newKey] = eData[key];
delete eData[key];
if (this.current === key) {
this.current = newKey;
}
}
});
EventBus.$on('folderDeleted', deletedFolder => {
for (const key in eData) {
const idx = key.indexOf(deletedFolder.path);
if (idx !== 0) {
return;
}
eData[key].readOnly = true;
if (this.current === key) {
editor.updateOptions({readOnly: true});
}
}
});
jQuery(window).on('resize.ffexplorer', () => {
this.computeEditorHeight();
this.resizeEditorLayout();
});
setTimeout(() => {
const loading = this.$loading({
lock: true,
text: 'Refreshing',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)',
customClass: 'compress-loading'
});
this.loader().then(() => {
window.require.config({
paths: {
'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.21.2/min/vs'
}
});
this.createEditor().then(() => {
loading.close();
this.resizeEditorLayout();
});
})
});
setTimeout(() => {
this.computeEditorHeight();
});
},
methods: {
loader() {
return new Promise((resolve, reject) => {
const head = document.getElementsByTagName('head')[0]
const script = document.createElement('script')
script.type = 'text/javascript'
script.addEventListener('load', function () {
resolve(script)
})
script.src = Joomla.getOptions('system.paths').root + '/administrator/components/com_ffexplorer/assets/monaco/loader.js';
head.appendChild(script)
});
},
async initEditor(file, force) {
if (!editor) {
await this.createEditor();
}
const path = file.path;
if (this.current === path && !force) {
return;
}
if (this.current && eData[this.current]) {
eData[this.current].model = editor.getModel();
eData[this.current].state = editor.saveViewState();
}
this.current = path;
if (!eData[path] || force) {
const language = this.parseLanguage(file.name);
const sampleModel = monaco.editor.createModel('loading...', language);
const _data = {
model: sampleModel,
state: {},
lastSaved: sampleModel.getAlternativeVersionId(),
status: 'opening',
readOnly: true,
path: path,
};
eData[path] = _data;
this.emitEditorStatus(_data);
this.$store.commit('lock', path);
this.getFileContent(path).then(res => {
_data.status = 'normal';
_data.readOnly = false;
_data.model = monaco.editor.createModel(res.content, language);
_data.model.onDidChangeContent(() => {
if (_data.status === 'saving') {
return;
}
this.checkDirty(_data);
this.emitEditorStatus(_data);
});
this.emitEditorStatus(_data);
if (this.current === _data.path) {
editor.layout();
this.updateEditor(_data);
}
})
.catch(error => {
alert('Could not open this file');
_data.status = 'normal';
this.emitEditorStatus(_data);
this.$emit('removeFile', _data.path);
})
.finally(() => {
this.$store.commit('unlock', path);
});
}
this.updateEditor(eData[path]);
},
checkDirty(data) {
const isDirty = data.lastSaved !== data.model.getAlternativeVersionId();
data.status = isDirty ? 'dirty' : 'normal';
},
updateEditor({model, state, readOnly}) {
editor.setModel(model);
editor.restoreViewState(state);
editor.updateOptions({
readOnly: readOnly
});
editor.layout();
editor.focus();
},
parseLanguage(name) {
const langs = {
js: 'javascript',
php: 'php',
scss: 'scss',
css: 'css',
less: 'less',
sql: 'mysql',
ini: 'ini',
xml: 'xml',
html: 'html',
svg: 'html',
json: 'json',
md: 'markdown',
gitignore: 'markdown',
};
if (name.indexOf('.') === -1) {
return '';
}
const ext = name.split('.').pop();
return langs[ext] ? langs[ext] : '';
},
saveContent(path, content) {
return new Promise((resolve, reject) => {
this.$ajax({
task: 'explorer.saveContent',
path,
content,
})
.then(res => {
resolve(res);
})
.catch(error => {
reject(error);
});
});
},
emitEditorStatus({status, path}) {
this.$emit('statusChange', {
status,
path,
});
},
getFileContent(path) {
return new Promise((resolve, reject) => {
this.$ajax({
task: 'explorer.openFile',
path: path
})
.then(res => {
if (!res || res.error) {
reject();
} else {
resolve(res);
}
})
.catch(error => {
reject();
});
});
},
addSnippet(monaco) {
monaco.languages.registerCompletionItemProvider('php', {
provideCompletionItems: function(model, position) {
const textUntilPosition = model.getValueInRange({startLineNumber: 1, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column});
const fragsA = textUntilPosition.split('<?php');
const fragsB = textUntilPosition.split('?>');
if (textUntilPosition.indexOf('<?php') === -1) {
return;
}
if (fragsB.length === 1 || fragsB.pop().length > fragsA.pop().length) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
return {
suggestions: [
{
label: 'pre',
kind: monaco.languages.CompletionItemKind.Function,
documentation: "Print Result",
insertText: "echo '<pre>' . print_r(${1:true}, 1) . '</pre>';",
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range
},
{
label: 'd',
kind: monaco.languages.CompletionItemKind.Function,
documentation: "Print Result and Die",
insertText: "die('<pre>'.print_r(${1:true}, 1).'</pre>');",
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range
}
]
};
}
}
});
monaco.languages.registerCompletionItemProvider('javascript', {
provideCompletionItems: function(model, position) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
return {
suggestions: [
{
label: 'cl',
kind: monaco.languages.CompletionItemKind.Function,
documentation: "console.log",
insertText: "console.log(${1:true});",
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range
},
]
};
}
});
},
createEditor() {
return new Promise((resolve, reject) => {
window.require(['vs/editor/editor.main'], () => {
const monaco = window.monaco;
if (!this.initedSnippet) {
this.addSnippet(monaco);
this.initedSnippet = true;
}
if (!editor) {
editor = monaco.editor.create(this.$el, {
model: null,
});
}
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, () => {
const path = this.current;
const data = eData[path];
if (!data || data.status === 'saving' || data.readOnly) {
return;
}
data.status = 'saving';
this.emitEditorStatus(data);
this.$store.commit('lock', path);
const content = editor.getValue();
const tmpSaved = data.model.getAlternativeVersionId();
this.saveContent(path, content).then(res => {
if (res.error) {
alert(res.error)
} else {
data.lastSaved = tmpSaved;
}
this.$store.commit('unlock', path);
})
.catch(error => {
alert('save error');
console.log(error);
this.$store.commit('unlock', path);
})
.finally(() => {
this.checkDirty(data);
this.emitEditorStatus(data);
});
});
editor.addAction({
id: 'copy-relative-path',
label: 'Copy Relative Path',
contextMenuGroupId: 'navigation',
run: ed => {
navigator.clipboard.writeText(this.current).then(() => {
this.$message({
type: 'success',
message: 'Copied'
});
})
}
})
resolve();
});
});
},
removeFile(path) {
delete eData[path];
},
resetEditor() {
editor && editor.dispose();
editor = '';
this.current = '';
},
resizeEditorLayout: debounce(function() {
editor && editor.layout();
}, 100),
computeEditorHeight: debounce(function() {
const $ = jQuery;
const wHeight = $(window).height();
if ($('.e-folders').length) {
this.editorHeight = (wHeight - $('.e-folders').offset().top - 35) + 'px';
}
}, 100),
}
}
</script>
<style lang="scss">
.e-content-editor {
border: solid 1px #ddd;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="e-sidebar">
<div class="e-sidebar-header">
</div>
<ETree />
</div>
</template>
<script>
import ETree from './ETree.vue';
export default {
components: {
ETree,
},
data() {
return {
app: 'explorer',
};
},
methods: {
changeApp() {
this.$store.dispatch('setApp', this.app);
}
}
}
</script>
<style lang="scss">
.e-sidebar {
width: 300px;
.e-sidebar-header {
min-height: 40px;
font-size: 14px;
line-height: 40px;
font-weight: bold;
padding-bottom: 3px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
<template>
<li class="e-tree-item" :class="{'e-folder': isFolder, 'e-file': !isFolder}">
<div
class="e-tree-item-title"
:class="{selected: isSelected}"
@click="onClick"
@dblclick="onDblclick"
@contextmenu.stop.prevent="openContextMenu"
>
<span v-if="isFolder">[{{ isOpen ? '-' : '+' }}]</span> {{ item.name }}
</div>
<ul v-show="isOpen" v-if="isFolder">
<ETreeItem
v-for="child in item.children"
:key="child.path"
:item="child"
/>
</ul>
</li>
</template>
<script>
import Vue from 'vue';
import { EventBus } from '../event-bus.js';
import { arrange } from '../utils';
export default {
name: 'ETreeItem',
props: {
item: Object
},
data() {
return {
isOpen: this.item.path === '\\' ? true : false,
fetching: false,
}
},
computed: {
isFolder() {
return this.item.type === 'folder';
},
isSelected() {
return this.item.path === this.$store.state.selectedPath;
},
isRoot() {
return this.item.path === '\\';
},
isImage() {
if (this.isFolder) {
return false;
}
const pieces = this.item.name.split('.');
if (pieces.length < 2) {
return false;
}
const ext = pieces.pop().toLowerCase();
const imageExt = ['jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp'];
if (imageExt.indexOf(ext) !== -1) {
return true;
}
return false;
}
},
methods: {
openContextMenu(event) {
this.$store.commit('selectPath', this.item.path);
EventBus.$emit('openContextMenu', {event, item: this.item});
},
onDblclick() {
if (this.isFolder) {
return;
}
if (this.isImage) {
return EventBus.$emit('openImage', this.item);
}
const {lockedFiles} = this.$store.state;
const isLocked = lockedFiles.indexOf(this.item.path) > -1;
if (isLocked) {
alert('File is locked for opening or saving. Please try again later.');
return;
}
EventBus.$emit('openFileEditor', {
item: this.item,
force: true
});
},
onClick() {
const path = this.isFolder ? '' : this.item.path;
this.$store.commit('selectPath', path);
if (this.fetching || !this.isFolder || this.isRoot) {
return;
}
this.isOpen = !this.isOpen;
if (!this.item.children) {
this.fetching = true;
this.$ajax({
task: 'explorer.explodeFolder',
path: this.item.path,
})
.then(res => {
this.fetching = false;
Vue.set(this.item, 'children', arrange(res));
})
.catch(error => {
this.fetching = false;
alert('error');
console.log(error);
});
}
},
}
}
</script>
<style lang="scss">
.e-tree-item-title {
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: background-color 300ms;
&.selected {
background-color: #cacaca !important;
}
&:hover {
background-color: #ddd;
}
}
</style>

View File

@@ -0,0 +1,2 @@
import Vue from 'vue';
export const EventBus = new Vue();

View File

@@ -0,0 +1,70 @@
import Vue from 'vue';
import App from './App.vue';
import store from './store';
import {
Select,
Option,
Input,
Button,
Message,
MessageBox,
Loading,
Dialog,
Upload,
Pagination,
} from "element-ui";
import lang from 'element-ui/lib/locale/lang/en';
import locale from 'element-ui/lib/locale';
import {translate} from './utils';
document.addEventListener('DOMContentLoaded', () => {
__webpack_public_path__ = FF_EXPLORER_DATA.path.root + 'administrator/components/com_ffexplorer/assets/explorer/dist/';
locale.use(lang)
Vue.use(Select);
Vue.use(Option);
Vue.use(Input);
Vue.use(Button);
Vue.use(Dialog);
Vue.use(Upload);
Vue.use(Pagination);
Vue.use(Loading.directive);
Vue.prototype.$loading = Loading.service;
Vue.prototype.$msgbox = MessageBox;
Vue.prototype.$alert = MessageBox.alert;
Vue.prototype.$confirm = MessageBox.confirm;
Vue.prototype.$prompt = MessageBox.prompt;
Vue.prototype.$message = Message;
Vue.prototype.$t = function(text) {
return translate(text);
}
Vue.prototype.$ajax = function(options, method) {
const $ = jQuery;
const {path, params} = FF_EXPLORER_DATA;
const url = path.ajax;
return new Promise((resolve, reject) => {
$.ajax({
method: method ? method : 'post',
url: url,
dataType: 'json',
data: $.extend({}, params, options),
})
.done(response => {
resolve(response);
})
.fail(error => {
reject(error);
});
});
}
new Vue({
store,
render: h => h(App)
}).$mount('#explorer-app');
});

View File

@@ -0,0 +1,73 @@
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default new Vuex.Store({
strict: true,
state: {
app: '',
selectedPath: '',
lockedFiles: [],
history: [],
db_keyword: '',
activeTable: '',
},
actions: {
setApp({state, commit}, payload) {
commit('setApp', payload);
}
},
mutations: {
setActiveTable(state, value) {
state.activeTable = value;
},
searchDb(state, keyword) {
state.db_keyword = keyword;
},
setApp(state, app) {
state.app = app;
},
selectPath(state, path) {
state.selectedPath = path;
},
lock({lockedFiles}, path) {
if (lockedFiles.indexOf(path) < 0) {
lockedFiles.push(path);
}
},
unlock({lockedFiles}, path) {
const idx = lockedFiles.findIndex(item => item === path);
if (idx !== -1) {
lockedFiles.splice(idx, 1);
}
},
setHistory({history}, path) {
const idx = history.findIndex(item => item === path);
if (idx !== -1) {
history.splice(idx, 1);
}
history.push(path);
},
deleteHistory({history}, path) {
const idx = history.findIndex(item => item === path);
if (idx !== -1) {
history.splice(idx, 1);
}
}
},
});

View File

@@ -0,0 +1,27 @@
export const arrange = function(items) {
items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
const min = Math.min(a.name.length, b.name.length);
for (let i = 0; i < min; i++) {
const aSub = a.name.toLowerCase().substring(i, 1);
const bSub = b.name.toLowerCase().substring(i, 1);
if (aSub !== bSub) {
return aSub < bSub ? -1 : 1;
}
}
return a.name.length < b.name.length ? - 1 : 1;
});
return items;
}
export const translate = function(text) {
const {language} = FF_EXPLORER_DATA;
return language[text] || text;
}

View File

@@ -0,0 +1,68 @@
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = env => {
const isDev = env && env.dev;
return {
watch: isDev ? true : false,
mode: isDev ? 'development' : 'production',
entry: './src/index.js',
output: {
filename: 'app.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.(css|scss)$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
]
},
{
test: /\.(ttf|woff)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name][ext]'
}
},
{
test: /\.m?js$/,
loader: 'babel-loader',
options: {
plugins: [
[
'component',
{
libraryName: 'element-ui',
styleLibraryName: 'theme-chalk',
},
],
],
},
},
]
},
plugins: [
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'app.css',
}),
],
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset
name="permissions"
label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC"
>
<field
name="rules"
type="rules"
label="JCONFIG_PERMISSIONS_LABEL"
class="inputbox"
validate="rules"
filter="rules"
component="com_ffexplorer"
section="component"
/>
</fieldset>
</config>

View File

@@ -0,0 +1,18 @@
<?php
/**
* @package FF Explorer
* @subpackage com_ffexplorer
*
* @copyright https://github.com/trananhmanh89/ffexplorer
* @license MIT
*/
use Joomla\CMS\MVC\Controller\BaseController;
defined('_JEXEC') or die('Restricted access');
class FfexplorerController extends BaseController
{
public $default_view = 'explorer';
}

View File

@@ -0,0 +1,226 @@
<?php
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
defined('_JEXEC') or die('Restricted access');
class FfexplorerControllerDb extends BaseController
{
public function deleteRecord()
{
$this->checkToken();
$table = $this->input->get('table');
$condition = @json_decode($this->input->get('condition', '', 'raw'));
if (!$table) {
$this->response('error', 'Table is empty');
}
if (!$condition) {
$this->response('error', 'Condition is empty');
}
$db = Factory::getDbo();
$query = $db->getQuery(true)->delete($db->qn($table));
foreach ($condition as $k => $v) {
$query->where($db->qn($k) . '=' . $db->q($v));
}
try {
$db->setQuery($query, 0, 1)->execute();
$this->response('success', true);
} catch (Exception $e) {
$this->response('error', $e->getMessage());
}
}
public function insertRecord()
{
$this->checkToken();
$data = @json_decode($this->input->get('data', '', 'raw'));
if (!$data || !is_array($data)) {
$this->response('error', 'data missing');
}
$table = $this->input->get('table');
if (!$table) {
$this->response('error', 'table missing');
}
$db = Factory::getDbo();
$columns = array();
$values = array();
foreach ($data as $item) {
$columns[] = $item->name;
$values[] = $db->q($item->value);
}
$query = $db->getQuery(true)
->insert($db->qn($table))
->columns($db->qn($columns))
->values(implode(',', $values));
try {
$db->setQuery($query)->execute();
$this->response('success', true);
} catch (Exception $e) {
$this->response('error', $e->getMessage());
}
}
public function saveNode()
{
$this->checkToken();
$table = $this->input->get('table');
$condition = @json_decode($this->input->get('condition', '', 'raw'));
$column = $this->input->get('column');
$value = $this->input->get('value', '', 'raw');
if (!$table || !$condition || !$column) {
$this->response('error', 'Input error');
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->update($db->qn($table))
->set($db->qn($column) . '=' . $db->q($value));
$where = array();
foreach ($condition as $k => $v) {
$where[] = $db->qn($k) . '=' . $db->q($v);
}
$query->where($where);
try {
$db->setQuery($query, 0, 1)->execute();
$this->response('success', true);
} catch (Exception $e) {
$this->response('error', $e->getMessage());
}
}
public function initTable()
{
$this->checkToken();
$name = $this->input->get('name');
if (!$name) {
$this->response('error', 'Table name is empty');
}
$page = $this->input->getInt('page', 1);
$page = $page ? $page : 1;
$dbName = Factory::getConfig()->get('db');
$db = Factory::getDbo();
$query = "SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = '$dbName'
AND table_name = '$name'";
$existed = $db->setQuery($query)->loadResult();
if (!$existed) {
$this->response('error', 'Table is not existed');
}
$query = "SELECT `COLUMN_NAME` AS `name`,
`COLUMN_KEY` AS `key`,
`COLUMN_DEFAULT` AS `default`,
`COLUMN_TYPE` AS `type`,
`EXTRA` AS extra
FROM information_schema.`columns`
WHERE `table_schema` = '$dbName' AND `table_name` = '$name'";
$columns = $db->setQuery($query)->loadObjectList();
$columns = array_map(function($col) {
$col->default = trim($col->default, "'");
return $col;
}, $columns);
$query = $this->getListQuery($name)->select('COUNT(*)');
$total = $db->setQuery($query)->loadResult();
$query = $this->getListQuery($name)->select('*');
$limit = 50;
$offset = $limit * ($page - 1);
$items = $db->setQuery($query, $offset, $limit)->loadObjectList();
$data = array(
'columns' => $columns,
'total' => $total,
'items' => $items,
);
$this->response('data', $data);
}
protected function getListQuery($table)
{
$db = Factory::getDbo();
$query = $db->getQuery(true)->from($db->qn($table));
$filterCol = $this->input->get('filterCol');
$filterValue = $this->input->get('filterValue', '', 'raw');
$filterMethod = $this->input->get('filterMethod', '', 'raw');
if ($filterCol && $filterMethod) {
switch ($filterMethod) {
case 'like_both':
$query->where($db->qn($filterCol) . ' LIKE ' . $db->q('%' . $filterValue . '%'));
break;
case 'like_start':
$query->where($db->qn($filterCol) . ' LIKE ' . $db->q($filterValue . '%'));
break;
case 'like_end':
$query->where($db->qn($filterCol) . ' LIKE ' . $db->q('%' . $filterValue));
break;
default:
$query->where($db->qn($filterCol) . '=' . $db->q($filterValue));
break;
}
}
return $query;
}
public function tableList()
{
$this->checkToken();
$config = Factory::getConfig();
$dbName = $config->get('db');
$prefix = $config->get('dbprefix');
$db = Factory::getDbo();
$query = "SELECT TABLE_NAME AS `name`,
(DATA_LENGTH + INDEX_LENGTH) AS `size`
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '$dbName'
AND TABLE_NAME LIKE '$prefix%'
ORDER BY `name` ASC";
$list = $db->setQuery($query)->loadObjectList();
$this->response('data', $list);
}
public function checkToken($method = 'post', $redirect = false)
{
// sleep(3);
if (!parent::checkToken($method, $redirect)) {
$this->response('error', 'csrf token error');
}
}
protected function response($type = 'success', $data = array())
{
die(@json_encode(array($type => $data)));
}
}

View File

@@ -0,0 +1,490 @@
<?php
/**
* @package FF Explorer
* @subpackage com_ffexplorer
*
* @copyright https://github.com/trananhmanh89/ffexplorer
* @license MIT
*/
use Joomla\Archive\Archive;
use Joomla\Archive\Zip;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Filesystem\Path;
use Joomla\CMS\Helper\MediaHelper;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\String\PunycodeHelper;
defined('_JEXEC') or die('Restricted access');
class FfexplorerControllerExplorer extends BaseController
{
public function extract()
{
$this->checkToken();
$target = $this->input->get('target', '', 'raw');
$source = $this->input->get('source', '', 'raw');
if (!$target || !$source) {
$this->response('error', 'Path not found');
}
$target = JPATH_ROOT . $target;
$source = JPATH_ROOT . $source;
if (!is_file($source) || !is_dir($target)) {
$this->response('error', 'Path not found');
}
try {
$archive = new Archive();
$archive->extract($source, $target);
$this->response('success', true);
} catch (Exception $e) {
$this->response('error', $e->getMessage());
}
}
public function download()
{
$this->checkToken();
$relativePath = $this->input->getString('path');
$path = realpath(JPATH_ROOT . $relativePath);
if (!$relativePath || !file_exists($path) || !is_file($path)) {
$this->response('error', 'File not existed');
}
ob_clean();
$finfo = finfo_open(FILEINFO_MIME_TYPE);
header('Content-Type: ' . finfo_file($finfo, $path));
$finfo = finfo_open(FILEINFO_MIME_ENCODING);
header('Content-Transfer-Encoding: ' . finfo_file($finfo, $path));
header('Content-disposition: attachment; filename="' . basename($path) . '"');
readfile($path);
die();
}
public function compress()
{
$this->checkToken();
$relativePath = $this->input->getString('path');
$path = realpath(JPATH_ROOT . $relativePath);
if (!$relativePath || !file_exists($path)) {
$this->response('error', 'Path not existed');
}
$info = pathinfo($path);
$items = array();
if (is_dir($path)) {
$exclude = array('.svn', 'CVS', '.DS_Store', '__MACOSX');
$excludefilter = array('.*~');
$files = Folder::files($path, '.', true, true, $exclude, $excludefilter);
foreach ($files as &$file) {
$content = array();
$content['data'] = file_get_contents($file);
$name = realpath($file);
$name = $info['filename'] . str_replace($path, '', $name);
$content['name'] = $name;
$items[] = $content;
}
} else {
$items[] = array(
'data' => file_get_contents($path),
'name' => $info['basename']
);
}
$target = $info['dirname'] . '/' . $info['filename'] . '.zip';
if (File::exists($target)) {
for ($i=1; $i < 100; $i++) {
$target = $info['dirname'] . '/' . $info['filename'] . '(' . $i . ')' . '.zip';
if (!File::exists($target)) {
break;
}
}
}
$archive = new Zip();
$result = $archive->create($target, $items);
if ($result) {
$this->response('success', '');
} else {
$this->response('error', 'Compress error!!');
}
}
public function getPermission()
{
$this->checkToken();
$relativePath = $this->input->getString('path');
$path = realpath(JPATH_ROOT . $relativePath);
if (!$relativePath || !file_exists($path)) {
$this->response('error', 'Path not existed');
}
if (!Path::check($path)) {
$this->response('error', 'Path error');
}
$permission = Path::getPermissions($path);
$this->response('permission', $permission);
}
public function setPermission()
{
$this->checkToken();
$relativePath = $this->input->getString('path');
$path = realpath(JPATH_ROOT . $relativePath);
if (!$relativePath || !file_exists($path)) {
$this->response('error', 'Path not existed');
}
$mode = $this->input->get('mode');
if (!$mode) {
$this->response('error', 'Mode is missing!');
}
if (is_dir($path)) {
$result = Path::setPermissions($path, null, $mode);
} else {
$result = Path::setPermissions($path, $mode, null);
}
if ($result) {
$this->response('success', '');
} else {
$this->response('error', 'Set permission failed');
}
}
public function upload()
{
$this->checkToken();
$path = $this->input->getString('path');
$path = Path::clean($path, '/');
if (!is_dir(JPATH_ROOT . $path)) {
$this->response('error', 'empty path');
}
$path = $path ? $path : '/';
$file = $this->input->files->get('file', array(), 'raw');
$contentLength = (int) $file['size'];
$mediaHelper = new MediaHelper;
$postMaxSize = $mediaHelper->toBytes(ini_get('post_max_size'));
$memoryLimit = $mediaHelper->toBytes(ini_get('memory_limit'));
$uploadMaxFileSize = $mediaHelper->toBytes(ini_get('upload_max_filesize'));
if (($file['error'] == 1)
|| ($postMaxSize > 0 && $contentLength > $postMaxSize)
|| ($memoryLimit != -1 && $contentLength > $memoryLimit)
|| ($uploadMaxFileSize > 0 && $contentLength > $uploadMaxFileSize))
{
$this->response('error', 'File too large');
}
$file['filepath'] = JPATH_ROOT . $path . '/' . $file['name'];
if (File::exists($file['filepath']))
{
$this->response('error', 'File ' . $file['name'] . ' existed');
}
if (!isset($file['name']))
{
$this->response('error', 'File error');
}
if (File::upload($file['tmp_name'], $file['filepath'], false, true)) {
$this->response('success', '');
} else {
$this->response('error', 'Upload error');
}
}
public function saveContent()
{
$this->checkToken();
$path = $this->input->getString('path');
$path = Path::clean($path, '/');
$content = $this->input->get('content', '', 'raw');
if (!$path) {
$this->response('error', 'empty path');
}
$file = JPATH_ROOT . $path;
if (!File::exists($file)) {
$this->response('error', 'file not existed');
}
if (@File::write($file, $content)) {
$this->response('success', 'saved');
} else {
$this->response('error', 'could not write file');
}
}
public function openFile()
{
$this->checkToken();
$path = $this->input->getString('path');
$path = Path::clean($path, '/');
if (!$path) {
$this->response('error', 'empty path');
}
$file = JPATH_ROOT . $path;
if (!File::exists($file)) {
$this->response('error', 'file not existed');
}
$mime = mime_content_type($file);
if (strpos($mime, 'text') === 0 || $mime === 'inode/x-empty') {
$content = file_get_contents($file);
$this->response('content', $content);
} else {
$this->response('error', 'Could not open this file');
}
}
public function explodeFolder()
{
$this->checkToken();
$path = $this->input->getString('path');
$path = $path ? JPATH_ROOT . $path : JPATH_ROOT;
$folders = Folder::folders($path, '.', false, true);
$folders = array_map(function($folder) {
$folder = realpath($folder);
$info = pathinfo($folder);
$item = new stdClass;
$item->path = str_replace(JPATH_ROOT, '', $folder);
$item->name = $info['basename'];
$item->type = 'folder';
return $item;
}, $folders);
$exclude = array('.svn', 'CVS', '.DS_Store', '__MACOSX');
$excludefilter = array('.*~');
$files = Folder::files($path, '.', false, true, $exclude, $excludefilter);
$files = array_map(function($file) {
$file = realpath($file);
$info = pathinfo($file);
$item = new stdClass;
$item->path = str_replace(JPATH_ROOT, '', $file);
$item->name = $info['basename'];
$item->type = 'file';
return $item;
}, $files);
$result = array_merge($folders, $files);
die(json_encode($result));
}
public function newFile()
{
$this->checkToken();
$name = $this->input->getString('name');
$path = $this->input->getString('path');
if (!$name || !$path) {
die(json_encode(array('error' => 'empty')));
}
$file = JPATH_ROOT . $path . '/' . $name;
if (File::exists($file)) {
$this->response('error', 'File is already existed');
}
if (File::write($file, '')) {
$this->response('success', 'File has been created');
} else {
$this->response('error', 'Create file failed');
}
}
public function newFolder()
{
$this->checkToken();
$name = $this->input->getString('name');
$path = $this->input->getString('path');
$name = Folder::makeSafe($name);
if (!$name || !$path) {
die(json_encode(array('error' => 'empty')));
}
$folder = JPATH_ROOT . $path . '/' . $name;
if (Folder::exists($folder)) {
$this->response('error', 'Folder is already existed');
}
if (Folder::create($folder)) {
$this->response('success', 'Folder has been created');
} else {
$this->response('error', 'Create folder failed');
}
}
public function renameFolder()
{
$this->checkToken();
$newName = $this->input->getString('newName');
$oldPath = $this->input->getString('oldPath');
$newName = Folder::makeSafe($newName);
if (!$newName || !$oldPath) {
$this->response('error', 'empty');
}
if (!Folder::exists(JPATH_ROOT . $oldPath)) {
$this->response('error', 'Folder not found');
}
$info = pathinfo($oldPath);
$folder = JPATH_ROOT . $info['dirname'] . '/' . $newName;
if (Folder::exists($folder)) {
$this->response('error', 'Folder is already existed');
}
$result = rename( JPATH_ROOT . $oldPath, $folder);
if ($result) {
$folderPath = realpath($folder);
$folderPath = str_replace(JPATH_ROOT, '', $folderPath);
$this->response('data', array(
'path' => $folderPath,
'name' => $newName,
));
} else {
$this->response('error', 'rename error');
}
}
public function deleteFolder()
{
$this->checkToken();
$path = $this->input->getString('path');
$path = Path::clean($path, '/');
if (!$path) {
$this->response('error', 'empty path');
}
if (Folder::exists(JPATH_ROOT . $path)) {
try {
if(Folder::delete(JPATH_ROOT . $path)) {
$this->response('success', 'deleted');
} else {
$this->response('error', 'Delete failed');
}
} catch (Exception $e) {
$this->response('error', $e->getMessage());
}
} else {
$this->response('error', 'Folder is not existed');
}
}
public function renameFile()
{
$this->checkToken();
$newName = $this->input->getString('newName');
$oldPath = $this->input->getString('oldPath');
if (!$newName || !$oldPath) {
$this->response('error', 'empty');
}
if (!File::exists(JPATH_ROOT . $oldPath)) {
$this->response('error', 'File not found');
}
$info = pathinfo($oldPath);
$file = JPATH_ROOT . $info['dirname'] . '/' . $newName;
if (File::exists($file)) {
$this->response('error', 'File is already existed');
}
$result = rename( JPATH_ROOT . $oldPath, $file);
if ($result) {
$filePath = realpath($file);
$filePath = str_replace(JPATH_ROOT, '', $filePath);
$this->response('data', array(
'path' => $filePath,
'name' => $newName,
));
} else {
$this->response('error', 'rename error');
}
}
public function deleteFile()
{
$this->checkToken();
$path = $this->input->getString('path');
$path = Path::clean($path, '/');
if (!$path) {
$this->response('error', 'empty path');
}
if (File::exists(JPATH_ROOT . $path)) {
try {
if(File::delete(JPATH_ROOT . $path)) {
$this->response('success', 'deleted');
} else {
$this->response('error', 'Delete failed');
}
} catch (Exception $e) {
$this->response('error', $e->getMessage());
}
} else {
$this->response('error', 'File is not existed');
}
}
public function checkToken($method = 'post', $redirect = false)
{
// sleep(3);
if (!parent::checkToken($method, $redirect)) {
$this->response('error', 'csrf token error');
}
}
protected function response($type = 'success', $data = array())
{
die(@json_encode(array($type => $data)));
}
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* @package FF Explorer
* @subpackage com_ffexplorer
*
* @copyright https://github.com/trananhmanh89/ffexplorer
* @license MIT
*/
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
defined('_JEXEC') or die('Restricted access');
if (!Factory::getUser()->authorise('core.manage', 'com_ffexplorer'))
{
throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
$controller = BaseController::getInstance('ffexplorer');
$controller->execute(Factory::getApplication()->input->get('task'));
$controller->redirect();

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="component" version="3.1" method="upgrade">
<name>COM_FFEXPLORER</name>
<author>Mr Meo</author>
<creationDate>December 2019</creationDate>
<copyright>https://github.com/trananhmanh89/ffexplorer</copyright>
<license>GPL v2</license>
<authorUrl>https://github.com/trananhmanh89/ffexplorer</authorUrl>
<version>1.0.7</version>
<description>FF Explorer</description>
<updateservers>
<server type="extension" name="FF Explorer">https://raw.githubusercontent.com/trananhmanh89/ff-update-server/master/com_ffexplorer.xml</server>
</updateservers>
<administration>
<files folder="admin">
<filename>ffexplorer.php</filename>
<filename>controller.php</filename>
<filename>access.xml</filename>
<filename>config.xml</filename>
<folder>assets</folder>
<folder>controllers</folder>
<folder>views</folder>
</files>
<menu link="index.php?option=com_ffexplorer">COM_FFEXPLORER_MENU</menu>
<languages folder="admin/language">
<language tag="en-GB">en-GB.com_ffexplorer.ini</language>
<language tag="en-GB">en-GB.com_ffexplorer.sys.ini</language>
</languages>
</administration>
</extension>

View File

@@ -0,0 +1,25 @@
<?php
/**
* @package FF Explorer
* @subpackage com_ffexplorer
*
* @copyright https://github.com/trananhmanh89/ffexplorer
* @license MIT
*/
defined('_JEXEC') or die('Restricted access');
?>
<div id="explorer-app"></div>
<br>
<hr>
<div>
* Mr Meo: <a href="https://github.com/trananhmanh89/ffexplorer" target="_blank">https://github.com/trananhmanh89/ffexplorer</a>
</div>
<br>
<script type='text/javascript' src='https://ko-fi.com/widgets/widget_2.js'></script>
<script type='text/javascript'>
kofiwidget2.init('Support Me on Ko-fi', '#29abe0', 'I3I71FSC5');
kofiwidget2.draw();
</script>

View File

@@ -0,0 +1,69 @@
<?php
/**
* @package FF Explorer
* @subpackage com_ffexplorer
*
* @copyright https://github.com/trananhmanh89/ffexplorer
* @license MIT
*/
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\MVC\View\HtmlView;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\CMS\Uri\Uri;
defined('_JEXEC') or die('Restricted access');
class FfexplorerViewExplorer extends HtmlView
{
public function display($tpl = null)
{
ToolbarHelper::title('FF Explorer');
ToolbarHelper::preferences('com_ffexplorer');
HTMLHelper::_('jquery.framework');
HTMLHelper::_('behavior.core');
HTMLHelper::_('behavior.keepalive');
$xml = simplexml_load_file(JPATH_ADMINISTRATOR . '/components/com_ffexplorer/ffexplorer.xml');
HTMLHelper::script('administrator/components/com_ffexplorer/assets/explorer/dist/app.js', array('version' => (string) $xml->version));
HTMLHelper::stylesheet('administrator/components/com_ffexplorer/assets/explorer/dist/app.css', array('version' => (string) $xml->version));
$doc = $this->document;
$doc->addScriptOptions('ffexplorer_max_file_size_upload', ini_get('upload_max_filesize'));
$csrfToken = Session::getFormToken();
$language = array(
);
$config = Factory::getConfig();
$data = array(
'path' => array(
'ajax' => Uri::base() . '?option=com_ffexplorer',
'root' => Uri::root(),
),
'params' => array(
$csrfToken => 1,
),
'maxFileSizeUpload' => ini_get('upload_max_filesize'),
'uploadForm' => implode('', array(
'<form class="context-download-file" action="' . Uri::base() . '?option=com_ffexplorer" method="post">',
'<input type="hidden" name="option" value="com_ffexplorer">',
'<input type="hidden" name="task" value="explorer.download">',
'<input type="hidden" name="path" value="" class="file-path">',
'<input type="hidden" name="'.$csrfToken.'" value="1">',
'</form>',
)),
'language' => $language,
'db' => $config->get('db'),
);
$doc->addScriptDeclaration(';var FF_EXPLORER_DATA = ' . json_encode($data) . ';');
parent::display($tpl);
}
}