Browser: Implement multi select user interface for object listings (#3730)

master
Rushan 8 years ago committed by Harshavardhana
parent d8950ba7c5
commit 52d6678bf0
  1. 57
      browser/app/js/actions.js
  2. 43
      browser/app/js/components/Browse.js
  3. 3
      browser/app/js/components/Dropzone.js
  4. 25
      browser/app/js/components/ObjectsList.js
  5. 2
      browser/app/js/components/Policy.js
  6. 2
      browser/app/js/components/PolicyInput.js
  7. 17
      browser/app/js/reducers.js
  8. 4
      browser/app/less/inc/buttons.less
  9. 8
      browser/app/less/inc/header.less
  10. 335
      browser/app/less/inc/list.less
  11. 14
      browser/app/less/inc/misc.less
  12. 2
      browser/app/less/inc/mixin.less
  13. 11
      browser/app/less/inc/variables.less
  14. 4
      browser/webpack.config.js
  15. 2
      cmd/web-handlers_test.go
  16. 2
      cmd/web-router.go

@ -28,7 +28,6 @@ export const SET_CURRENT_BUCKET = 'SET_CURRENT_BUCKET'
export const SET_CURRENT_PATH = 'SET_CURRENT_PATH'
export const SET_BUCKETS = 'SET_BUCKETS'
export const ADD_BUCKET = 'ADD_BUCKET'
export const ADD_OBJECT = 'ADD_OBJECT'
export const SET_VISIBLE_BUCKETS = 'SET_VISIBLE_BUCKETS'
export const SET_OBJECTS = 'SET_OBJECTS'
export const SET_STORAGE_INFO = 'SET_STORAGE_INFO'
@ -57,6 +56,9 @@ export const SET_SHARE_OBJECT = 'SET_SHARE_OBJECT'
export const DELETE_CONFIRMATION = 'DELETE_CONFIRMATION'
export const SET_PREFIX_WRITABLE = 'SET_PREFIX_WRITABLE'
export const REMOVE_OBJECT = 'REMOVE_OBJECT'
export const CHECKED_OBJECTS_ADD = 'CHECKED_OBJECTS_ADD'
export const CHECKED_OBJECTS_REMOVE = 'CHECKED_OBJECTS_REMOVE'
export const CHECKED_OBJECTS_RESET = 'CHECKED_OBJECTS_RESET'
export const showDeleteConfirmation = (object) => {
return {
@ -304,13 +306,13 @@ export const listObjects = () => {
marker: marker
})
.then(res => {
let objects = res.objects
let objects = res.objects
if (!objects)
objects = []
objects = objects.map(object => {
object.name = object.name.replace(`${currentPath}`, '');
return object
})
object.name = object.name.replace(`${currentPath}`, '');
return object
})
dispatch(setObjects(objects, res.nextmarker, res.istruncated))
dispatch(setPrefixWritable(res.writable))
dispatch(setLoadBucket(''))
@ -344,9 +346,9 @@ export const selectPrefix = prefix => {
if (!objects)
objects = []
objects = objects.map(object => {
object.name = object.name.replace(`${prefix}`, '');
return object
})
object.name = object.name.replace(`${prefix}`, '');
return object
})
dispatch(setObjects(
objects,
res.nextmarker,
@ -410,6 +412,24 @@ export const setLoginError = () => {
}
}
export const downloadAllasZip = (url, req, xhr) => {
return (dispatch) => {
xhr.open('POST', url, true)
xhr.responseType = 'blob'
xhr.onload = function(e) {
if (this.status == 200) {
var blob = new Blob([this.response], {
type: 'application/zip'
})
var blobUrl = window.URL.createObjectURL(blob);
window.location = blobUrl
}
};
xhr.send(JSON.stringify(req));
}
}
export const uploadFile = (file, xhr) => {
return (dispatch, getState) => {
const {currentBucket, currentPath} = getState()
@ -563,3 +583,24 @@ export const setPolicies = (policies) => {
policies
}
}
export const checkedObjectsAdd = (objectName) => {
return {
type: CHECKED_OBJECTS_ADD,
objectName
}
}
export const checkedObjectsRemove = (objectName) => {
return {
type: CHECKED_OBJECTS_REMOVE,
objectName
}
}
export const checkedObjectsReset = (objectName) => {
return {
type: CHECKED_OBJECTS_RESET,
objectName
}
}

@ -27,7 +27,6 @@ import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'
import Tooltip from 'react-bootstrap/lib/Tooltip'
import Dropdown from 'react-bootstrap/lib/Dropdown'
import MenuItem from 'react-bootstrap/lib/MenuItem'
import InputGroup from '../components/InputGroup'
import Dropzone from '../components/Dropzone'
import ObjectsList from '../components/ObjectsList'
@ -363,9 +362,27 @@ export default class Browse extends React.Component {
}
}
checkObject(e, objectName) {
const {dispatch} = this.props
e.target.checked ? dispatch(actions.checkedObjectsAdd(objectName)) : dispatch(actions.checkedObjectsRemove(objectName))
}
downloadAll() {
const {dispatch} = this.props
let req = {
bucketName: this.props.currentBucket,
objects: this.props.checkedObjects,
prefix: this.props.currentPath
}
let requestUrl = location.origin + "/minio/zip?token=" + localStorage.token
this.xhr = new XMLHttpRequest()
dispatch(actions.downloadAllasZip(requestUrl, req, this.xhr))
}
render() {
const {total, free} = this.props.storageInfo
const {showMakeBucketModal, alert, sortNameOrder, sortSizeOrder, sortDateOrder, showAbout, showBucketPolicy} = this.props
const {showMakeBucketModal, alert, sortNameOrder, sortSizeOrder, sortDateOrder, showAbout, showBucketPolicy, checkedObjects} = this.props
const {version, memory, platform, runtime} = this.props.serverInfo
const {sidebarStatus} = this.props
const {showSettings} = this.props
@ -435,7 +452,6 @@ export default class Browse extends React.Component {
</li>
</ul>
</div>
}
let createButton = ''
@ -490,6 +506,12 @@ export default class Browse extends React.Component {
clickOutside={ this.hideSidebar.bind(this) }
showPolicy={ this.showBucketPolicy.bind(this) } />
<div className="fe-body">
<div className={ 'list-actions' + (classNames({
' list-actions-toggled': checkedObjects.length > 0
})) }>
<span className="la-label"><i className="fa fa-check-circle" /> { checkedObjects.length } Objects selected</span>
<span className="la-actions pull-right"><button onClick={ this.downloadAll.bind(this) }> Download all as zip </button></span>
</div>
<Dropzone>
{ alertBox }
<header className="fe-header-mobile hidden-lg hidden-md">
@ -515,7 +537,8 @@ export default class Browse extends React.Component {
</header>
<div className="feb-container">
<header className="fesl-row" data-type="folder">
<div className="fesl-item fi-name" onClick={ this.sortObjectsByName.bind(this) } data-sort="name">
<div className="fesl-item fesl-item-icon"></div>
<div className="fesl-item fesl-item-name" onClick={ this.sortObjectsByName.bind(this) } data-sort="name">
Name
<i className={ classNames({
'fesli-sort': true,
@ -524,7 +547,7 @@ export default class Browse extends React.Component {
'fa-sort-alpha-asc': !sortNameOrder
}) } />
</div>
<div className="fesl-item fi-size" onClick={ this.sortObjectsBySize.bind(this) } data-sort="size">
<div className="fesl-item fesl-item-size" onClick={ this.sortObjectsBySize.bind(this) } data-sort="size">
Size
<i className={ classNames({
'fesli-sort': true,
@ -533,7 +556,7 @@ export default class Browse extends React.Component {
'fa-sort-amount-asc': !sortSizeOrder
}) } />
</div>
<div className="fesl-item fi-modified" onClick={ this.sortObjectsByDate.bind(this) } data-sort="last-modified">
<div className="fesl-item fesl-item-modified" onClick={ this.sortObjectsByDate.bind(this) } data-sort="last-modified">
Last Modified
<i className={ classNames({
'fesli-sort': true,
@ -542,7 +565,7 @@ export default class Browse extends React.Component {
'fa-sort-numeric-asc': !sortDateOrder
}) } />
</div>
<div className="fesl-item fi-actions"></div>
<div className="fesl-item fesl-item-actions"></div>
</header>
</div>
<div className="feb-container">
@ -553,7 +576,9 @@ export default class Browse extends React.Component {
<ObjectsList dataType={ this.dataType.bind(this) }
selectPrefix={ this.selectPrefix.bind(this) }
showDeleteConfirmation={ this.showDeleteConfirmation.bind(this) }
shareObject={ this.shareObject.bind(this) } />
shareObject={ this.shareObject.bind(this) }
checkObject={ this.checkObject.bind(this) }
checkedObjectsArray={ checkedObjects } />
</InfiniteScroll>
<div className="text-center" style={ { display: istruncated ? 'block' : 'none' } }>
<span>Loading...</span>
@ -734,4 +759,4 @@ export default class Browse extends React.Component {
</div>
)
}
}
}

@ -39,11 +39,12 @@ export default class Dropzone extends React.Component {
// won't handle child elements correctly.
const style = {
height: '100%',
borderWidth: '2px',
borderWidth: '0',
borderStyle: 'dashed',
borderColor: '#fff'
}
const activeStyle = {
borderWidth: '2px',
borderColor: '#777'
}
const rejectStyle = {

@ -20,8 +20,7 @@ import humanize from 'humanize'
import connect from 'react-redux/lib/components/connect'
import Dropdown from 'react-bootstrap/lib/Dropdown'
let ObjectsList = ({objects, currentPath, selectPrefix, dataType, showDeleteConfirmation, shareObject, loadPath}) => {
let ObjectsList = ({objects, currentPath, selectPrefix, dataType, showDeleteConfirmation, shareObject, loadPath, checkObject, checkedObjectsArray}) => {
const list = objects.map((object, i) => {
let size = object.name.endsWith('/') ? '-' : humanize.filesize(object.size)
let lastModified = object.name.endsWith('/') ? '-' : Moment(object.lastModified).format('lll')
@ -39,20 +38,30 @@ let ObjectsList = ({objects, currentPath, selectPrefix, dataType, showDeleteConf
</Dropdown.Menu>
</Dropdown>
}
let activeClass = checkedObjectsArray.indexOf(object.name) > -1 ? ' fesl-row-selected' : ''
return (
<div key={ i } className={ "fesl-row " + loadingClass } data-type={ dataType(object.name, object.contentType) }>
<div className="fesl-item fi-name">
<div key={ i } className={ "fesl-row " + loadingClass + activeClass } data-type={ dataType(object.name, object.contentType) }>
<div className="fesl-item fesl-item-icon">
<div className="fi-select">
<input type="checkbox" name={ object.name } onChange={ (e) => checkObject(e, object.name) } />
<i className="fis-icon"></i>
<i className="fis-helper"></i>
</div>
</div>
<div className="fesl-item fesl-item-name">
<a href="" onClick={ (e) => selectPrefix(e, `${currentPath}${object.name}`) }>
{ object.name }
</a>
</div>
<div className="fesl-item fi-size">
<div className="fesl-item fesl-item-size">
{ size }
</div>
<div className="fesl-item fi-modified">
<div className="fesl-item fesl-item-modified">
{ lastModified }
</div>
<div className="fesl-item fi-actions">
<div className="fesl-item fesl-item-actions">
{ actionButtons }
</div>
</div>
@ -72,4 +81,4 @@ export default connect(state => {
currentPath: state.currentPath,
loadPath: state.loadPath
}
})(ObjectsList)
})(ObjectsList)

@ -77,4 +77,4 @@ class Policy extends Component {
}
}
export default connect(state => state)(Policy)
export default connect(state => state)(Policy)

@ -80,4 +80,4 @@ class PolicyInput extends Component {
}
}
export default connect(state => state)(PolicyInput)
export default connect(state => state)(PolicyInput)

@ -56,7 +56,8 @@ export default (state = {
url: '',
expiry: 604800
},
prefixWritable: false
prefixWritable: false,
checkedObjects: []
}, action) => {
let newState = Object.assign({}, state)
switch (action.type) {
@ -185,6 +186,20 @@ export default (state = {
if (idx == -1) break
newState.objects = [...newState.objects.slice(0, idx), ...newState.objects.slice(idx + 1)]
break
case actions.CHECKED_OBJECTS_ADD:
newState.checkedObjects = [...newState.checkedObjects, action.objectName]
break
case actions.CHECKED_OBJECTS_REMOVE:
let index = newState.checkedObjects.indexOf(action.objectName)
if (index == -1) break
newState.checkedObjects = [...newState.checkedObjects.slice(0, index), ...newState.checkedObjects.slice(index + 1)]
break
case actions.CHECKED_OBJECTS_RESET:
newState.checkedObjects = []
break
}
console.log(newState.checkedObjects)
return newState
}

@ -35,6 +35,10 @@
width: 100%;
}
.btn-white {
.btn-variant(#fff, darken(@text-color, 20%));
}
.btn-link {
.btn-variant(#eee, #545454);
}

@ -2,14 +2,13 @@
Header
----------------------------*/
.fe-header {
padding: 45px 55px 20px;
@media(min-width: @screen-md-min) {
@media(min-width: (@screen-sm-min - 100)) {
position: relative;
padding: 40px 40px 20px 45px;
}
@media(max-width: (@screen-xs-max - 100)) {
padding: 25px 25px 20px;
padding: 20px;
}
h2 {
@ -239,4 +238,3 @@
}

@ -2,17 +2,19 @@
Row
----------------------------*/
.fesl-row {
padding-right: 40px;
padding-top: 5px;
padding-bottom: 5px;
position: relative;
@media (min-width: (@screen-sm-min - 100px)) {
padding: 5px 20px 5px 40px;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
}
@media(max-width: (@screen-xs-max - 100px)) {
padding: 5px 20px;
}
.clearfix();
}
@ -20,7 +22,7 @@ header.fesl-row {
@media (min-width:(@screen-sm-min - 100px)) {
margin-bottom: 20px;
border-bottom: 1px solid lighten(@text-muted-color, 20%);
padding-left: 40px;
padding-left: 30px;
.fesl-item,
.fesli-sort {
@ -42,7 +44,7 @@ header.fesl-row {
font-size: 14px;
}
&:hover:not(.fi-actions) {
&:hover:not(.fesl-item-actions) {
background: lighten(@text-muted-color, 22%);
color: @dark-gray;
@ -58,54 +60,42 @@ header.fesl-row {
}
}
.list-type(@background, @icon) {
.fis-icon {
background-color: @background;
&:before {
content: @icon;
}
}
}
div.fesl-row {
padding-left: 85px;
border-bottom: 1px solid transparent;
cursor: default;
.transition(background-color);
.transition-duration(500ms);
@media (max-width: (@screen-xs-max - 100px)) {
padding-left: 70px;
padding-right: 45px;
padding: 5px 20px;
}
&:nth-child(even) {
background-color: #fafafa;
&:not(.fesl-row-selected) {
&:nth-child(even) {
background-color: @list-row-even-bg;
}
}
&:hover {
background-color: #fbf7dc;
}
&[data-type]:before {
font-family: @font-family-icon;
width: 35px;
height: 35px;
text-align: center;
line-height: 35px;
position: absolute;
border-radius: 50%;
font-size: 16px;
left: 50px;
top: 9px;
color: @white;
@media (max-width: (@screen-xs-max - 100px)) {
left: 20px;
.fis-icon {
&:before {
.opacity(0)
}
}
}
&[data-type="folder"] {
@media (max-width: (@screen-xs-max - 100px)) {
.fesl-item {
&.fi-name {
padding-top: 10px;
padding-bottom: 7px;
}
&.fi-size,
&.fi-modified {
display: none;
}
.fis-helper {
&:before {
.opacity(1);
}
}
}
@ -113,54 +103,18 @@ div.fesl-row {
/*--------------------------
Icons
----------------------------*/
&[data-type=folder]:before {
content: '\f114';
background-color: #a1d6dd;
}
&[data-type=pdf]:before {
content: "\f1c1";
background-color: #fa7775;
}
&[data-type=zip]:before {
content: "\f1c6";
background-color: #427089;
}
&[data-type=audio]:before {
content: "\f1c7";
background-color: #009688
}
&[data-type=code]:before {
content: "\f1c9";
background-color: #997867;
}
&[data-type=excel]:before {
content: "\f1c3";
background-color: #64c866;
}
&[data-type=image]:before {
content: "\f1c5";
background-color: #f06292;
}
&[data-type=video]:before {
content: "\f1c8";
background-color: #f8c363;
}
&[data-type=other]:before {
content: "\f016";
background-color: #afafaf;
}
&[data-type=text]:before {
content: "\f0f6";
background-color: #8a8a8a;
}
&[data-type=doc]:before {
content: "\f1c2";
background-color: #2196f5;
}
&[data-type=presentation]:before {
content: "\f1c4";
background-color: #896ea6;
}
&[data-type=folder] { .list-type(#a1d6dd, '\f114'); }
&[data-type=pdf] {.list-type(#fa7775, '\f1c1'); }
&[data-type=zip] { .list-type(#427089, '\f1c6'); }
&[data-type=audio] { .list-type(#009688, '\f1c7'); }
&[data-type=code] { .list-type(#997867, "\f1c9"); }
&[data-type=excel] { .list-type(#f1c3, '\f1c3'); }
&[data-type=image] { .list-type(#f06292, '\f1c5'); }
&[data-type=video] { .list-type(#f8c363, '\f1c8'); }
&[data-type=other] { .list-type(#afafaf, '\f016'); }
&[data-type=text] { .list-type(#8a8a8a, '\f0f6'); }
&[data-type=doc] { .list-type(#2196f5, '\f1c2'); }
&[data-type=presentation] { .list-type(#896ea6, '\f1c4'); }
&.fesl-loading{
&:before {
@ -180,6 +134,113 @@ div.fesl-row {
}
}
.fesl-row-selected {
background-color: @list-row-selected-bg;
&, .fesl-item a {
color: darken(@text-color, 10%);
}
}
.fi-select {
float: left;
position: relative;
width: 35px;
height: 35px;
margin: 3px 0;
@media(max-width: (@screen-xs-max - 100px)) {
margin-right: 15px;
}
input {
position: absolute;
left: 0;
top: 0;
width: 35px;
height: 35px;
z-index: 20;
opacity: 0;
cursor: pointer;
&:checked {
& ~ .fis-icon {
background-color: #32393F;
&:before {
opacity: 0;
}
}
& ~ .fis-helper {
&:before {
.scale(0);
}
&:after {
.scale(1);
}
}
}
}
}
.fis-icon {
display: inline-block;
vertical-align: top;
border-radius: 50%;
width: 35px;
height: 35px;
.transition(background-color);
.transition-duration(250ms);
&:before {
width: 100%;
height: 100%;
text-align: center;
position: absolute;
border-radius: 50%;
font-family: @font-family-icon;
line-height: 35px;
font-size: 16px;
color: @white;
.transition(all);
.transition-duration(300ms);
font-style: normal;
}
}
.fis-helper {
&:before,
&:after {
position: absolute;
.transition(all);
.transition-duration(250ms);
}
&:before {
content: '';
width: 15px;
height: 15px;
border: 2px solid @white;
z-index: 10;
border-radius: 2px;
top: 10px;
left: 10px;
opacity: 0;
}
&:after {
font-family: @font-family-icon;
content: '\f00c';
top: 8px;
left: 9px;
color: @white;
font-size: 14px;
.scale(0);
}
}
/*--------------------------
Files and Folders
@ -192,26 +253,26 @@ div.fesl-row {
}
@media(min-width: (@screen-sm-min - 100px)) {
&:not(.fi-actions) {
&:not(.fesl-item-actions):not(.fesl-item-icon) {
text-overflow: ellipsis;
padding: 10px 15px;
white-space: nowrap;
overflow: hidden;
}
&.fi-name {
&.fesl-item-name {
flex: 3;
}
&.fi-size {
&.fesl-item-size {
width: 140px;
}
&.fi-modified {
&.fesl-item-modified {
width: 190px;
}
&.fi-actions {
&.fesl-item-actions {
width: 40px;
}
}
@ -219,29 +280,29 @@ div.fesl-row {
@media(max-width: (@screen-xs-max - 100px)) {
padding: 0;
&.fi-name {
&.fesl-item-name {
width: 100%;
margin-bottom: 3px;
}
&.fi-size,
&.fi-modified {
&.fesl-item-size,
&.fesl-item-modified {
font-size: 12px;
color: #B5B5B5;
float: left;
}
&.fi-modified {
&.fesl-item-modified {
max-width: 72px;
white-space: nowrap;
overflow: hidden;
}
&.fi-size {
&.fesl-item-size {
margin-right: 10px;
}
&.fi-actions {
&.fesl-item-actions {
position: absolute;
top: 5px;
right: 10px;
@ -266,7 +327,7 @@ div.fesl-row {
}
}
.fi-actions {
.fesl-item-actions {
.dropdown-menu {
background-color: transparent;
box-shadow: none;
@ -324,6 +385,78 @@ div.fesl-row {
}
}
.list-actions {
position: fixed;
.translate3d(0, -100%, 0);
.opacity(0);
.transition(all);
.transition-duration(200ms);
padding: 20px 25px;
top: 0;
left: 0;
width: 100%;
background-color: @brand-primary;
z-index: 20;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
text-align: center;
&.list-actions-toggled {
.translate3d(0, 0, 0);
.opacity(1);
}
}
.la-close {
position: absolute;
right: 20px;
top: 0;
color: #fff;
width: 30px;
height: 30px;
border-radius: 50%;
text-align: center;
line-height: 30px !important;
background: rgba(255, 255, 255, 0.1);
font-weight: normal;
bottom: 0;
margin: auto;
cursor: pointer;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
.la-label {
color: @white;
float: left;
padding: 4px 0;
.fa {
font-size: 22px;
vertical-align: top;
margin-right: 10px;
margin-top: -1px;
}
}
.la-actions {
button {
background-color: transparent;
border: 2px solid rgba(255,255,255,0.9);
color: @white;
border-radius: 2px;
padding: 5px 10px;
font-size: 13px;
.transition(all);
.transition-duration(300ms);
&:hover {
background-color: @white;
color: @brand-primary;
}
}
}
@-webkit-keyframes fiad-action-anim {
from {

@ -99,4 +99,18 @@
content: '7 days';
right: 0;
}
}
.modal-aheader {
height: 100px;
&:before {
height: 0 !important;
}
.modal-dialog {
margin: 0;
vertical-align: top;
}
}

@ -49,4 +49,4 @@
z-index: 1;
-webkit-animation: zoomIn 250ms, spin 700ms 250ms infinite linear;
animation: zoomIn 250ms, spin 700ms 250ms infinite linear;
}
}

@ -81,7 +81,7 @@
/*-------------------------
Colors
--------------------------*/
@brand-primary: #2196F3;
@brand-primary: #2298f7;
@brand-success: #4CAF50;
@brand-info: #00BCD4;
@brand-warning: #FF9800;
@ -91,4 +91,11 @@
/*-------------------------
Form
--------------------------*/
@input-border: #eee;
@input-border: #eee;
/*-------------------------
List
--------------------------*/
@list-row-selected-bg: #fbf2bf;
@list-row-even-bg: #fafafa;

@ -71,6 +71,10 @@ var exports = {
target: 'http://localhost:9000',
secure: false
},
'/minio/zip': {
target: 'http://localhost:9000',
secure: false
}
}
},
plugins: [

@ -849,7 +849,7 @@ func testWebHandlerDownloadZip(obj ObjectLayer, instanceType string, t TestErrHa
return 0, nil
}
var req *http.Request
req, err = http.NewRequest("GET", path, bytes.NewBuffer(argsData))
req, err = http.NewRequest("POST", path, bytes.NewBuffer(argsData))
if err != nil {
t.Fatalf("Cannot create upload request, %v", err)

@ -84,7 +84,7 @@ func registerWebRouter(mux *router.Router) error {
webBrowserRouter.Methods("POST").Path("/webrpc").Handler(webRPC)
webBrowserRouter.Methods("PUT").Path("/upload/{bucket}/{object:.+}").HandlerFunc(web.Upload)
webBrowserRouter.Methods("GET").Path("/download/{bucket}/{object:.+}").Queries("token", "{token:.*}").HandlerFunc(web.Download)
webBrowserRouter.Methods("GET").Path("/zip").Queries("token", "{token:.*}").HandlerFunc(web.DownloadZip)
webBrowserRouter.Methods("POST").Path("/zip").Queries("token", "{token:.*}").HandlerFunc(web.DownloadZip)
// Add compression for assets.
compressedAssets := handlers.CompressHandler(http.StripPrefix(minioReservedBucketPath, http.FileServer(assetFS())))

Loading…
Cancel
Save