nicer tags selection
This commit is contained in:
parent
f3b8a3247e
commit
01286aede8
4 changed files with 233 additions and 33 deletions
206
frontend/src/components/BadgeSelectField.vue
Normal file
206
frontend/src/components/BadgeSelectField.vue
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
<template>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="badge-list form-control form-control-lg" style="position: relative;">
|
||||||
|
<span v-for="(option, index) in value" :key="index">
|
||||||
|
<slot :option="option" :index="index"></slot>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="invisible-input" v-model="textinput" ref="input"
|
||||||
|
@keydown="keyDown">
|
||||||
|
<ul class="dropdown-menu" style="position: absolute; top: 100%; left: 0; width: 100%; z-index: 100;"
|
||||||
|
v-if="filteredOption.length > 0">
|
||||||
|
<li v-for="(option, index) in filteredOption" :key="index"
|
||||||
|
@mousedown="chooseOption(option)" @mouseover="selectOption(option)"
|
||||||
|
:class="{chosen: localValue.includes(option), selected: selectedTag === option, 'dropdown-item': true}">
|
||||||
|
{{ option }}
|
||||||
|
<b>
|
||||||
|
<b-icon-check class="checkmark"></b-icon-check>
|
||||||
|
<b-icon-x class="cross"></b-icon-x>
|
||||||
|
<b-icon-plus class="plus"></b-icon-plus>
|
||||||
|
</b>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!--button class="btn btn-outline-secondary form-control-lg" type="button" @click="chooseOption">Add</button-->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-list > * {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
padding: .25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-list > input.invisible-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.invisible-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #495057;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right .7rem center;
|
||||||
|
background-size: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invisible-input:focus ~ .dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark, .cross, .plus {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.selected {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
color: var(--bs-light);
|
||||||
|
|
||||||
|
& .plus {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.chosen {
|
||||||
|
& .checkmark {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.chosen.selected {
|
||||||
|
background-color: var(--bs-danger);
|
||||||
|
color: var(--bs-light);
|
||||||
|
|
||||||
|
& .cross {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .checkmark, & .plus {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "BadgeSelectField",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
textinput: "",
|
||||||
|
selectedTag: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
prop: "value",
|
||||||
|
event: "input"
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
availableTags() {
|
||||||
|
return this.options.filter(option => !this.localValue.includes(option));
|
||||||
|
},
|
||||||
|
filteredOption() {
|
||||||
|
return this.options.filter(option => option.includes(this.textinput) || option === this.selectedTag);
|
||||||
|
},
|
||||||
|
localValue: {
|
||||||
|
get() {
|
||||||
|
return this.value;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
console.log("set", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
chooseOption(option) {
|
||||||
|
this.$emit("input", option);
|
||||||
|
},
|
||||||
|
resetSelection() {
|
||||||
|
this.textinput = "";
|
||||||
|
this.selectedTag = null;
|
||||||
|
},
|
||||||
|
selectOption(option) {
|
||||||
|
this.selectedTag = option;
|
||||||
|
},
|
||||||
|
keyDown(event) {
|
||||||
|
const key = event.key;
|
||||||
|
if (key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.chooseOption(this.selectedTag);
|
||||||
|
this.$refs.input.blur();
|
||||||
|
} else if (key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.selectedTag === "") {
|
||||||
|
this.selectedTag = this.filteredOption[0];
|
||||||
|
} else {
|
||||||
|
const index = this.filteredOption.indexOf(this.selectedTag);
|
||||||
|
if (index < this.filteredOption.length - 1) {
|
||||||
|
this.selectedTag = this.filteredOption[index + 1];
|
||||||
|
} else {
|
||||||
|
this.selectedTag = this.filteredOption[0];
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = this.$el.querySelector(".selected");
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({block: "nearest"});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} else if (key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.selectedTag === "") {
|
||||||
|
this.selectedTag = this.filteredOption[this.filteredOption.length - 1];
|
||||||
|
} else {
|
||||||
|
const index = this.filteredOption.indexOf(this.selectedTag);
|
||||||
|
if (index > 0) {
|
||||||
|
this.selectedTag = this.filteredOption[index - 1];
|
||||||
|
} else {
|
||||||
|
this.selectedTag = this.filteredOption[this.filteredOption.length - 1];
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = this.$el.querySelector(".selected");
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({block: "nearest"});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} else if (key === "ArrowRight") {
|
||||||
|
if (event.target.selectionStart === this.textinput.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.chooseOption(this.selectedTag);
|
||||||
|
}
|
||||||
|
} else if (key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.$refs.input.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,43 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="input-group">
|
<badge-select-field :value="this.value" :options="this.tags" @input="addTag" ref="badgeSelect">
|
||||||
<div class="taglist form-control form-control-lg">
|
<template v-slot:default="{option, index}">
|
||||||
<span class="badge bg-dark" v-for="(tag, index) in value" :key="index">
|
<span class="badge bg-dark" @click="removeTag(index)">
|
||||||
{{ tag }}
|
{{ option }}
|
||||||
<b-icon-x-circle @click="removeTag(index)"></b-icon-x-circle>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</template>
|
||||||
<select class="form-select form-control form-control-lg" id="tag" name="tag" v-model="tag">
|
</badge-select-field>
|
||||||
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-outline-secondary form-control-lg" type="button" @click="addTag">Add</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.taglist {
|
.badge {
|
||||||
display: flex;
|
padding: .3rem .5rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: .25rem
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge:hover {
|
||||||
padding: .3rem .5rem
|
background-color: var(--bs-danger) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
|
import BadgeSelectField from "@/components/BadgeSelectField.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "TagField",
|
name: "TagField",
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tag: ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
components: {
|
components: {
|
||||||
|
BadgeSelectField,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
@ -67,10 +56,15 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchTags"]),
|
...mapActions(["fetchTags"]),
|
||||||
addTag() {
|
addTag(tag) {
|
||||||
if (this.tag !== "") {
|
if (this.localValue.includes(tag)) {
|
||||||
this.localValue.push(this.tag);
|
this.localValue.splice(this.localValue.indexOf(tag), 1);
|
||||||
this.tag = "";
|
this.$refs.badgeSelect.resetSelection();
|
||||||
|
} else if (this.availableTags.includes(tag)) {
|
||||||
|
this.localValue.push(tag);
|
||||||
|
this.$refs.badgeSelect.resetSelection();
|
||||||
|
}else{
|
||||||
|
// input does not match any tag
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeTag(index) {
|
removeTag(index) {
|
||||||
|
|
|
@ -31,7 +31,7 @@ class ServerSet {
|
||||||
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const url = "http://" + server + target // TODO https
|
const url = "https://" + server + target // TODO https
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -59,7 +59,7 @@ class ServerSet {
|
||||||
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const url = "http://" + server + target // TODO https
|
const url = "https://" + server + target // TODO https
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -89,7 +89,7 @@ class ServerSet {
|
||||||
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const url = "http://" + server + target // TODO https
|
const url = "https://" + server + target // TODO https
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -119,7 +119,7 @@ class ServerSet {
|
||||||
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const url = "http://" + server + target // TODO https
|
const url = "https://" + server + target // TODO https
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -149,7 +149,7 @@ class ServerSet {
|
||||||
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const url = "http://" + server + target // TODO https
|
const url = "https://" + server + target // TODO https
|
||||||
return await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
@ -252,7 +252,7 @@ export default createStore({
|
||||||
},
|
},
|
||||||
async pushFile({state, dispatch, getters}, {item_id, file}) {
|
async pushFile({state, dispatch, getters}, {item_id, file}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.post(getters.signAuth, '/api/item_files/'+item_id+'/', file)
|
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', file)
|
||||||
if (data.hash) {
|
if (data.hash) {
|
||||||
state.files.push(data)
|
state.files.push(data)
|
||||||
return data
|
return data
|
||||||
|
|
Loading…
Reference in a new issue