filebrowser/frontend/src/components/Search.vue

680 lines
17 KiB
Vue

<template>
<div id="search" @click="open" v-bind:class="{ active, ongoing }">
<div id="input">
<button v-if="active" class="action" @click="close" :aria-label="$t('buttons.close')" :title="$t('buttons.close')">
<i class="material-icons">close</i>
</button>
<i v-else class="material-icons">search</i>
<input class="main-input" type="text" @keyup.exact="keyup" @input="submit" ref="input" :autofocus="active"
v-model.trim="value" :aria-label="$t('search.search')" :placeholder="$t('search.search')" />
</div>
<div v-if="isMobile && active" id="result" :class="{ hidden: !active }" ref="result">
<div id="result-list">
<div class="button" style="width: 100%">
Search Context: {{ getContext(this.$route.path) }}
</div>
<ul v-show="results.length > 0">
<li v-for="(s, k) in results" :key="k" @click.stop.prevent="navigateTo(s.url)" style="cursor: pointer">
<router-link to="#" event="">
<i v-if="s.dir" class="material-icons folder-icons"> folder </i>
<i v-else-if="s.audio" class="material-icons audio-icons"> volume_up </i>
<i v-else-if="s.image" class="material-icons image-icons"> photo </i>
<i v-else-if="s.video" class="material-icons video-icons"> movie </i>
<i v-else-if="s.archive" class="material-icons archive-icons"> archive </i>
<i v-else class="material-icons file-icons"> insert_drive_file </i>
<span class="text-container">
{{ basePath(s.path) }}<b>{{ baseName(s.path) }}</b>
</span>
</router-link>
</li>
</ul>
<p v-show="isEmpty && isRunning" id="renew">
<i class="material-icons spin">autorenew</i>
</p>
<div v-show="isEmpty && !isRunning">
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
</div>
</div>
<template v-if="isEmpty">
<button class="mobile-boxes" v-if="value.length === 0 && !showBoxes" @click="resetSearchFilters()">
Reset filters
</button>
<template v-if="value.length === 0 && showBoxes">
<div class="boxes">
<h3>{{ $t("search.types") }}</h3>
<div>
<div class="mobile-boxes" tabindex="0" v-for="(v, k) in boxes" :key="k" role="button"
@click="addToTypes('type:' + k)" :aria-label="v.label">
<i class="material-icons">{{ v.icon }}</i>
<p>{{ v.label }}</p>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
<div v-show="!isMobile && active" id="result-desktop" ref="result">
<div class="searchContext">Search Context: {{ getContext(this.$route.path) }}</div>
<div id="result-list">
<template>
<p v-show="isEmpty && isRunning" id="renew">
<i class="material-icons spin">autorenew</i>
</p>
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
<div class="helpButton" @click="toggleHelp()">Help</div>
</div>
<div class="helpText" v-if="showHelp">
<p>
Search occurs on each character you type (3 character minimum for search
terms).
</p>
<p>
<b>The index:</b> Search utilizes the index which automatically gets updated
on the configured interval (default: 5 minutes). Searching when the program
has just started may result in incomplete results.
</p>
<p>
<b>Filter by type:</b> You can have multiple type filters by adding
<code>type:condition</code> followed by search terms.
</p>
<p>
<b>Multiple Search terms:</b> Additional terms separated by <code>|</code>,
for example <code>"test|not"</code> searches for both terms independently.
</p>
<p>
<b>File size:</b> Searching files by size may have significantly longer
search times.
</p>
</div>
<template>
<ButtonGroup :buttons="folderSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
@disableAll="folderSelectClicked()" @enableAll="resetButtonGroups()" />
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled" />
<div class="sizeConstraints">
<div class="sizeInputWrapper">
<p>Smaller Than:</p>
<input class="sizeInput" v-model="smallerThan" type="number" min="0" placeholder="number" />
<p>MB</p>
</div>
<div class="sizeInputWrapper">
<p>Larger Than:</p>
<input class="sizeInput" v-model="largerThan" type="number" placeholder="number" />
<p>MB</p>
</div>
</div>
</template>
</template>
<ul v-show="results.length > 0">
<li v-for="(s, k) in results" :key="k" @click.stop.prevent="navigateTo(s.url)" style="cursor: pointer">
<router-link to="#" event="">
<i v-if="s.dir" class="material-icons folder-icons"> folder </i>
<i v-else-if="s.audio" class="material-icons audio-icons"> volume_up </i>
<i v-else-if="s.image" class="material-icons image-icons"> photo </i>
<i v-else-if="s.video" class="material-icons video-icons"> movie </i>
<i v-else-if="s.archive" class="material-icons archive-icons"> archive </i>
<i v-else class="material-icons file-icons"> insert_drive_file </i>
<span class="text-container">
{{ basePath(s.path) }}<b>{{ baseName(s.path) }}</b>
</span>
</router-link>
</li>
</ul>
</div>
</div>
</div>
</template>
<style>
.main-input {
width: 100%;
}
.searchContext {
width: 100%;
padding: 0.5em 1em;
background: var(--blue);
color: white;
border-left: 1px solid gray;
border-right: 1px solid gray;
}
#result-desktop #result-list {
-webkit-transition: width 0.3s ease 0s;
transition: width 0.3s ease 0s;
max-width: 100%;
overflow: auto;
width: 30em;
padding-top: 0em;
border-width: 1px;
border-style: solid;
border-radius: 1em;
border-top: none;
border-top-width: initial;
border-top-style: none;
border-top-color: initial;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
max-height: 80vh;
left: 50%;
vertical-align: middle;
position: relative;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
-webkit-box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
box-shadow: 0px 2em 50px 10px rgba(0, 0, 0, 0.3);
background-color: lightgray;
}
#result-desktop {
animation: SlideDown 0.5s forwards;
border-radius: 1em;
}
#search.active #result-desktop ul li a {
display: flex;
align-items: center;
padding: .3em 0;
margin-right: .3em;
}
#result-list.active {
width: 65em !important;
max-width: 85vw !important;
}
/* Animations */
@keyframes SlideDown {
0% {
transform: translateY(-3em);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
/* Search */
#search {
flex-basis: auto;
min-width: 35em;
height: 3em;
display: flex;
flex-direction: column;
}
#search #input {
background-color: rgba(100, 100, 100, 0.2);
display: flex;
height: 100%;
padding: 0em 0.75em;
border-style: solid;
border-color: #ccc;
border-radius: 1em;
border-style: solid;
border-color: #a7a7a7;
border-width: 1px;
transition: 1s ease all;
align-items: center;
z-index: 3;
}
#search input {
border: 0;
background-color: transparent;
padding: 0;
}
#result-list p {
margin: 1em;
}
/* Hiding scrollbar for Chrome, Safari and Opera */
#result-list::-webkit-scrollbar {
display: none;
}
/* Hiding scrollbar for IE, Edge and Firefox */
#result-list {
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE and Edge */
}
.text-container {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: left;
direction: rtl;
}
#search #result {
padding-top: 1em;
overflow: hidden;
background: white;
display: flex;
top: -4em;
flex-direction: column;
align-items: center;
text-align: left;
color: rgba(0, 0, 0, 0.6);
height: 0;
transition: 2s ease height, 2s ease padding;
z-index: 3;
}
body.rtl #search #result {
direction: ltr;
}
#search #result>div>*:first-child {
margin-top: 0;
}
body.rtl #search #result {
direction: rtl;
text-align: right;
}
/* Search Results */
body.rtl #search #result ul>* {
direction: ltr;
text-align: left;
}
#search ul {
margin-top: 1em;
padding: 0;
list-style: none;
}
#search li {
margin: 0.5em;
}
#search #renew {
width: 100%;
text-align: center;
display: none;
margin: 1em;
max-width: none;
}
#search.ongoing #renew {
display: block;
}
#search.active #input {
border-bottom-left-radius: 1px;
background-color: lightgray;
border-color: gray;
border-bottom-style: none;
border-bottom-right-radius: 1px;
}
/* Search Input Placeholder */
#search::-webkit-input-placeholder {
color: rgba(255, 255, 255, 0.5);
}
#search:-moz-placeholder {
opacity: 1;
color: rgba(255, 255, 255, 0.5);
}
#search::-moz-placeholder {
opacity: 1;
color: rgba(255, 255, 255, 0.5);
}
#search:-ms-input-placeholder {
color: rgba(255, 255, 255, 0.5);
}
/* Search Boxes */
#search .boxes {
margin: 1em;
text-align: center;
}
#search .boxes h3 {
margin: 0;
font-weight: 500;
font-size: 1em;
color: #212121;
padding: 0.5em;
}
body.rtl #search .boxes h3 {
text-align: right;
}
#search .boxes p {
margin: 1em 0 0;
}
#search .boxes i {
color: #fff !important;
font-size: 3.5em;
}
.mobile-boxes {
cursor: pointer;
overflow: hidden;
margin-bottom: 1em;
background: var(--blue);
color: white;
padding: 1em;
border-radius: 1em;
text-align: center;
}
/* Hiding scrollbar for Chrome, Safari and Opera */
.mobile-boxes::-webkit-scrollbar {
display: none;
}
/* Hiding scrollbar for IE, Edge and Firefox */
.mobile-boxes {
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE and Edge */
}
.helpText {
padding: 1em;
}
.sizeConstraints {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-content: center;
margin: 1em;
justify-content: center;
}
.sizeInput {
height: 100%;
text-align: center;
width: 5em;
border-radius: 1em;
padding: 1em;
backdrop-filter: invert(.1);
border: none !important;
}
.sizeInputWrapper {
border-radius: 1em;
margin-left: 0.5em;
margin-right: 0.5em;
display: -ms-flexbox;
display: flex;
background-color: rgb(245, 245, 245);
padding: 0.25em;
height: 3em;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid #ccc
}
.helpButton {
position: absolute;
right: 10px;
cursor: pointer;
text-align: center;
background: rgb(211, 211, 211);
padding: 0.25em;
border-radius: 0.25em;
}
.searchPrompt {
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
align-items: center;
}
</style>
<script>
import ButtonGroup from "./ButtonGroup.vue";
import { mapState, mapGetters, mapMutations } from "vuex";
import { search } from "@/api";
var boxes = {
folder: { label: "folders", icon: "folder" },
file: { label: "files", icon: "insert_drive_file" },
archive: { label: "archives", icon: "archive" },
image: { label: "images", icon: "photo" },
audio: { label: "audio files", icon: "volume_up" },
video: { label: "videos", icon: "movie" },
doc: { label: "documents", icon: "picture_as_pdf" },
};
export default {
components: {
ButtonGroup,
},
name: "search",
data: function () {
return {
largerThan: "",
smallerThan: "",
noneMessage: "Start typing 3 or more characters to begin searching.",
searchTypes: "",
isTypeSelectDisabled: false,
showHelp: false,
folderSelect: [
{ label: "Only Folders", value: "type:folder" },
{ label: "Only Files", value: "type:file" },
],
typeSelect: [
{ label: "Photos", value: "type:image" },
{ label: "Audio", value: "type:audio" },
{ label: "Videos", value: "type:video" },
{ label: "Documents", value: "type:docs" },
{ label: "Archives", value: "type:archive" },
],
value: "",
width: window.innerWidth,
active: false,
ongoing: false,
results: [],
reload: false,
scrollable: null,
};
},
watch: {
active(active) {
const resultList = document.getElementById("result-list");
if (!active) {
resultList.classList.remove("active");
return
}
setTimeout(() => {
resultList.classList.add("active");
}, 100);
},
show(val, old) {
this.active = val === "search";
if (old === "search" && !this.active) {
if (this.reload) {
this.setReload(true);
}
document.body.style.overflow = "auto";
this.ongoing = false;
this.results = [];
this.value = "";
this.active = false;
this.$refs.input.blur();
} else if (this.active) {
this.reload = false;
this.$refs.input.focus();
document.body.style.overflow = "hidden";
}
},
value() {
if (this.results.length) {
this.ongoing = false;
this.results = [];
}
},
},
computed: {
...mapState(["user", "show"]),
...mapGetters(["isListing"]),
showBoxes() {
return this.searchTypes == "";
},
boxes() {
return boxes;
},
isEmpty() {
return this.results.length === 0;
},
text() {
if (this.ongoing) {
return "";
}
return this.value === ""
? this.$t("search.typeToSearch")
: this.$t("search.pressToSearch");
},
isMobile() {
return this.width <= 800;
},
isRunning() {
return this.ongoing;
},
searchHelp() {
return this.showHelp;
},
},
mounted() {
window.addEventListener("resize", this.handleResize);
this.handleResize(); // Call this once to set the initial width
},
methods: {
handleResize() {
this.width = window.innerWidth;
},
async navigateTo(url) {
this.closeHovers();
await this.$nextTick();
setTimeout(() => this.$router.push(url), 0);
},
getContext(url) {
url = url.slice(1);
let path = "./" + url.substring(url.indexOf("/") + 1);
return path.replace(/\/+$/, "") + "/";
},
basePath(str) {
let parts = str.split("/");
if (parts.length <= 2) {
return "/";
}
parts.pop();
parts = parts.join("/") + "/";
if (str.endsWith("/")) {
parts = "/" + parts;
}
return parts;
},
baseName(str) {
let parts = str.replace(/(\/$|^\/)/, "").split("/");
return parts.pop();
},
...mapMutations(["showHover", "closeHovers", "setReload"]),
open() {
this.showHover("search");
},
close(event) {
event.stopPropagation();
this.closeHovers();
},
keyup(event) {
if (event.keyCode === 27) {
this.close(event);
return;
}
this.results.length === 0;
},
addToTypes(string) {
if (this.searchTypes.includes(string)) {
return true;
}
if (string == null || string == "") {
return false;
}
this.searchTypes = this.searchTypes + string + " ";
},
resetSearchFilters() {
this.searchTypes = "";
},
removeFromTypes(string) {
if (string == null || string == "") {
return false;
}
this.searchTypes = this.searchTypes.replace(string + " ", "");
if (this.isMobile) {
this.$refs.input.focus();
}
},
folderSelectClicked() {
this.isTypeSelectDisabled = true; // Disable the other ButtonGroup
},
resetButtonGroups() {
this.isTypeSelectDisabled = false;
},
async submit(event) {
this.showHelp = false;
event.preventDefault();
if (this.value === "" || this.value.length < 3) {
this.ongoing = false;
this.results = [];
this.noneMessage = "Not enough characters to search (min 3)";
return;
}
let searchTypesFull = this.searchTypes;
if (this.largerThan != "") {
searchTypesFull = searchTypesFull + "type:largerThan=" + this.largerThan + " ";
}
if (this.smallerThan != "") {
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
}
let path = this.$route.path;
this.ongoing = true;
try {
this.results = await search(path, searchTypesFull + this.value);
} catch (error) {
this.$showError(error);
}
this.ongoing = false;
if (this.results.length == 0) {
this.noneMessage = "No results found in indexed search.";
}
},
toggleHelp() {
this.showHelp = !this.showHelp;
},
},
};
</script>