LbScroll, FullImage, ImageEditor

This commit is contained in:
Juan Ferrer 2019-08-01 15:39:39 +02:00
parent b06dacc4ce
commit 29acd913ee
27 changed files with 2454 additions and 457 deletions

View File

@ -0,0 +1,5 @@
{
"name": "Container",
"base": "PersistedModel",
"idInjection": true
}

View File

@ -0,0 +1,35 @@
{
"name": "ImageCollectionSize",
"base": "PersistedModel",
"options": {
"mysql": {
"table": "hedera.imageCollectionSize"
}
},
"properties": {
"id": {
"type": "Number",
"id": true,
"description": "Identifier"
},
"width": {
"type": "Number",
"required": true
},
"height": {
"type": "Number",
"required": true
},
"crop": {
"type": "Boolean",
"required": true
}
},
"relations": {
"collection": {
"type": "belongsTo",
"model": "ImageCollection",
"foreignKey": "collectionFk"
}
}
}

View File

@ -9,11 +9,11 @@
"properties": { "properties": {
"id": { "id": {
"type": "Number", "type": "Number",
"id": true,
"description": "Identifier" "description": "Identifier"
}, },
"name": { "name": {
"type": "String", "type": "String",
"id": true,
"required": true "required": true
}, },
"desc": { "desc": {
@ -28,17 +28,21 @@
"type": "Number", "type": "Number",
"required": true "required": true
}, },
"schema": { "model": {
"type": "String", "type": "String",
"required": true "required": true
}, },
"table": { "property": {
"type": "String",
"required": true
},
"column": {
"type": "String", "type": "String",
"required": true "required": true
} }
},
"relations": {
"sizes": {
"type": "hasMany",
"model": "ImageCollectionSize",
"foreignKey": "collectionFk",
"property": "id"
}
} }
} }

View File

@ -1,3 +1,6 @@
const md5 = require('md5');
const fs = require('fs-extra');
const sharp = require('sharp');
module.exports = Self => { module.exports = Self => {
Self.remoteMethod('upload', { Self.remoteMethod('upload', {
@ -5,26 +8,14 @@ module.exports = Self => {
accessType: 'WRITE', accessType: 'WRITE',
accepts: [ accepts: [
{ {
arg: 'file',
type: 'String',
description: 'The image file'
}, {
arg: 'name',
type: 'String',
description: 'The image name'
}, {
arg: 'collectionFk',
type: 'String',
description: 'The collection'
}, {
arg: 'ctx', arg: 'ctx',
type: 'Object', type: 'Object',
http: {source: 'context'} http: {source: 'context'}
} }
], ],
returns: { returns: {
type: 'Boolean', type: Self.modelName,
description: 'Success or failed', description: 'The resulting file instance',
root: true, root: true,
}, },
http: { http: {
@ -33,7 +24,134 @@ module.exports = Self => {
} }
}); });
Self.upload = async (file, name, collectionFk) => { Self.upload = async ctx => {
let $ = Self.app.models; let app = Self.app;
let $ = app.models;
let storageConnector = app.dataSources.storage.connector;
let tx = await Self.beginTransaction({});
let myOptions = {transaction: tx};
async function getContainer(name) {
let container;
try {
container = await $.Container.getContainer(name);
} catch (err) {
if (err.code === 'ENOENT') {
container = await $.Container.createContainer({
name: name
});
} else throw err;
}
return container;
}
try {
// Upload file to temporary path
let tempContainer = await getContainer('temp');
let uploaded = await $.Container.upload(tempContainer.name, ctx.req, ctx.result, {});
let files = Object.values(uploaded.files).map(file => file[0]);
let args = {};
for (let key in uploaded.fields)
args[key] = uploaded.fields[key][0];
if (!/^[a-z0-9_]+$/.test(args.name))
throw new Error('Bad file name');
let collection = await $.ImageCollection.findOne({
fields: [
'id',
'name',
'maxWidth',
'maxHeight',
'model',
'property'
],
where: {name: args.collectionFk},
include: {
relation: 'sizes',
scope: {
fields: ['width', 'height', 'crop']
}
}
});
let md5Hash = md5(args.name).substring(0, 4);
let md5Path = md5Hash.match(/(..?)/g).join('/');
let rootPath = storageConnector.client.root;
let file = files[0];
let data = {
name: args.name,
collectionFk: args.collectionFk
};
let newImage = await Self.upsertWithWhere(data, {
name: args.name,
collectionFk: args.collectionFk,
updated: (new Date).getTime()
}, myOptions);
// Resizes and saves the image
let extension = file.name.split('.').pop().toLowerCase();
let srcPath = `${rootPath}/${tempContainer.name}/${file.name}`;
let collectionDir = `${rootPath}/${args.collectionFk}`;
let dstDir = `${collectionDir}/${md5Path}/${args.name}`;
let dstFile = `full.${extension}`;
let resizeOpts = {
withoutEnlargement: true,
fit: 'inside'
};
await fs.mkdir(dstDir, {recursive: true});
await sharp(srcPath)
.resize(collection.maxWidth, collection.maxHeight, resizeOpts)
.toFile(`${dstDir}/${dstFile}`);
let sizes = collection.sizes();
for (let size of sizes) {
let dstFile = `${size.width}x${size.height}.${extension}`;
let resizeOpts = {
withoutEnlargement: true,
fit: size.crop ? 'cover' : 'inside'
};
await sharp(srcPath)
.resize(size.width, size.height, resizeOpts)
.toFile(`${dstDir}/${dstFile}`);
}
// Updates items with matching id, when option is checked
if (args.updateMatching === 'true') {
if (!collection.model || !collection.property)
throw new Error('Matching model settings not defined');
let model = app.models[collection.model];
if (!model)
throw new Error('Matching model not found');
let item = await model.findById(args.name, null, myOptions);
if (item)
await item.updateAttribute(
collection.property,
args.name,
myOptions
);
}
await fs.unlink(srcPath);
await tx.commit();
return newImage;
} catch (e) {
await tx.rollback();
throw e;
}
}; };
}; };

View File

@ -1,9 +1,9 @@
{ {
"name": "Image", "name": "Image",
"base": "PersistedModel", "base": "PersistedModel",
"options": { "options": {
"mysql": { "mysql": {
"table": "hedera.image" "table": "hedera.image"
} }
}, },
"properties": { "properties": {
@ -16,19 +16,17 @@
"type": "String", "type": "String",
"required": true "required": true
}, },
"collectionFk": {
"type": "String",
"required": true
},
"updated": { "updated": {
"type": "Number" "type": "Number"
}, },
"nRefs": { "nRefs": {
"type": "Number", "type": "Number",
"required": true "required": true,
"default": 0
} }
},
"relations": {
"collection": {
"type": "belongsTo",
"model": "ImageCollection",
"foreignKey": "collectionFk"
}
} }
} }

