add photo management page

This commit is contained in:
MarcZierle 2022-01-21 20:38:29 +01:00
parent 29c6777a03
commit 08b8e0c35c
8 changed files with 471 additions and 5 deletions

View File

@ -7,7 +7,9 @@
<n-config-provider :theme="null"> <n-config-provider :theme="null">
<n-message-provider> <n-message-provider>
<NavBar v-if="doesntNeedNav" /> <NavBar v-if="doesntNeedNav" />
<router-view /> <div :class="{'body_wrapper' : doesntNeedNav}">
<router-view />
</div>
</n-message-provider> </n-message-provider>
</n-config-provider> </n-config-provider>
</div> </div>
@ -53,4 +55,9 @@ body,html {
font-family: v-sans; font-family: v-sans;
font-weight: 400; font-weight: 400;
} }
.body_wrapper {
max-width: 900px;
margin: auto;
}
</style> </style>

View File

@ -3,7 +3,7 @@ import axios from 'axios'
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: 'https://server.riezel.com/api/v1', baseURL: 'https://server.riezel.com/api/v1',
withCredentials: false, withCredentials: false,
timeout: 10000, timeout: 120000,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -80,4 +80,8 @@ export function addNewPhoto(original_image, cropped_image=null, group_id=null, o
} }
return apiClient.post('/addphoto/', data, config) return apiClient.post('/addphoto/', data, config)
}
export function updatePhoto(photo) {
return apiClient.put('/updatephoto/'+photo.id+'/', photo)
} }

View File

