SolidScribe/client/src/pages/NotesPage.vue
Max G b0eee636b5 * Made splash page dark and updated description
* Cleaned up unused things
* Updated squire which had a comment typo update...thats it
* Background color picker has matching colors and styles to text color picker
* Added new black theme
* Moved search to main page, show it on mobile and added options to push things to notes from search with experimental tag searching
* Added active note menu buttons based on cursor location in text
* Added more instant updating if app is open in two locations for the same user Scratch Pad and home page update with new notes and new text in real time
2020-05-15 23:12:09 +00:00

758 lines
22 KiB
Vue

<template>
<div class="ui basic segment no-fluf-segment">
<div class="ui grid" ref="content">
<div class="sixteen wide column">
<!-- :class="{ 'sixteen wide column':showOneColumn(), 'sixteen wide column':!showOneColumn() }" -->
<div class="ui stackable grid">
<div class="ten wide column"
:class="{ 'sixteen wide column':$store.getters.getIsUserOnMobile }">
<div class="ui basic button shrinking"
v-on:click="updateFastFilters(3)"
v-if="$store.getters.totals && ($store.getters.totals['sharedToNotes'] > 0 || $store.getters.totals['sharedFromNotes'] > 0)"
style="position: relative;">
<i class="green mail icon"></i>Shared Notes
<span class="floating ui green label" v-if="$store.getters.totals['unreadNotes'] > 0">
{{ $store.getters.totals['unreadNotes'] }}
</span>
</div>
<div class="ui basic button shrinking" v-on:click="updateFastFilters(2)" v-if="$store.getters.totals && $store.getters.totals['archivedNotes'] > 0">
<i class="green archive icon"></i>Archived
<!-- <span>{{ $store.getters.totals['archivedNotes'] }}</span> -->
</div>
<tag-display
:active-tags="searchTags"
v-on:tagClick="tagId => toggleTagFilter(tagId)"
/>
<div class="ui basic shrinking icon button" v-on:click="toggleTitleView()">
<i v-if="titleView" class="th icon"></i>
<i v-if="!titleView" class="bars icon"></i>
</div>
</div>
<div class="six wide column">
<search-input
v-on:tagClick="tagId => toggleTagFilter(tagId)"
v-if="$store.getters.totals && $store.getters.totals['totalNotes']" />
</div>
<div class="eight wide column" v-if="showClear">
<!-- <fast-filters /> -->
<span class="ui fluid green button" @click="reset">
<i class="arrow circle left icon"></i>Show All Notes
</span>
</div>
</div>
</div>
<div class="sixteen wide column" v-if="searchTerm.length > 0 && !loadingInProgress">
<h2 class="ui header">
<div class="content">
{{ searchResultsCount.toLocaleString() }} notes with keyword "{{ searchTerm }}"
<div v-if="searchResultsCount == 0" class="sub header">
Search can only find key words. Try a single word search.
</div>
</div>
</h2>
</div>
<h2 v-if="fastFilters['withLinks'] == 1">Notes with Links</h2>
<h2 v-if="fastFilters['withTags'] == 1">Notes with Tags</h2>
<h2 v-if="fastFilters['onlyArchived'] == 1">Archived Notes</h2>
<h2 v-if="fastFilters['onlyShowSharedNotes'] == 1">Shared Notes</h2>
<h2 v-if="fastFilters['onlyShowEncrypted'] == 1">Password Protected - No longer supported</h2>
<!-- Note title card display -->
<div class="sixteen wide column">
<h3 v-if="$store.getters.totals && $store.getters.totals['totalNotes'] == 0">
No Notes Yet. <br>Thats ok.<br><br> <br>
<img loading="lazy" width="25%" src="/api/static/assets/marketing/hamburger.svg" alt="Create a new note"><br>
Create one when you feel ready.
</h3>
<!-- Go to one wide column, do not do this on mobile interface -->
<div :class="{'one-column':( showOneColumn() )}">
<!-- render each section based on notes in set -->
<div v-for="section,index in noteSections" v-if="section.length > 0" class="note-card-section">
<h5 class="ui tiny dividing header"><i :class="`green ${sectionData[index][0]} icon`"></i>{{ sectionData[index][1] }}</h5>
<div class="note-card-display-area">
<note-title-display-card
v-for="note in section"
:ref="'note-'+note.id"
:onClick="openNote"
:data="note"
:title-view="titleView"
:currently-open="(activeNoteId1 == note.id || activeNoteId2 == note.id)"
:key="note.id + note.color + note.note_highlights.length + note.attachment_highlights.length + ' -' + note.tag_highlights.length + '-' +note.title.length + '-' +note.subtext.length + '-' + note.tag_count + note.updated"
/>
</div>
</div>
<loading-icon v-if="loadingInProgress" message="Decrypting Notes" />
</div>
</div>
<!-- found attachments -->
<div class="sixteen wide column" v-if="foundAttachments.length > 0">
<h4><i class="folder open outline icon"></i> Found in Files ({{ foundAttachments.length }})</h4>
<attachment-display
v-for="item in foundAttachments"
:item="item"
:key="item.id"
:search-params="{}"
/>
</div>
</div>
<input-notes
v-if="activeNoteId1 != null"
:noteid="activeNoteId1"
:position="activeNote1Position"
:url-data="$route.params"
ref="note1" />
<input-notes
v-if="activeNoteId2 != null"
:noteid="activeNoteId2"
:position="activeNote2Position"
ref="note2" />
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'SearchBar',
components: {
'input-notes': () => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue'),
'note-title-display-card': require('@/components/NoteTitleDisplayCard.vue').default,
'fast-filters': require('@/components/FastFilters.vue').default,
'search-input': require('@/components/SearchInput.vue').default,
'attachment-display': require('@/components/AttachmentDisplayCard').default,
'counter':require('@/components/AnimatedCounterComponent.vue').default,
'tag-display':require('@/components/TagDisplayComponent.vue').default,
'loading-icon':require('@/components/LoadingIconComponent.vue').default,
},
data () {
return {
initComponent: true,
commonTags: [],
searchTerm: '',
searchResultsCount: 0,
searchTags: [],
notes: [],
highlights: [],
searchDebounce: null,
fastFilters: {},
titleView: false,
//Load up notes in batches
firstLoadBatchSize: 10, //First set of rapidly loaded notes
batchSize: 25, //Size of batch loaded when user scrolls through current batch
batchOffset: 0, //Tracks the current batch that has been loaded
loadingBatchTimeout: null, //Limit how quickly batches can be loaded
loadingInProgress: false,
scrollLoadEnabled: true,
//Clear button is not visible
showClear: false,
initialPostData: null,
currentPostData: null,
containsNormalNotes: 0,
containsPinnednotes: 0,
containsTextResults: 0,
// containsTagResults: 0,
// containsAttachmentResults: 0,
//Currently open notes in app
activeNoteId1: null,
activeNoteId2: null,
//Position determines how note is Positioned
activeNote1Position: 0,
activeNote2Position: 0,
lastVisibilityState: null,
foundAttachments: [],
sectionData: {
'pinned': ['thumbtack', 'Pinned'],
'archived': ['archive', 'Archived'],
'shared': ['envelope outline', 'Received Notes'],
'sent': ['paper plane outline', 'Shared Notes'],
'notes': ['file','Notes'],
'highlights': ['paragraph', 'Found In Text']
},
noteSections: {
pinned: [],
archived: [],
shared:[],
sent:[],
notes: [],
highlights: []
},
}
},
beforeMount(){
this.$parent.loginGateway()
this.$io.on('new_note_created', noteId => {
//Do not update note if its open
if(this.activeNoteId1 != noteId){
this.updateSingleNote(parseInt(noteId))
}
})
//Update title cards when new note text is saved
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
//Do not update note if its open
if(this.activeNoteId1 != noteId){
this.updateSingleNote(parseInt(noteId))
}
})
//Update totals for app
this.$store.dispatch('fetchAndUpdateUserTotals')
this.$bus.$on('close_active_note', ({position, noteId, modified}) => {
this.closeNote(position)
this.$store.dispatch('fetchAndUpdateUserTotals')
if(modified){
this.updateSingleNote(parseInt(noteId))
}
})
this.$bus.$on('update_single_note', (noteId) => {
this.updateSingleNote(noteId)
})
this.$bus.$on('note_deleted', (noteId) => {
//Remove deleted note from set, its deleted
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key].forEach( (note, index) => {
if(note.id == noteId){
this.noteSections[key].splice(index,1)
this.$store.dispatch('fetchAndUpdateUserTotals')
return
}
})
})
})
this.$bus.$on('update_fast_filters', newFilter => {
this.fastFilters = newFilter
//Fast filters always return all the results and tags
this.search(true, this.batchSize, false).then( () => {
// return
})
})
//Event to update search from other areas
this.$bus.$on('update_search_term', sentInSearchTerm => {
this.searchTerm = sentInSearchTerm
this.search(true, this.batchSize)
.then( () => {
console.log('Search attachments disabled for now')
// this.searchAttachments()
// return
})
})
//New note button pushes open note event
this.$bus.$on('open_note', noteId => {
this.openNote(noteId)
})
//Reload page content
this.$bus.$on('note_reload', () => {
this.reset()
})
//Mount notes on load if note ID is set
if(this.$route.params && this.$route.params.id){
const id = this.$route.params.id
this.openNote(id)
}
window.addEventListener('scroll', this.onScroll)
//Close notes when back button is pressed
window.addEventListener('hashchange', this.hashChangeAction)
//update note on visibility change
document.addEventListener('visibilitychange', this.visibiltyChangeAction);
},
beforeDestroy(){
window.removeEventListener('scroll', this.onScroll)
window.removeEventListener('hashchange', this.hashChangeAction)
document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
this.$bus.$off('note_reload')
this.$bus.$off('close_active_note')
this.$bus.$off('update_single_note')
this.$bus.$off('note_deleted')
this.$bus.$off('update_fast_filters')
this.$bus.$off('update_search_term')
this.$bus.$off('open_note')
//We want to remove event listeners, but something here is messing them up and preventing ALL event listeners from working
// this.$off() // Remove all event listeners
// this.$bus.$off()
},
mounted() {
//Loads initial batch and tags
this.reset()
},
methods: {
toggleTitleView(){
this.titleView = !this.titleView
},
showOneColumn(){
return this.$store.getters.getIsUserOnMobile
//If note 1 or 2 is open, show one column. Or if the user is on mobile
return (this.activeNoteId1 != null || this.activeNoteId2 != null) &&
!this.$store.getters.getIsUserOnMobile
},
openNote(id, event = null){
//Don't open note if a link is clicked in display card
if(event && event.target && event.target.nodeName){
const nodeClick = event.target.nodeName
if(nodeClick == 'A'){ return }
}
//1 note open
if(this.activeNoteId1 == null){
this.activeNoteId1 = id
this.activeNote1Position = 0 //Middel of page
this.$router.push('/notes/open/'+this.activeNoteId1)
return
}
},
closeNote(position){
//One note open, close that note
if(position == 0){
this.activeNoteId1 = null
this.activeNoteId2 = null
}
//Right note closed, thats 1
if(position == 1){
this.activeNoteId1 = null
}
if(position == 2){
this.activeNoteId2 = null
}
//IF two notes get opened, update ID of open note
if(this.activeNoteId1 || this.activeNoteId2){
this.$router.push('/notes/open/'+Math.max(this.activeNoteId1, this.activeNoteId2))
} else {
//No notes are open, just show notes page
this.$router.push('/notes')
}
this.activeNote1Position = 0
this.activeNote2Position = 0
},
toggleTagFilter(tagId){
this.searchTags = [tagId]
//Reset note set and load up notes and tags
if(this.searchTags.length > 0){
this.search(true, this.batchSize)
return
}
//If no tags are selected, reset entire page
this.reset()
},
onScroll(e){
clearTimeout(this.loadingBatchTimeout)
this.loadingBatchTimeout = setTimeout(() => {
//Distance to bottom of page
const bottomOfWindow =
Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop)
+ window.innerHeight
//height of page
const offsetHeight = this.$refs.content.clientHeight
//Determine percentage down the page
const percentageDown = Math.round( (bottomOfWindow/offsetHeight)*100 )
//If greater than 80 of the way down the page, load the next batch
if(percentageDown >= 65 && this.scrollLoadEnabled){
this.search(false, this.batchSize, true)
}
}, 50)
return
},
//Try to close notes on URL hash change /notes/open/123 to /notes - parse 123, close note id 123
hashChangeAction(event){
//Clean up path of hash change
let path = window.location.protocol + '//' + window.location.hostname + window.location.pathname + window.location.hash
let newPath = event.newURL.replace(path,'')
let oldPath = event.oldURL.replace(path,'')
// console.log(this.$route.params)
// console.log(this.$router)
//Open note if user goes forward to a note id
if(this.$route.params && this.$route.params.id){
this.openNote(this.$route.params.id)
}
//If we go from open note ID to no note ID, close the note
if(newPath == '' && oldPath.indexOf('/open/') != -1){
//Pull note ID out of URL
const noteIdToClose = oldPath.split('/').pop()
// console.log(noteIdToClose)
if(this.$refs.note1 && this.$refs.note1.currentNoteId == noteIdToClose){
// this.$refs.note1.close()
}
}
},
visibiltyChangeAction(event){
//Fuck this shit, just use web sockets
return
//@TODO - phase this out, update it via socket.io
//If user leaves page then returns to page, reload the first batch
if(this.lastVisibilityState == 'hidden' && document.visibilityState == 'visible'){
//Load initial batch, then tags, then other batch
this.search(false, this.firstLoadBatchSize)
.then( () => {
// return
})
}
this.lastVisibilityState = document.visibilityState
},
// @TODO Don't even trigger this if the note wasn't changed
updateSingleNote(noteId){
//Find local note, if it exists; continue
//Lookup one note using passed in ID
const postData = {
searchQuery: this.searchTerm,
searchTags: this.searchTags,
fastFilters:{
noteIdSet:[noteId]
}
}
//Note data must be fetched, then sorted into existing note data
axios.post('/api/note/search', postData)
.then(results => {
//Pull note data out of note set
let newNote = results.data.notes[0]
let foundNote = false
if(newNote === undefined){
return
}
//Find Just updated note and modify all its attributes
Object.keys(this.noteSections).forEach(key => {
this.noteSections[key].forEach( (note,index) => {
if(note.id == noteId){
foundNote = true
//Don't move notes that were not changed
if(note.updated == newNote.updated){
// return
}
//Compare note tags, if they changed, reload tags
if(newNote.tag_count != note.tag_count){
}
//go through each prop and update it with new values
Object.keys(newNote).forEach(prop => {
note[prop] = newNote[prop]
})
//Remove note from location and push to front
this.noteSections[key].splice(index, 1)
this.noteSections[key].unshift(note)
this.$nextTick( () => {
//Trigger close animation on note
if(this.$refs['note-'+noteId] && this.$refs['note-'+noteId][0]){
this.$refs['note-'+noteId][0].justClosed()
}
})
return
}
})
})
//New notes don't exist in list, push them to the front
if(!foundNote){
this.noteSections.notes.unshift(newNote)
}
//Trigger section rebuild
this.rebuildNoteCategorise()
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Notes') })
},
searchAttachments(){
axios.post('/api/attachment/textsearch', {'searchTerm':this.searchTerm})
.then(results => {
this.foundAttachments = results.data
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Attachments') })
},
search(showLoading = true, notesInNextLoad = null, mergeExisting = false){
return new Promise((resolve, reject) => {
//Don't double load note batches
if(this.loadingInProgress){
console.log('Loading in progress, cancel operation')
return resolve()
}
//Reset a lot of stuff if we are not merging batches
if(!mergeExisting){
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key] = []
})
this.batchOffset = 0 // Reset batch offset if we are not merging note batches
// this.commonTags = [] //Don't reset tags, if search returns tags, they will be set
}
this.searchResultsCount = 0
//Remove all filter limits from previous queries
delete this.fastFilters.limitSize
delete this.fastFilters.limitOffset
let postData = {
searchQuery: this.searchTerm,
searchTags: this.searchTags,
fastFilters: this.fastFilters,
}
//Save initial post data on first load
if(this.initialPostData == null){
this.initialPostData = JSON.stringify(postData)
}
//If post data is not the same as initial, show clear button
if(JSON.stringify(postData) != this.initialPostData){
this.showClear = true
}
if(notesInNextLoad && notesInNextLoad > 0){
//Create limit based off of the number of notes already loaded
postData.fastFilters.limitSize = notesInNextLoad
postData.fastFilters.limitOffset = this.batchOffset
}
//Perform search - or die
this.loadingInProgress = true
// console.time('Fetch TitleCard Batch '+notesInNextLoad)
axios.post('/api/note/search', postData)
.then(response => {
// console.timeEnd('Fetch TitleCard Batch '+notesInNextLoad)
//Save the number of notes just loaded
this.batchOffset += response.data.notes.length
//Enable or disable scroll loading
this.scrollLoadEnabled = response.data.notes.length > 0
//Mush the two new sets of data together (set will be empty is reset is on)
// if(response.data.tags.length > 0){
// this.commonTags = response.data.tags
// }
if(response.data.total > 0){
this.searchResultsCount = response.data.total
}
this.loadingInProgress = false
this.generateNoteCategories(response.data.notes, mergeExisting)
return resolve(true)
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Notes') })
})
},
rebuildNoteCategorise(){
let currentNotes = []
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key].forEach( note => {
currentNotes.push(note)
})
})
this.generateNoteCategories(currentNotes, false)
},
generateNoteCategories(notes, mergeExisting){
// Place each note in a category based on certain attributes and fast filters
//Reset all sections if we are not merging existing
if(!mergeExisting){
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key] = []
})
}
//Sort notes into defined sections
notes.forEach(note => {
//Show archived notes
if(note.archived == 1 && this.fastFilters.onlyArchived == 1){
this.noteSections.archived.push(note)
return
}
if(note.shareUsername != null){
this.noteSections.shared.push(note)
return
}
//Only show sent notes section if shared is selected
if(note.shared == 2 && this.fastFilters.onlyShowSharedNotes == 1){
this.noteSections.sent.push(note)
return
}
if(note.note_highlights.length > 0){
this.noteSections.highlights.push(note)
return
}
// Pinned notes are always first, they can appear in the archive
if(note.pinned == 1){
this.noteSections.pinned.push(note)
return
}
//If the note is not archived, push it.
if(note.archived != 1 && this.fastFilters.onlyArchived != 1){
this.noteSections.notes.push(note)
}
})
},
reset(){
this.showClear = false
this.scrollLoadEnabled = true
this.searchTerm = ''
this.searchTags = []
this.fastFilters = {}
this.foundAttachments = [] //Remove all attachments
this.$bus.$emit('reset_fast_filters')
//Load initial batch, then tags, then other batch
this.search(true, this.firstLoadBatchSize)
.then( () => {
//Load a larger batch once first batch has loaded
return this.search(false, this.batchSize, true)
})
.then( i => {
//Thats how you promise chain
})
},
updateFastFilters(index){
//clear out tags
this.searchTags = []
//A little hacky, brings user to notes page then filters on click
if(this.$route.name != 'Note Page'){
this.$router.push('/notes')
setTimeout( () => {
this.updateFastFilters(index)
}, 500 )
}
const options = [
'withLinks', // 'Only Show Notes with Links'
'withTags', // 'Only Show Notes with Tags'
'onlyArchived', //'Only Show Archived Notes'
'onlyShowSharedNotes', //Only show shared notes
'onlyShowEncrypted',
]
let filter = {}
filter[options[index]] = 1
this.$bus.$emit('update_fast_filters', filter)
}
}
}
</script>
<style type="text/css" scoped>
.detail {
float: right;
}
.note-card-display-area {
display: flex;
flex-wrap: wrap;
}
.display-area-title {
width: 100%;
display: inline-block;
}
.note-card-section {
/*padding-bottom: 15px;*/
}
.note-card-section + .note-card-section {
padding: 15px 0 0;
}
</style>