View File

@ -21,8 +21,7 @@
"required": true "required": true
}, },
"image": { "image": {
"type": "String", "type": "String"
"required": true
}, },
"created": { "created": {
"type": "Date", "type": "Date",

1423
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,16 @@
"dependencies": { "dependencies": {
"compression": "^1.0.3", "compression": "^1.0.3",
"cors": "^2.5.2", "cors": "^2.5.2",
"helmet": "^3.10.0", "fs-extra": "^8.1.0",
"loopback": "^3.22.0", "helmet": "^3.19.0",
"loopback": "^3.26.0",
"loopback-boot": "^2.6.5", "loopback-boot": "^2.6.5",
"loopback-component-explorer": "^6.2.0", "loopback-component-explorer": "^6.2.0",
"loopback-component-storage": "^3.6.2",
"loopback-connector-mysql": "^5.4.1", "loopback-connector-mysql": "^5.4.1",
"md5": "^2.2.1",
"serve-favicon": "^2.0.1", "serve-favicon": "^2.0.1",
"sharp": "^0.22.1",
"strong-error-handler": "^3.0.0" "strong-error-handler": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -2,5 +2,17 @@
"db": { "db": {
"name": "db", "name": "db",
"connector": "memory" "connector": "memory"
},
"storage": {
"name": "storage",
"connector": "loopback-component-storage",
"provider": "filesystem",
"root": "./dist/image",
"maxFileSize": "10485760",
"allowedContentTypes": [
"image/png",
"image/jpeg",
"image/jpg"
]
} }
} }

View File

@ -71,6 +71,9 @@
"Client": { "Client": {
"dataSource": "vn" "dataSource": "vn"
}, },
"Container": {
"dataSource": "storage"
},
"Country": { "Country": {
"dataSource": "vn" "dataSource": "vn"
}, },
@ -86,6 +89,9 @@
"ImageCollection": { "ImageCollection": {
"dataSource": "vn" "dataSource": "vn"
}, },
"ImageCollectionSize": {
"dataSource": "vn"
},
"ItemCategory": { "ItemCategory": {
"dataSource": "vn" "dataSource": "vn"
}, },

View File

@ -1,5 +1,6 @@
import { date as qdate } from 'quasar' import { date as qdate, format } from 'quasar'
const { pad } = format
export default async ({ app, Vue }) => { export default async ({ app, Vue }) => {
let i18n = app.i18n let i18n = app.i18n
@ -58,8 +59,26 @@ export default async ({ app, Vue }) => {
return relDate(val) + ' ' + qdate.formatDate(val, 'H:mm:ss') return relDate(val) + ' ' + qdate.formatDate(val, 'H:mm:ss')
} }
function elapsedTime (val) {
if (val == null) return val
if (!(val instanceof Date)) {
val = new Date(val)
}
let now = (new Date()).getTime()
val = Math.floor((now - val.getTime()) / 1000)
let hours = Math.floor(val / 3600)
val -= hours * 3600
let minutes = Math.floor(val / 60)
val -= minutes * 60
let seconds = val
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`
}
Vue.filter('currency', currency) Vue.filter('currency', currency)
Vue.filter('date', date) Vue.filter('date', date)
Vue.filter('relDate', relDate) Vue.filter('relDate', relDate)
Vue.filter('relTime', relTime) Vue.filter('relTime', relTime)
Vue.filter('elapsedTime', elapsedTime)
} }

View File

@ -0,0 +1,75 @@
<template>
<div>
<q-dialog v-model="showDialog">
<q-card style="width: 80em;">
<q-img
:src="`${$imageBase}/${collection}/${size}/${value}`"
@click="showDialog = false">
</q-img>
<q-btn
v-if="editable"
@click="showImgEditor = true"
:title="$t('edit')"
icon="edit"
round
color="accent"
class="absolute-bottom-right"
style="bottom: .6em; right: .6em;"/>
</q-card>
</q-dialog>
<image-editor
v-if="editable"
v-model="image"
:collection="collection"
:show="showImgEditor"
@response="showImgEditor = false"/>
</div>
</template>
<script>
import ImageEditor from 'components/ImageEditor'
export default {
name: 'FullImage',
components: {
ImageEditor
},
props: {
value: {},
collection: {
type: String,
required: true
},
size: {
type: String,
required: false,
default: 'full'
},
editable: {
type: Boolean,
required: false,
default: true
}
},
data () {
return {
image: null,
showImgEditor: false,
showDialog: false
}
},
watch: {
image (val) {
this.$emit('input', val)
},
value (val) {
this.image = val
}
},
methods: {
show () {
this.showDialog = true
}
}
}
</script>

View File

@ -0,0 +1,162 @@
<template>
<q-dialog v-model="show" persistent>
<q-card style="width: 25em;">
<q-card-section>
<q-select
v-model="image"
:label="$t('image')"
:options="images"
@filter="onFilter"
option-value="name"
option-label="name"
@new-value="onAdd"
input-debounce="250"
emit-value
clearable
use-input
hide-selected
fill-input>
<template v-slot:option="scope">
<q-item
v-bind="scope.itemProps"
v-on="scope.itemEvents">
<q-item-section avatar>
<q-avatar>
<img :src="`${$imageBase}/${collection}/200x200/${scope.opt.name}`">
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{scope.opt.name}}</q-item-label>
</q-item-section>
</q-item>
</template>
<template v-slot:prepend>
<q-avatar>
<img :src="`${$imageBase}/${collection}/200x200/${image}`">
</q-avatar>
</template>
<template v-slot:after>
<q-btn
icon="add_a_photo"
@click="showUploader = !showUploader"
:title="$t('edit')"
:disable="!image"
:color="showUploader ? 'primary' : null"
flat
round
dense/>
</template>
</q-select>
<q-slide-transition>
<q-uploader
ref="uploader"
v-show="showUploader"
:url="`${$apiBase}/Images/upload`"
:form-fields="formFields"
auto-upload
class="q-mt-md"
flat
bordered/>
</q-slide-transition>
</q-card-section>
<q-card-actions align="right">
<q-btn
:label="$t('cancel')"
@click="onCancel"
color="primary"
flat/>
<q-btn
:label="$t('accept')"
@click="onAccept"
color="primary"
flat/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script>
export default {
name: 'ImageEditor',
props: {
value: {},
collection: {
type: String,
required: true
},
show: {
type: Boolean,
required: false,
default: false
}
},
data () {
return {
image: null,
showUploader: false,
images: []
}
},
watch: {
image () {
if (this.$refs.uploader) {
this.$refs.uploader.reset()
}
if (!this.image) {
this.showUploader = false
}
},
value () {
this.image = this.value
},
showUploader () {
this.$refs.uploader.reset()
},
show () {
if (!this.show) {
this.showUploader = false
}
}
},
methods: {
onFilter (val, update, abort) {
let filter = {
where: {
name: { like: `${val}%` },
collectionFk: this.collection
},
limit: !val || val.length < 2 ? 50 : undefined
}
this.$axios.get(`Images`, { params: { filter } })
.then(res => {
update(() => (this.images = res.data))
})
.catch(() => abort())
},
onAdd (inputValue, done) {
done({ name: inputValue }, 'toggle')
this.showUploader = true
// this.$refs.uploader.pickFiles()
},
onAccept () {
this.$emit('input', this.image)
this.$emit('response', 'accept')
},
onCancel () {
this.image = this.value
this.$emit('response', 'cancel')
},
formFields () {
return [
{
name: 'collectionFk',
value: this.collection
}, {
name: 'name',
value: this.image
}
]
}
}
}
</script>

173
src/components/LbScroll.vue Normal file
View File

@ -0,0 +1,173 @@
<template>
<div>
<div
v-if="status == 'clear' || status == 'empty'"
class="text-subtitle1 text-center text-grey-7 q-pa-md">
<span v-if="status == 'clear'">
{{$t('setSearchFilter')}}
</span>
<span v-if="status == 'empty'">
{{$t('noDataFound')}}
</span>
</div>
<q-infinite-scroll
ref="scroll"
@load="onLoad"
scroll-taget="html">
<slot></slot>
<template slot="loading">
<div class="row justify-center q-my-md">
<q-spinner color="primary" size="40px"/>
</div>
</template>
</q-infinite-scroll>
</div>
</template>
<script>
import { CancelToken } from 'axios'
export default {
name: 'LbScroll',
props: {
request: {
type: Function,
required: true
},
autoRefresh: {
type: Number,
required: false
}
},
data () {
return {
data: null,
source: null,
isLoading: false,
index: null
}
},
computed: {
status () {
if (this.isLoading && !this.data) {
return 'loading'
} else if (!this.data) {
return 'clear'
} else if (this.data.length === 0) {
return 'empty'
} else {
return 'ready'
}
}
},
beforeDestroy () {
this.clearTimeout()
},
watch: {
data (val) {
this.$emit('data', val)
}
},
methods: {
get (path, params) {
this.cancelRequest()
this.isLoading = true
this.source = CancelToken.source()
let config = {
params,
cancelToken: this.source.token
}
return this.$axios.get(path, config)
.then(res => {
this.source = null
this.isLoading = false
return res
})
.catch(err => {
if (!err.__CANCEL__) {
this.source = null
this.isLoading = false
}
throw err
})
},
cancelRequest () {
if (this.source) {
this.source.cancel()
this.source = null
}
},
doRequest () {
this.cancelRequest()
this.clearTimeout()
let config = this.request(this.index)
if (!config) {
this.data = null
return Promise.resolve(null)
}
let params = config.params || {}
let filter = config.filter
let limit
if (filter) {
params.filter = filter
limit = filter.limit
if (!limit) {
limit = this.index * 30
filter.limit = limit
}
}
return this.get(config.url, params)
.then(res => {
res.limit = limit
this.data = res.data
return res
})
.finally(() => {
this.setTimeout()
})
},
setTimeout () {
this.clearTimeout()
if (this.autoRefresh) {
this.timeout = setTimeout(
() => this.refresh(), this.autoRefresh * 1000)
}
},
clearTimeout () {
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
},
onLoad (index, done) {
this.index = index
this.doRequest()
.then(res => {
if (!res) return done(true)
done(!res.limit || res.data.length < res.limit)
return res
})
.catch(err => {
done(true)
throw err
})
},
reload () {
this.data = null
let scroll = this.$refs.scroll
scroll.stop()
scroll.reset()
scroll.resume()
scroll.trigger()
},
refresh () {
this.doRequest()
}
}
}
</script>

View File

@ -1,8 +1,17 @@
import Toolbar from './Toolbar' import Toolbar from './Toolbar'
import LbScroll from './LbScroll'
import FullImage from './FullImage'
export default { export default {
components: { components: {
Toolbar Toolbar,
LbScroll,
FullImage
},
data () {
return {
data: null
}
}, },
beforeRouteEnter (to, from, next) { beforeRouteEnter (to, from, next) {
next(vm => {}) next(vm => {})

View File

@ -178,6 +178,13 @@ export default {
priority: 'Priority', priority: 'Priority',
text: 'Text', text: 'Text',
// Images
collection: 'Collection',
updateMatchingId: 'Update items with matching id',
uploadAutomatically: 'Upload automatically',
imagesUploadSuccess: 'Images uploaded successfully',
imagesUploadFailed: 'Some images could not be uploaded',
// User // User
accessLog: 'Access log', accessLog: 'Access log',

View File

@ -178,6 +178,13 @@ export default {
priority: 'Prioridad', priority: 'Prioridad',
text: 'Texto', text: 'Texto',
// Images
collection: 'Colección',
updateMatchingId: 'Actualizar ítems con id coincidente',
uploadAutomatically: 'Subir automáticamente',
imagesUploadSuccess: 'Imágenes subidas correctamente',
imagesUploadFailed: 'Algunas imágenes no se ha podido subir',
// User // User
accessLog: 'Registro de accesos', accessLog: 'Registro de accesos',

View File

@ -159,7 +159,7 @@ export default {
this.$q.notify({ this.$q.notify({
message: this.$t(message), message: this.$t(message),
icon: 'check', icon: 'check',
color: 'green-6' color: 'green'
}) })
this.$router.go(-1) this.$router.go(-1)
}) })

View File

@ -19,7 +19,7 @@
:label="$t('refreshRate')" :label="$t('refreshRate')"
v-model="rate" v-model="rate"
:options="rates" :options="rates"
@input="setTimeout" @input="$refs.scroll.reload()"
emit-value> emit-value>
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="timer"/> <q-icon name="timer"/>
@ -29,7 +29,7 @@
:label="$t('orderBy')" :label="$t('orderBy')"
v-model="order" v-model="order"
:options="orderOptions" :options="orderOptions"
@input="refresh" @input="$refs.scroll.reload()"
emit-value> emit-value>
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="sort"/> <q-icon name="sort"/>
@ -38,20 +38,15 @@
</div> </div>
</q-drawer> </q-drawer>
<div> <div>
<div <lb-scroll
v-if="connections && !connections.length"
class="text-subtitle1 text-center text-grey-7 q-pa-md">
{{$t('noDataFound')}}
</div>
<q-infinite-scroll
ref="scroll" ref="scroll"
@load="onLoad" :request="request"
scroll-taget="html" @data="onData"
:offset="500"> :auto-refresh="rate">
<q-card class="vn-w-md"> <q-card class="vn-w-md">
<q-list bordered separator> <q-list bordered separator>
<q-item <q-item
v-for="conn in connections" v-for="conn in data"
:key="conn.id" :key="conn.id"
:to="`/access-log/${conn.userVisit.user.id}`" :to="`/access-log/${conn.userVisit.user.id}`"
:title="$t('accessLog')" :title="$t('accessLog')"
@ -61,7 +56,7 @@
{{conn.userVisit.user.nickname}} {{conn.userVisit.user.nickname}}
</q-item-label> </q-item-label>
<q-item-label caption> <q-item-label caption>
{{conn.lastUpdate | relTime}} {{conn.lastUpdate | elapsedTime}}
</q-item-label> </q-item-label>
<q-item-label caption> <q-item-label caption>
{{conn.userVisit.access.agent.platform}} - {{conn.userVisit.access.agent.platform}} -
@ -72,21 +67,15 @@
</q-item> </q-item>
</q-list> </q-list>
</q-card> </q-card>
<template slot="loading"> </lb-scroll>
<div class="row justify-center q-my-md">
<q-spinner color="primary" name="dots" size="40px" />
</div>
</template>
</q-infinite-scroll>
</div> </div>
<q-page-sticky position="bottom-right" :offset="[18, 18]"> <q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn <q-btn
fab fab
icon="refresh" icon="refresh"
color="accent" color="accent"
@click="refresh" @click="$refs.scroll.refresh()"
:title="$t('refresh')" :title="$t('refresh')"/>
/>
</q-page-sticky> </q-page-sticky>
</div> </div>
</template> </template>
@ -99,9 +88,7 @@ export default {
mixins: [Page], mixins: [Page],
data () { data () {
return { return {
connections: null, data: [],
pageSize: 30,
limit: 0,
rate: 5, rate: 5,
count: null, count: null,
rates: [ rates: [
@ -110,6 +97,7 @@ export default {
value: null value: null
} }
], ],
where: { userVisitFk: { neq: null } },
order: 'lastUpdate DESC', order: 'lastUpdate DESC',
orderOptions: [ orderOptions: [
{ {
@ -134,77 +122,51 @@ export default {
}) })
} }
}, },
beforeDestroy () {
this.clearTimeout()
},
methods: { methods: {
onLoad (index, done) { request () {
this.limit = this.pageSize * index return {
this.refresh() url: 'UserSessions',
.then(() => { filter: {
done(this.connections.length < this.limit) fields: ['created', 'lastUpdate', 'userVisitFk'],
}) order: this.order,
.catch((err) => { where: this.where,
done(true) include: {
throw err relation: 'userVisit',
}) scope: {
}, fields: ['accessFk', 'userFk'],
refresh () { include: [
this.clearTimeout() {
let where = { userVisitFk: { neq: null } } relation: 'access',
let filter = { scope: {
fields: ['created', 'lastUpdate', 'userVisitFk'], fields: ['agentFk'],
order: this.order, include: {
limit: this.limit, relation: 'agent',
where, scope: {
include: { fields: [
relation: 'userVisit', 'platform',
scope: { 'browser',
fields: ['accessFk', 'userFk'], 'version'
include: [ ]
{ }
relation: 'access',
scope: {
fields: ['agentFk'],
include: {
relation: 'agent',
scope: {
fields: [
'platform',
'browser',
'version'
]
} }
} }
}, {
relation: 'user',
scope: {
fields: ['id', 'nickname']
}
} }
}, { ]
relation: 'user', }
scope: {
fields: ['id', 'nickname']
}
}
]
} }
} }
} }
return this.$axios.get(`UserSessions`, { params: { filter } }) },
.then(res => (this.connections = res.data)) onData (data) {
.then(() => this.$axios.get(`UserSessions/count`, { params: { where } })) this.data = data
let params = { where: this.where }
this.$axios.get(`UserSessions/count`, { params })
.then(res => (this.count = res.data.count)) .then(res => (this.count = res.data.count))
.finally(() => this.setTimeout())
},
setTimeout () {
this.clearTimeout()
if (this.rate) {
this.timeout = setTimeout(
() => this.refresh(), this.rate * 1000)
}
},
clearTimeout () {
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
} }
} }
} }

View File

@ -1,14 +1,139 @@
<template> <template>
<div class="text-subtitle1 text-center text-grey-7 q-pa-lg"> <div class="vn-pp row justify-center">
Sorry this section is under construction <q-card class="vn-w-md">
<q-card-section>
<q-select
v-model="collection"
:label="$t('collection')"
:options="collections"
option-value="name"
option-label="desc"
emit-value/>
</q-card-section>
<q-card-section>
<q-uploader
ref="uploader"
:url="`${$apiBase}/Images/upload`"
:form-fields="formFields"
@added="onAdded"
@finish="onFinish"
:auto-upload="uploadAutomatically"
multiple
flat
bordered
style="width: 100%;">
<template v-slot:list="scope">
<q-list separator>
<q-item v-for="file in scope.files" :key="file.name">
<q-item-section>
<q-input
v-model="file.uploadName"
:readonly="file.__status == 'uploading' || file.__status == 'uploaded'"
:loading="file.__status == 'uploading'"
:error="file.__status == 'failed'"
dense>
<template v-slot:prepend
v-if="file.__img">
<q-avatar>
<img :src="file.__img.src">
</q-avatar>
</template>
<template v-slot:append
v-if="file.__status == 'uploaded'">
<q-icon
name="check"
color="green"/>
</template>
<template v-slot:after>
<q-btn
icon="delete"
@click="scope.removeFile(file)"
flat
dense
round/>
</template>
<template v-slot:hint>
{{ file.__sizeLabel }} / {{ file.__progressLabel }}
</template>
<template v-slot:error>
{{ file.xhr.statusText }}
</template>
</q-input>
</q-item-section>
</q-item>
</q-list>
</template>
</q-uploader>
</q-card-section>
<q-card-section>
<q-checkbox
v-model="updateMatching"
:label="$t('updateMatchingId')"/>
<q-checkbox
v-model="uploadAutomatically"
:label="$t('uploadAutomatically')"/>
</q-card-section>
</q-card>
</div> </div>
</template> </template>
<style lang="stylus" scoped>
</style>
<script> <script>
export default { export default {
name: 'Images' name: 'Images',
data () {
return {
collection: 'catalog',
collections: null,
updateMatching: true,
uploadAutomatically: true
}
},
mounted () {
let filter = {
fields: ['id', 'name', 'desc'],
order: 'desc'
}
this.$axios.get(`ImageCollections`, { params: { filter } })
.then(res => (this.collections = res.data))
},
methods: {
onAdded (files) {
for (let file of files) {
file.uploadName = file.name.split('.')[0]
}
},
onFinish () {
let error = this.$refs.uploader.files.findIndex(
file => file.__status === 'failed')
if (error === -1) {
this.$q.notify({
message: this.$t('imagesUploadSuccess'),
icon: 'check',
color: 'green'
})
} else {
this.$q.notify({
message: this.$t('imagesUploadFailed'),
icon: 'warning',
color: 'orange'
})
}
},
formFields (files) {
return [
{
name: 'collectionFk',
value: this.collection
}, {
name: 'updateMatching',
value: this.updateMatching
}, {
name: 'name',
value: files[0].uploadName
}
]
}
}
} }
</script> </script>

View File

@ -1,14 +1,97 @@
<template> <template>
<div class="text-subtitle1 text-center text-grey-7 q-pa-lg"> <div class="vn-pp row justify-center">
Sorry this section is under construction <toolbar>
<q-input
v-model="search"
debounce="500"
dark
dense
standout>
<template v-slot:append>
<q-icon
v-if="search === ''"
name="search"/>
<q-icon
v-else
name="clear"
class="cursor-pointer"
@click="search = ''"/>
</template>
</q-input>
</toolbar>
<div>
<lb-scroll
ref="scroll"
:request="request"
@data="data = arguments[0]">
<q-card class="vn-w-md">
<q-list bordered separator>
<q-item
v-for="item in data"
:key="item.id"
clickable>
<q-item-section avatar>
<q-avatar>
<img :src="`${$imageBase}/catalog/200x200/${item.image}`">
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{item.longName}}</q-item-label>
<q-item-label caption>#{{item.id}}</q-item-label>
<q-item-label caption>{{item.image}}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</lb-scroll>
</div>
</div> </div>
</template> </template>
<style lang="stylus" scoped>
</style>
<script> <script>
import Page from 'components/Page'
export default { export default {
name: 'Items' name: 'Items',
mixins: [Page],
data () {
return {
search: ''
}
},
beforeRouteUpdate (to, from, next) {
next()
this.$refs.scroll.reload()
},
watch: {
search (value) {
let query
if (value) query = { search: value }
this.$router.push({ query })
}
},
methods: {
request () {
this.search = this.$route.query.search || ''
if (!this.search) return null
let where = {}
if (/^[0-9]+$/.test(this.search)) {
where.id = this.search
} else {
where.longName = { like: `%${this.search}%` }
}
return {
url: 'Items',
filter: {
fields: ['id', 'longName', 'image'],
order: 'name',
where
}
}
}
}
} }
</script> </script>

View File

@ -1,18 +1,20 @@
<template> <template>
<div class="vn-pp row justify-center"> <div class="vn-pp row justify-center">
<q-card class="vn-w-lg"> <q-card class="vn-w-lg">
<q-img <div style="position: relative;">
:src="`${$imageBase}/news/full/${myNew.image}`" <q-img
:ratio="16/4"> :src="`${$imageBase}/news/full/${myNew.image}`"
:ratio="3/1">
</q-img>
<q-btn <q-btn
@click="editImage = true" @click="showImgEditor = true"
:title="$t('edit')" :title="$t('edit')"
icon="edit" icon="edit"
round round
color="accent" color="accent"
class="absolute-bottom-right" class="absolute-bottom-right"
style="bottom: .6em; right: .6em;"/> style="bottom: .6em; right: .6em;"/>
</q-img> </div>
<q-card-section> <q-card-section>
<q-input <q-input
v-model="myNew.title" v-model="myNew.title"
@ -42,79 +44,11 @@
:toolbar="tools"/> :toolbar="tools"/>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-dialog v-model="editImage" persistent> <image-editor
<q-card style="width: 25em;"> v-model="myNew.image"
<q-card-section> collection="news"
<q-select :show="showImgEditor"
v-model="image" @response="showImgEditor = false"/>
:label="$t('image')"
:options="images"
@filter="filterImages"
option-value="name"
option-label="name"
new-value-mode="add"
@new-value="addImage"
emit-value
use-input
hide-selected
fill-input
input-debounce="0">
<template v-slot:option="scope">
<q-item
v-bind="scope.itemProps"
v-on="scope.itemEvents">
<q-item-section avatar>
<q-avatar>
<img :src="`${$imageBase}/${imageCollection}/200x200/${scope.opt.name}`">
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{scope.opt.name}}</q-item-label>
</q-item-section>
</q-item>
</template>
<template v-slot:prepend>
<q-avatar>
<img :src="`${$imageBase}/${imageCollection}/200x200/${image}`">
</q-avatar>
</template>
<template v-slot:after>
<q-btn
icon="add_a_photo"
@click="showUploader = !showUploader"
:title="$t('edit')"
:disable="!image"
:color="showUploader ? 'primary' : null"
flat
round
dense/>
</template>
</q-select>
<q-slide-transition>
<q-uploader
ref="uploader"
v-show="showUploader"
:url="`${$apiBase}/Images/upload`"
auto-upload
class="q-mt-md"
flat
bordered/>
</q-slide-transition>
</q-card-section>
<q-card-actions align="right">
<q-btn
:label="$t('cancel')"
v-close-popup
color="primary"
flat/>
<q-btn
:label="$t('accept')"
@click="onEditImage"
color="primary"
flat/>
</q-card-actions>
</q-card>
</q-dialog>
<q-page-sticky position="bottom-right" :offset="[18, 18]"> <q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn <q-btn
:title="$t(id ? 'save' : 'create')" :title="$t(id ? 'save' : 'create')"
@ -128,10 +62,14 @@
<script> <script>
import Page from 'components/Page' import Page from 'components/Page'
import ImageEditor from 'components/ImageEditor'
export default { export default {
name: 'New', name: 'New',
mixins: [Page], mixins: [Page],
components: {
ImageEditor
},
data () { data () {
return { return {
myNew: { text: '' }, myNew: { text: '' },
@ -139,10 +77,7 @@ export default {
tags: null, tags: null,
priorities: [1, 2, 3], priorities: [1, 2, 3],
image: null, image: null,
editImage: false, showImgEditor: false,
showUploader: false,
imageCollection: 'news',
images: [],
tools: [ tools: [
['left', 'center', 'right', 'justify'], ['left', 'center', 'right', 'justify'],
['bold', 'italic', 'underline', 'removeFormat'], ['bold', 'italic', 'underline', 'removeFormat'],
@ -194,19 +129,6 @@ export default {
watch: { watch: {
'this.$route.params.id': function () { 'this.$route.params.id': function () {
this.loadNew() this.loadNew()
},
image () {
this.showUploader = false
},
showUploader () {
this.$refs.uploader.reset()
},
editImage () {
if (this.editImage) {
this.image = this.myNew.image
} else {
this.showUploader = false
}
} }
}, },
methods: { methods: {
@ -229,7 +151,7 @@ export default {
this.$axios.get(`News/${this.id}`, { params: { filter } }) this.$axios.get(`News/${this.id}`, { params: { filter } })
.then(res => (this.myNew = res.data)) .then(res => (this.myNew = res.data))
} else { } else {
this.new = { this.myNew = {
userFk: this.$state.userId, userFk: this.$state.userId,
tag: 'new', tag: 'new',
priority: 1, priority: 1,
@ -253,31 +175,10 @@ export default {
this.$q.notify({ this.$q.notify({
message: this.$t(message), message: this.$t(message),
icon: 'check', icon: 'check',
color: 'green-6' color: 'green'
}) })
this.$router.go(-1) this.$router.go(-1)
}) })
},
filterImages (val, update, abort) {
let filter = {
where: {
name: { like: `${val}%` },
collectionFk: this.imageCollection
},
limit: !val ? 50 : undefined
}
this.$axios.get(`Images`, { params: { filter } })
.then(res => {
update(() => (this.images = res.data))
})
.catch(() => abort())
},
onEditImage () {
this.myNew.image = this.image
this.editImage = false
},
addImage (inputValue, doneFn) {
console.log(inputValue)
} }
} }
} }

View File

@ -14,7 +14,7 @@
clickable clickable
v-ripple> v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-avatar rounded> <q-avatar @click.native="onImgClick($event, myNew.image)" rounded>
<img :src="`${$imageBase}/news/200x200/${myNew.image}`"> <img :src="`${$imageBase}/news/200x200/${myNew.image}`">
</q-avatar> </q-avatar>
</q-item-section> </q-item-section>
@ -42,6 +42,10 @@
</q-item> </q-item>
</q-list> </q-list>
</q-card> </q-card>
<full-image
ref="fullImage"
v-model="image"
collection="news"/>
<q-dialog v-model="confirm" persistent> <q-dialog v-model="confirm" persistent>
<q-card> <q-card>
<q-card-section class="row items-center"> <q-card-section class="row items-center">
@ -74,12 +78,17 @@
</template> </template>
<script> <script>
import Page from 'components/Page'
export default { export default {
name: 'News', name: 'News',
mixins: [Page],
data () { data () {
return { return {
news: null, news: null,
confirm: false confirm: false,
showImage: false,
image: null
} }
}, },
mounted () { mounted () {
@ -117,6 +126,11 @@ export default {
this.news.splice(index, 1) this.news.splice(index, 1)
this.$q.notify(this.$t('removedSuccess')) this.$q.notify(this.$t('removedSuccess'))
}) })
},
onImgClick (event, image) {
this.image = image
this.$refs.fullImage.show()
event.preventDefault()
} }
} }
} }

View File

@ -1,26 +1,28 @@
<template> <template>
<div class="vn-pp row justify-center"> <div class="q-pa-sm">
<q-card class="vn-w-md"> <div
<q-list bordered> class="row justify-center"
style="max-width: 60em; margin: 0 auto;">
<a <a
v-for="link in links" v-for="link in links"
:key="link.id" :key="link.id"
:href="link.link"> :href="link.link"
<q-item clickable> class="link q-ma-sm">
<q-item-section avatar> <q-card
<q-avatar square> class="clickable"
<img :src="`${$imageBase}/link/full/${link.image}`"> style="width: 11em;">
</q-avatar> <q-card-section class="row justify-center">
</q-item-section> <img
<q-item-section> :src="`${$imageBase}/link/full/${link.image}`"
<q-item-label>{{link.name}}</q-item-label> style="width: 5em;">
<q-item-label caption>{{link.description}}</q-item-label> </q-card-section>
</q-item-section> <q-card-section style="height: 7em; overflow: hidden;">
</q-item> <div class="text-subtitle1 ellipsis">{{link.name}}</div>
<q-separator/> <div class="text-caption">{{link.description}}</div>
</q-card-section>
</q-card>
</a> </a>
</q-list> </div>
</q-card>
</div> </div>
</template> </template>
@ -29,6 +31,11 @@
display block display block
text-decoration inherit text-decoration inherit
color: inherit color: inherit
.q-card.clickable
transition background 300ms ease-out
&:hover
background rgba(255, 255, 255, .2)
</style> </style>
<script> <script>

View File

@ -8,19 +8,23 @@
flat/> flat/>
</toolbar> </toolbar>
<q-card class="vn-w-md"> <q-card class="vn-w-md">
<q-card-section class="q-gutter-md"> <q-card-section>
<q-input <q-input
v-model="user.name" v-model="user.name"
:label="$t('userName')" :label="$t('userName')"
readonly/> readonly/>
</q-card-section> </q-card-section>
<q-card-section class="q-gutter-md"> <q-card-section>
<q-input v-model="user.email" :label="$t('email')" /> <q-input
v-model="user.email"
:label="$t('email')" />
</q-card-section> </q-card-section>
<q-card-section class="q-gutter-md"> <q-card-section>
<q-input v-model="user.nickname" :label="$t('nickname')" /> <q-input
v-model="user.nickname"
:label="$t('nickname')" />
</q-card-section> </q-card-section>
<q-card-section class="q-gutter-md"> <q-card-section>
<q-select <q-select
v-model="user.lang" v-model="user.lang"
:label="$t('language')" :label="$t('language')"
@ -29,11 +33,6 @@
option-label="name" option-label="name"
emit-value/> emit-value/>
</q-card-section> </q-card-section>
<q-card-section class="q-gutter-md">
<q-checkbox
v-model="receiveInvoices"
:label="$t('receiveInvoiceByEmail')" />
</q-card-section>
</q-card> </q-card>
<q-dialog v-model="changePassword" persistent> <q-dialog v-model="changePassword" persistent>
<q-card style="width: 25em;"> <q-card style="width: 25em;">
@ -170,7 +169,7 @@ export default {
this.$q.notify({ this.$q.notify({
message: this.$t('passwordChanged'), message: this.$t('passwordChanged'),
icon: 'check', icon: 'check',
color: 'green-6' color: 'green'
}) })
}, },
onSave () { onSave () {
@ -178,7 +177,7 @@ export default {
.then(res => (this.$q.notify({ .then(res => (this.$q.notify({
message: this.$t('dataSaved'), message: this.$t('dataSaved'),
icon: 'check', icon: 'check',
color: 'green-6' color: 'green'
}))) })))
} }
} }

View File

@ -4,58 +4,47 @@
<q-input <q-input
v-model="search" v-model="search"
debounce="500" debounce="500"
class="q-mr-sm"
dark dark
dense dense
standout> standout>
<template v-slot:append> <template v-slot:append>
<q-icon <q-icon
v-if="search === ''" v-if="search === ''"
name="search" name="search"/>
/>
<q-icon <q-icon
v-else v-else
name="clear" name="clear"
class="cursor-pointer" class="cursor-pointer"
@click="search = ''" @click="search = ''"/>
/>
</template> </template>
</q-input> </q-input>
</toolbar> </toolbar>
<div> <div>
<div <lb-scroll
v-if="!users || !users.length" ref="scroll"
class="text-subtitle1 text-center text-grey-7 q-pa-md"> :request="request"
<span v-if="!users"> @data="data = arguments[0]">
{{$t('setSearchFilter')}} <q-card class="vn-w-md">
</span> <q-list bordered separator>
<span v-else> <q-item
{{$t('noDataFound')}} v-for="user in data"
</span> :key="user.id"
</div> :to="`/access-log/${user.id}`"
<q-card class="vn-w-md"> :title="$t('accessLog')"
<q-list bordered separator> clickable>
<q-item <q-item-section>
v-for="user in users" <q-item-label>{{user.nickname}}</q-item-label>
:key="user.id" <q-item-label caption>#{{user.id}}</q-item-label>
:to="`/access-log/${user.id}`" <q-item-label caption>{{user.name}}</q-item-label>
:title="$t('accessLog')" </q-item-section>
clickable> </q-item>
<q-item-section> </q-list>
<q-item-label>{{user.nickname}}</q-item-label> </q-card>
<q-item-label caption>#{{user.id}}</q-item-label> </lb-scroll>
<q-item-label caption>{{user.name}}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div> </div>
</div> </div>
</template> </template>
<style lang="stylus" scoped>
</style>
<script> <script>
import Page from 'components/Page' import Page from 'components/Page'
@ -64,51 +53,47 @@ export default {
mixins: [Page], mixins: [Page],
data () { data () {
return { return {
users: null,
search: '' search: ''
} }
}, },
mounted () {
this.onRouteChange(this.$route)
},
beforeRouteUpdate (to, from, next) { beforeRouteUpdate (to, from, next) {
this.onRouteChange(to)
next() next()
this.$refs.scroll.reload()
}, },
watch: { watch: {
search (value) { search (value) {
let location = {} let query
if (value) location.query = { search: value } if (value) query = { search: value }
this.$router.push(location) this.$router.push({ query })
} }
}, },
methods: { methods: {
onRouteChange (route) { request () {
this.users = null this.search = this.$route.query.search || ''
this.search = route.query.search || '' if (!this.search) return null
if (!this.search) return
let where = {} let where = {}
if (/^[0-9]+$/.test(this.search)) { if (/^[0-9]+$/.test(this.search)) {
where.id = this.search where.id = this.search
} else { } else {
let like = { like: `%${this.search}%` }
where = { where = {
or: [ or: [
{ name: { like: `%${this.search}%` } }, { name: like },
{ nickname: { like: `%${this.search}%` } } { nickname: like }
] ]
} }
} }
let filter = { return {
fields: ['id', 'nickname', 'name', 'active'], url: 'Accounts',
order: 'name', filter: {
limit: 25, fields: ['id', 'nickname', 'name', 'active'],
where order: 'name',
where
}
} }
this.$axios.get('Accounts', { params: { filter } })
.then(res => (this.users = res.data))
} }
} }
} }

View File

@ -48,39 +48,38 @@
</div> </div>
</q-drawer> </q-drawer>
<div> <div>
<div <lb-scroll
v-if="visits && !visits.length" ref="scroll"
class="text-subtitle1 text-center text-grey-7 q-pa-md"> :request="request"
{{$t('noDataFound')}} @data="data = arguments[0]">
</div> <q-card class="vn-w-md">
<q-card class="vn-w-md"> <q-list bordered separator>
<q-list bordered separator> <q-item
<q-item v-for="visit in data"
v-for="visit in visits" :key="visit.browser">
:key="visit.browser"> <q-item-section>
<q-item-section> <q-item-label>
<q-item-label> {{visit.browser}} {{visit.minVersion}} - {{visit.maxVersion}}
{{visit.browser}} {{visit.minVersion}} - {{visit.maxVersion}} </q-item-label>
</q-item-label> <q-item-label caption>
<q-item-label caption> {{$t('visitsCount', [visit.visits, visit.newVisits])}}
{{$t('visitsCount', [visit.visits, visit.newVisits])}} </q-item-label>
</q-item-label> <q-item-label caption>
<q-item-label caption> {{visit.lastVisit | relTime}}
{{visit.lastVisit | relTime}} </q-item-label>
</q-item-label> </q-item-section>
</q-item-section> </q-item>
</q-item> </q-list>
</q-list> </q-card>
</q-card> </lb-scroll>
</div> </div>
<q-page-sticky position="bottom-right" :offset="[18, 18]"> <q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn <q-btn
fab fab
icon="refresh" icon="refresh"
color="accent" color="accent"
@click="refresh" @click="$refs.scroll.reload()"
:title="$t('refresh')" :title="$t('refresh')"/>
/>
</q-page-sticky> </q-page-sticky>
</div> </div>
</template> </template>
@ -95,7 +94,6 @@ export default {
data () { data () {
let today = date.formatDate(new Date(), 'YYYY/MM/DD') let today = date.formatDate(new Date(), 'YYYY/MM/DD')
return { return {
visits: null,
from: today, from: today,
to: today, to: today,
count: null, count: null,
@ -106,18 +104,15 @@ export default {
created () { created () {
this.$state.useRightDrawer = true this.$state.useRightDrawer = true
}, },
mounted () {
this.refresh()
},
methods: { methods: {
refresh () { request () {
let params = { return {
from: new Date(this.from), url: 'Visits/listByBrowser',
to: new Date(this.to) params: {
from: new Date(this.from),
to: new Date(this.to)
}
} }
this.visits = null
return this.$axios.get(`Visits/listByBrowser`, { params })
.then(res => (this.visits = res.data))
}, },
optionsFn (date) { optionsFn (date) {
return date <= this.today return date <= this.today
@ -125,15 +120,15 @@ export default {
}, },
watch: { watch: {
from () { from () {
this.refresh() this.$refs.scroll.reload()
}, },
to () { to () {
this.refresh() this.$refs.scroll.reload()
}, },
visits () { data () {
if (this.visits) { if (this.data) {
this.count = this.visits.reduce((a, i) => a + i.visits, 0) this.count = this.data.reduce((a, i) => a + i.visits, 0)
this.newCount = this.visits.reduce((a, i) => a + i.newVisits, 0) this.newCount = this.data.reduce((a, i) => a + i.newVisits, 0)
} else { } else {
this.count = null this.count = null
this.newCount = null this.newCount = null