@ -0,0 +1,266 @@
<template>
<div
class="card"
:style="card_styles"
@mouseover="setHoverIfLoaded"
@mouseleave="hover=false"
>
<div
class="cover"
:style="cover_styles"
ref="cover"
@click="toggleSelection"
>
<n-skeleton v-if="!isImgLoaded" :height="height" :width="width" />
<div v-if="isImgLoaded" class="selection" :style="selection_styles">
<n-icon size="4em" color="rgba(255,255,255,0.75)"><Check /></n-icon>
</div>
</div>
<div
class="options"
:style="options_styles"
>
<div class="content">
<div class="options-item" v-if="can_change_group">
<n-button text @click="$emit('update:group')">
<template #icon><n-icon><SortFilled /></n-icon></template>
Change Group
</n-button>
</div>
<div class="options-item" v-if="can_crop">
<n-button text @click="$emit('update:crop')">
<template #icon><n-icon><CropRotateFilled /></n-icon></template>
Crop Photo
</n-button>
</div>
<div class="options-item" v-if="can_change_ocr">
<n-button text @click="$emit('update:ocr')">
<template #icon><n-icon><TextAnnotationToggle /></n-icon></template>
Change OCR
</n-button>
</div>
<div class="options-item" v-if="can_delete">
<n-button text type="error" @click="$emit('update:delete')">
<template #icon><n-icon><TrashBinSharp /></n-icon></template>
Delete Photo
</n-button>
</div>
</div>
</div>
</div>
</template>
<script>
import TextAnnotationToggle from '@vicons/carbon/TextAnnotationToggle'
import TrashBinSharp from '@vicons/ionicons5/TrashBinSharp'
import SortFilled from '@vicons/material/SortFilled'
import CropRotateFilled from '@vicons/material/CropRotateFilled'
import Check from '@vicons/fa/Check'
export default {
components: {
TextAnnotationToggle,
TrashBinSharp,
SortFilled,
CropRotateFilled,
Check,
},
props: {
src: {
type: String,
required: false,
default: '',
},
can_change_group: {
type: Boolean,
required: false,
default: false,
},
can_select: {
type: Boolean,
required: false,
default: false,
},
can_change_ocr: {
type: Boolean,
required: false,
default: false,
},
can_crop: {
type: Boolean,
required: false,
default: false,
},
can_delete: {
type: Boolean,
required: false,
default: false,
},
width: {
type: Number,
required: false,
default: 150,
}
},
emits: [
'update:select',
'update:delet',
'update:crop',
'update:ocr',
'update:group',
],
data() {
return {
hover: false,
selected: false,
isImgLoaded: false,
imgSrcObj: null,
}
},
beforeMount() {
this.imgSrcObj = new Image()
this.imgSrcObj.src = this.src
},
watch: {
imgSrcObj: {
handler: function () {
if (this.imgSrcObj.complete) {
URL.revokeObjectURL(this.imgSrcObj.src)
this.isImgLoaded = true
}
},
deep: true,
}
},
computed: {
height() { return this.width * 1.41421356 },
options_height() {
return [
this.can_change_group,
this.can_crop,
this.can_change_ocr,
this.can_delete
].filter(Boolean).length * 35
},
card_styles() {
if (this.hover) {
return `
z-index: 90;
box-shadow: 0px 0px 13px 5px rgba(0,0,0,0.05);
${/*transform: translateY(-10px);*/{}}
`
}
return ''
},
cover_styles() {
let styles = `
background-image: url(${this.src});
width: ${this.width}px;`
if (this.hover) {
styles += `height: ${this.height - this.options_height + 5}px;`
} else {
styles += `height: ${this.height}px;`
}
return styles
},
selection_styles() {
if (this.selected) {
return 'opacity: 100%;'
}
return 'opacity: 0;'
},
options_styles() {
const base_height = 5
const expand_height = this.options_height
if (!this.hover) {
return `
height: ${base_height}px;
`
} else {
return `
height: ${expand_height}px;
`
}
}
},
methods: {
setHoverIfLoaded() {
if (this.isImgLoaded) {
this.hover = true
}
},
toggleSelection() {
if (this.can_select) {
this.selected = !this.selected
}
}
},
}
</script>
<style scoped>
.card{
transition: all 0.25s;
border-radius: 5px;
}
.cover {
background-repeat: no-repeat;
background-position: center;
background-size: cover;
cursor: pointer;
transition: all 0.25s;
padding: 0;
}
.options {
transition: all 0.25s;
border: 1px solid rgb(238, 238, 238);
border-top: 0;
overflow: hidden;
}
.options .content {
padding: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.options .options-item {
vertical-align: middle;
cursor: pointer;
width: 100%;
padding: 0.125em 1em 0.125em 1em;
margin: 0;
}
.options .options-item:hover {
background-color: rgb(245, 245, 245);
}
.selection {
width: 100%;
height: 100%;
background-color: rgba(0, 162, 255, 0.432);
text-align: center;
vertical-align: middle;
transition: all 0.15s;
}
.selection .n-icon {
transform: translateY(50%);
}
</style>

View File

@ -57,9 +57,14 @@
<script> <script>
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
//import PhotoItem from '@/components/PhotoItem'
export default { export default {
name: 'PhotoSelectModal', name: 'PhotoSelectModal',
emits: [ 'closed', 'selected' ], emits: [ 'closed', 'selected' ],
components: {
//PhotoItem
},
props: { props: {
showSelection: { showSelection: {
type: Boolean, type: Boolean,

View File

@ -5,6 +5,7 @@ import HomeView from '../views/Home.vue'
import LogsList from '../views/LogsList.vue' import LogsList from '../views/LogsList.vue'
import CreateLog from '../views/CreateLog.vue' import CreateLog from '../views/CreateLog.vue'
import CameraCapture from '../views/CameraCapture.vue' import CameraCapture from '../views/CameraCapture.vue'
import ManagePhotos from '../views/ManagePhotos.vue'
const routes = [ const routes = [
{ {
@ -34,6 +35,11 @@ const routes = [
name: 'CameraCapture', name: 'CameraCapture',
component: CameraCapture component: CameraCapture
}, },
{
path: '/photos',
name: 'ManagePhotos',
component: ManagePhotos
},
] ]
const router = createRouter({ const router = createRouter({

View File

@ -6,14 +6,15 @@ import {
getPhotoLog, getPhotoLog,
updatePhotoLog, updatePhotoLog,
getPhotoGroups, getPhotoGroups,
getPhotosByGroup getPhotosByGroup,
updatePhoto,
} from '@/api' } from '@/api'
export default createStore({ export default createStore({
state: { state: {
photoLogList: [], photoLogList: [],
photoLogs: [], photoLogs: [],
photoGroups: [], photoGroups: null,
photos: [] photos: []
}, },
mutations: { mutations: {
@ -41,6 +42,22 @@ export default createStore({
} else { } else {
state.photos = {...state.photos, [group_id]: photos} state.photos = {...state.photos, [group_id]: photos}
} }
},
UPDATE_PHOTO(state, photo) {
for (const idx in state.photos) {
let group = state.photos[idx]
let photo_index = group.findIndex(p => p.id == photo.id)
if (photo_index > -1) {
let state_photo = state.photos[idx].splice(photo_index, 1)[0]
state_photo.group = photo.group
state_photo.bbox_coords = photo.bbox_coords
state_photo.ocr_text = photo.ocr_text
if (!state.photos[photo.group] || state.photos[photo.group].length === 0) {
state.photos[photo.group] = []
}
state.photos[photo.group].push(state_photo)
}
}
} }
}, },
actions: { actions: {
@ -115,6 +132,16 @@ export default createStore({
const group_id = getters.photoGroups[index].id const group_id = getters.photoGroups[index].id
dispatch('loadPhotosInGroup', group_id) dispatch('loadPhotosInGroup', group_id)
} }
},
updatePhoto({commit}, photo) {
new Promise((resolve, reject) => {
updatePhoto(photo).then(() => {
commit('UPDATE_PHOTO', photo)
resolve()
}).catch((error) => {
reject(error)
})
})
} }
}, },
getters: { getters: {

View File

@ -34,7 +34,9 @@
<n-icon><CameraAdd20Filled /></n-icon> <n-icon><CameraAdd20Filled /></n-icon>
</n-button> </n-button>
</router-link> </router-link>
<n-button>Crop Photos</n-button> <router-link :to="{name: 'ManagePhotos'}">
<n-button>Manage Photos</n-button>
</router-link>
</n-space> </n-space>
</n-space> </n-space>
</n-space> </n-space>

149
src/views/ManagePhotos.vue Normal file
View File

@ -0,0 +1,149 @@
<template>
<div>
<h1>Manage Photos</h1>
<n-collapse
v-if="photoGroups && photoGroups.length > 0"
@item-header-click="loadPhotosInGroup"
>
<n-collapse-item
v-for="group in photoGroups" :key="group.id"
:title="group.name"
:name="group.id">
<template #header-extra>{{ group.date }}</template>
<p v-if="!photos[group.id]">loading...</p>
<n-space>
<PhotoItem
v-for="(photo, idx) in photos[group.id]" :key="idx"
:src="getPhotoSrcById(photo.id)"
:can_change_group="true"
:can_crop="true"
:can_change_ocr="true"
:can_delete="true"
@update:group="change_group_modal(photo.id)"
/>
</n-space>
</n-collapse-item>
</n-collapse>
<p v-else>No Photo Groups</p>
<n-modal
v-model:show="showChangeGroupModal"
:mask-closable="true" >
<n-card
style="width: 400px;"
title="Change Photo Group"
:bordered="false"
role="dialog"
aria-modal="true"
>
<n-select v-model:value="new_group" :options="groups_select_options" />
<template #footer>
<n-space justify="end">
<n-button @click="showChangeGroupModal=false">Cancel</n-button>
<n-button type="success" @click="changePhotoGroup">Change Group</n-button>
</n-space>
</template>
</n-card>
</n-modal>
</div>
</template>
<script>
import { useMeta } from 'vue-meta'
import { useMessage } from 'naive-ui'
import PhotoItem from '@/components/PhotoItem'
export default {
components: {
PhotoItem,
},
setup() {
useMeta({ title: 'Manage Photos' })
const message = useMessage()
return { message }
},
data() {
return {
current_photo: null,
showChangeGroupModal: false,
new_group: null,
}
},
computed: {
photoGroups() {
let groups = this.$store.state.photoGroups
if (groups !== null) {
groups.sort((a,b) => {
return a.date < b.date ? 1 : -1
})
let index = groups.indexOf(groups.filter(g=>g.id==1)[0])
groups.unshift(groups.splice(index, 1)[0])
return groups
}
return []
},
photos() {
return this.$store.state.photos
},
groups_select_options() {
if (this.photoGroups.length > 0) {
let options = []
for (const idx in this.photoGroups) {
options.push({
value: this.photoGroups[idx].id,
label: this.photoGroups[idx].name,
})
}
return options
}
return []
},
},
mounted() {
this.$store.dispatch('loadPhotoGroups').catch((error) => {
this.message.error('Cannot load photo groups: ' + error)
})
},
methods: {
loadPhotosInGroup(group_data) {
if (group_data.expanded)
this.$store.dispatch('loadPhotosInGroup', group_data.name)
},
getPhotoSrcById(photo_id){
for (const index in this.photos) {
let group = this.photos[index]
let photo = group.filter((photo) => photo.id == photo_id)
if (photo.length > 0) {
photo = photo[0]
return photo.cropped_image !== null ? photo.cropped_image : photo.original_image
}
}
return null
},
change_group_modal(photo_id) {
this.current_photo = photo_id
this.showChangeGroupModal = true
this.new_group = null
},
changePhotoGroup(){
if (this.new_group && this.current_photo) {
this.$store.dispatch('updatePhoto', {
id: this.current_photo,
group: this.new_group
}).then(()=>{
this.current_photo = null
this.new_group = null
this.showChangeGroupModal = false
})
}
}
},
}
</script>
<style scoped>
</style>