1140 lines
32 KiB
Vue
1140 lines
32 KiB
Vue
<template>
|
|
<div class="page-container">
|
|
|
|
<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="six wide column" v-if="$store.getters.totals && $store.getters.totals['totalNotes']">
|
|
<search-input />
|
|
</div>
|
|
|
|
<div class="sixteen wide column" v-if="$store.getters.totals && $store.getters.totals['showTrackMetricsButton']">
|
|
<router-link class="ui fluid green button" to="/metrictrack">
|
|
<i class="calendar check outlin icon"></i>Metric Track
|
|
</router-link>
|
|
</div>
|
|
|
|
<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['youGotMailCount'] > 0)"
|
|
style="position: relative;">
|
|
<i class="green mail icon"></i>Inbox
|
|
<span class="tiny circular floating ui green label">+{{ $store.getters.totals['youGotMailCount'] }}</span>
|
|
</div>
|
|
|
|
<tag-display
|
|
v-if="$store.getters.totals && Object.keys($store.getters.totals['tags'] || {}).length"
|
|
:user-tags="$store.getters.totals['tags']"
|
|
:active-tags="searchTags"
|
|
v-on:tagClick="tagId => toggleTagFilter(tagId)"
|
|
/>
|
|
|
|
<paste-button />
|
|
|
|
</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 && !showLoading">
|
|
<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>
|
|
|
|
<div v-if="fastFilters['onlyArchived'] == 1" class="sixteen wide column">
|
|
<h2>
|
|
<i class="green archive icon"></i>
|
|
Archived Notes</h2>
|
|
</div>
|
|
|
|
<div class="sixteen wide column" v-if="fastFilters['onlyShowTrashed'] == 1">
|
|
<h2>
|
|
<i class="green trash alternate outline icon"></i>
|
|
Trashed Notes
|
|
<span>({{ $store.getters.totals['trashedNotes'] }})</span>
|
|
<div class="ui right floated basic button" data-tooltip="This doesn't work yet">
|
|
<i class="poo storm icon"></i>
|
|
Empty Trash
|
|
</div>
|
|
</h2>
|
|
</div>
|
|
|
|
<div class="sixteen wide column" v-if="fastFilters['onlyShowSharedNotes'] == 1">
|
|
<h2><i class="green paper plane outline icon"></i>
|
|
Shared Notes</h2>
|
|
</div>
|
|
|
|
<div class="sixteen wide column" v-if="tagSuggestions.length > 0">
|
|
<h5 class="ui tiny dividing header"><i class="green tags icon"></i> Tags ({{ tagSuggestions.length }})</h5>
|
|
<div class="ui clickable green label" v-for="tag in tagSuggestions" v-on:click="tagId => toggleTagFilter(tag.id)">
|
|
<i class="tag icon"></i>
|
|
{{ tag.text }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Note title card display -->
|
|
<div class="sixteen wide column">
|
|
|
|
<h3 v-if="$store.getters.totals && $store.getters.totals['totalNotes'] == 0 && fastFilters['notesHome'] == 1">
|
|
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), 'floating-list':( isFloatingList ), 'hidden-floating-list':(collapseFloatingList)}" v-on:scroll="onScroll">
|
|
|
|
|
|
<div class="ui basic fitted right aligned segment" v-if="isFloatingList">
|
|
<div class="ui small basic green left floated button" v-on:click="closeAllNotes()" v-if="openNotes.length >= 1">
|
|
<i class="close icon"></i>
|
|
Close Notes
|
|
</div>
|
|
<div class="ui small green button" v-on:click="collapseFloatingList = true">
|
|
<i class="caret square left outline icon"></i>
|
|
Hide List
|
|
</div>
|
|
</div>
|
|
|
|
<!-- render each section based on notes in set -->
|
|
<template v-for="section,index in noteSections" :key="index">
|
|
<div 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-on:tagClick="tagId => toggleTagFilter(tagId)"
|
|
v-for="note in section"
|
|
:ref="'note-'+note.id"
|
|
:onClick="openNote"
|
|
:data="note"
|
|
:title-view="titleView || isFloatingList"
|
|
:currently-open="openNotes.includes(note.id)"
|
|
:key="note.id + note.color + '-' +note.title.length + '-' +note.subtext.length + '-' + note.tag_count + note.updated + note.archived + note.pinned + note.trashed"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
|
|
<div class="loading-section" v-if="showLoading">
|
|
<loading-icon message="Decrypting Notes" />
|
|
</div>
|
|
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- found attachments -->
|
|
<div class="sixteen wide column" v-if="foundAttachments.length > 0">
|
|
<h5 class="ui tiny dividing header"><i class="green folder open outline icon"></i> Files ({{ foundAttachments.length }})</h5>
|
|
<attachment-display
|
|
v-for="item in foundAttachments"
|
|
:item="item"
|
|
:key="item.id"
|
|
:search-params="{}"
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="show-hidden-note-list-button"
|
|
v-if="collapseFloatingList && openNotes.length > 0" v-on:click="collapseFloatingList = false">
|
|
<i class="caret square right outline icon"></i>
|
|
</div>
|
|
|
|
<!-- flexbox note container evenly spaces open notes -->
|
|
<div class="note-panel-container" :class="{ 'note-panel-fullwidth':collapseFloatingList}" v-if="openNotes.length">
|
|
<template v-for="noteId in openNotes" :key="noteId">
|
|
<note-input-panel
|
|
v-if="noteId != null"
|
|
:noteid="noteId"
|
|
:url-data="$route.params"
|
|
:open-notes="openNotes.length"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { defineAsyncComponent } from 'vue'
|
|
|
|
import axios from 'axios'
|
|
|
|
export default {
|
|
name: 'NotesPage',
|
|
components: {
|
|
|
|
'note-input-panel': defineAsyncComponent(() => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue')),
|
|
|
|
'note-title-display-card': defineAsyncComponent(() => import('@/components/NoteTitleDisplayCard.vue')),
|
|
// 'fast-filters': defineAsyncComponent(() => import('@/components/FastFilters.vue')),
|
|
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
|
|
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
|
|
'tag-display':defineAsyncComponent(() => import('@/components/TagDisplayComponent.vue')),
|
|
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
|
'paste-button':defineAsyncComponent(() => import('@/components/PasteButton.vue')),
|
|
},
|
|
data () {
|
|
return {
|
|
initComponent: true,
|
|
tagSuggestions:[],
|
|
searchTerm: '',
|
|
searchResultsCount: 0,
|
|
searchTags: [],
|
|
notes: [],
|
|
openNotes: [],
|
|
// fullPath of our own last URL write, so the $route watcher can
|
|
// recognise it and not treat it as an inbound navigation
|
|
lastSyncedFullPath: null,
|
|
collapseFloatingList: false,
|
|
highlights: [],
|
|
searchDebounce: null,
|
|
fastFilters: {},
|
|
titleView: false,
|
|
|
|
//Load up notes in batches
|
|
firstLoadBatchSize: 10, //First set of rapidly loaded notes
|
|
batchSize: 20, //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
|
|
showLoading: false,
|
|
scrollLoadEnabled: true,
|
|
|
|
//Clear button is not visible
|
|
showClear: false,
|
|
initialPostData: null,
|
|
|
|
//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', 'Inbox'],
|
|
'sent': ['paper plane outline', 'Sent Notes'],
|
|
'notes': ['file','Notes'],
|
|
'highlights': ['paragraph', 'Found In Text'],
|
|
'trashed': ['poop', 'Trashed Notes'],
|
|
'tagged': ['tag', 'Tagged'],
|
|
},
|
|
noteSections: {
|
|
pinned: [],
|
|
archived: [],
|
|
shared:[],
|
|
sent:[],
|
|
notes: [],
|
|
highlights: [],
|
|
trashed: [],
|
|
tagged:[],
|
|
},
|
|
|
|
}
|
|
},
|
|
beforeMount(){
|
|
|
|
this.$root.loginGateway()
|
|
|
|
//If user is on title view,
|
|
this.titleView = this.$store.getters.getIsUserOnMobile
|
|
|
|
this.$io.on('new_note_created', noteId => {
|
|
|
|
// Push new note to top of list and animate
|
|
this.updateSingleNote(noteId)
|
|
this.$store.dispatch('fetchAndUpdateUserTotals')
|
|
})
|
|
|
|
this.$io.on('note_attribute_modified', noteId => {
|
|
|
|
const drawFocus = !this.openNotes.includes(parseInt(noteId))
|
|
this.updateSingleNote(noteId, drawFocus)
|
|
|
|
//Do not update note if its open
|
|
if(this.openNotes.includes(parseInt(noteId))){
|
|
this.$store.dispatch('fetchAndUpdateUserTotals')
|
|
}
|
|
})
|
|
|
|
//Update title cards when new note text is saved
|
|
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
|
|
|
|
const drawFocus = !this.openNotes.includes(parseInt(noteId))
|
|
this.updateSingleNote(noteId, drawFocus)
|
|
})
|
|
|
|
this.$bus.$on('update_single_note', (noteId) => {
|
|
|
|
const drawFocus = !this.openNotes.includes(parseInt(noteId))
|
|
this.updateSingleNote(noteId, drawFocus)
|
|
|
|
})
|
|
|
|
//Update totals for app
|
|
this.$store.dispatch('fetchAndUpdateUserTotals')
|
|
|
|
//Close note event
|
|
this.$bus.$on('close_active_note', ({noteId, modified}) => {
|
|
this.closeNote(noteId, modified)
|
|
})
|
|
|
|
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', filterIndex => {
|
|
|
|
this.updateFastFilters(filterIndex)
|
|
})
|
|
|
|
//Event to update search from other areas
|
|
this.$bus.$on('update_search_term', sentInSearchTerm => {
|
|
this.searchTerm = sentInSearchTerm
|
|
this.search(true, this.batchSize)
|
|
.then( () => {
|
|
|
|
this.searchAttachments()
|
|
|
|
const postData = {
|
|
'tagText':this.searchTerm.trim()
|
|
}
|
|
|
|
this.tagSuggestions = []
|
|
axios.post('/api/tag/suggest', postData)
|
|
.then( response => {
|
|
|
|
this.tagSuggestions = response.data
|
|
})
|
|
|
|
// return
|
|
})
|
|
})
|
|
|
|
//Reload page content - don't trigger if load is in progress
|
|
this.$bus.$on('note_reload', () => {
|
|
if(!this.showLoading){
|
|
this.reset()
|
|
}
|
|
})
|
|
|
|
// Window scroll needed when scrolling full page.
|
|
// second scroll event added on note-list for floating view scroll detection
|
|
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);
|
|
|
|
},
|
|
beforeUnmount(){
|
|
window.removeEventListener('scroll', this.onScroll)
|
|
// 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')
|
|
|
|
//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() {
|
|
|
|
// Restore every note named in ?open=
|
|
const ids = this.parseOpenQuery(this.$route.query.open)
|
|
|
|
// Fold the legacy /notes/open/:id deep link in, first position
|
|
const paramId = parseInt(this.$route.params.id, 10)
|
|
if(Number.isInteger(paramId) && paramId > 1 && !ids.includes(paramId)){
|
|
ids.unshift(paramId)
|
|
}
|
|
|
|
// Push directly rather than via openNote(), which would write the URL
|
|
// once per note; one normalising write follows instead.
|
|
ids.forEach(id => {
|
|
if(!this.openNotes.includes(id)){
|
|
this.openNotes.push(id)
|
|
}
|
|
})
|
|
|
|
// Unconditional, so a junk ?open=abc gets scrubbed too
|
|
this.syncOpenNotesToUrl()
|
|
|
|
//Loads initial batch and tags
|
|
this.reset()
|
|
|
|
},
|
|
watch: {
|
|
'$route.params.id': function(id){
|
|
this.openNote(id)
|
|
},
|
|
'$route' (to, from) {
|
|
|
|
// Our own URL write, not an inbound navigation
|
|
if(to.fullPath === this.lastSyncedFullPath){ return }
|
|
|
|
// The discriminator is the absence of ?open=, NOT the path. A menu's
|
|
// $router.go(-1) lands on /notes?open=5 with `from` containing
|
|
// '/notes/open/', so a path comparison here would close every note
|
|
// every time a side menu is dismissed.
|
|
const landingOnBareNotes = (to.path === '/notes' && !to.query.open)
|
|
|
|
// Reload the notes if arriving at an empty /notes from elsewhere
|
|
if(landingOnBareNotes && !from.path.startsWith('/notes/open/') && from.path !== '/notes'){
|
|
this.reset()
|
|
}
|
|
|
|
// Explicit navigation to a note-less /notes closes everything
|
|
if(landingOnBareNotes && this.openNotes.length > 0){
|
|
this.closeAllNotes()
|
|
}
|
|
|
|
// Stale history entry (e.g. go(-1) after another note was closed):
|
|
// rewrite the URL from the array. Never read it.
|
|
if(to.path === '/notes' && to.query.open && to.query.open !== this.openNotes.join(',')){
|
|
this.syncOpenNotesToUrl()
|
|
}
|
|
|
|
//Lookup tags set in URL
|
|
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
|
|
|
|
//Lookup tag in store by string
|
|
const tagObject = this.$store.getters.totals['tags'][to.params.tag]
|
|
|
|
//Pull key out of string and load tags for that key
|
|
this.toggleTagFilter(tagObject.id)
|
|
return
|
|
}
|
|
}
|
|
},
|
|
computed: {
|
|
isFloatingList(){
|
|
|
|
//If note 1 or 2 is open, show floating column
|
|
return (this.openNotes.length > 0)
|
|
|
|
},
|
|
showOneColumn(){
|
|
|
|
return this.$store.getters.getIsUserOnMobile
|
|
|
|
}
|
|
},
|
|
methods: {
|
|
// Where the open-note URL should point. A menu or a tag search owns the
|
|
// path, so only a plain notes view gets normalised back to /notes.
|
|
openNotesTargetPath(){
|
|
|
|
// A side menu is open; its close button is a $router.go(-1), so the
|
|
// path has to stay put or there is nowhere sane to land.
|
|
if(this.$route.params.openMenu){ return this.$route.path }
|
|
|
|
// Preserve a tag search
|
|
if(this.$route.params.tag){ return this.$route.path }
|
|
|
|
return '/notes'
|
|
},
|
|
// Mirror openNotes into ?open=. This is the ONLY place the open-note
|
|
// URL is written.
|
|
syncOpenNotesToUrl(){
|
|
|
|
const query = Object.assign({}, this.$route.query)
|
|
|
|
if(this.openNotes.length > 0){
|
|
query.open = this.openNotes.join(',')
|
|
}
|
|
else {
|
|
delete query.open
|
|
}
|
|
|
|
const target = { path: this.openNotesTargetPath(), query }
|
|
const targetFullPath = this.$router.resolve(target).fullPath
|
|
|
|
// Already correct -- don't navigate at all
|
|
if(targetFullPath === this.$route.fullPath){
|
|
this.lastSyncedFullPath = targetFullPath
|
|
return
|
|
}
|
|
|
|
// Set before navigating so the watcher sees it
|
|
this.lastSyncedFullPath = targetFullPath
|
|
|
|
this.$router.replace(target).catch(() => {})
|
|
},
|
|
// Tolerant parse of ?open=. Handles 'abc', '1,,2,2', '-4' and the
|
|
// repeated form ?open=1&open=2 (which arrives as an array).
|
|
parseOpenQuery(raw){
|
|
|
|
if(raw === undefined || raw === null){ return [] }
|
|
|
|
// Cap: a hand-crafted URL shouldn't be able to fire 50 note fetches
|
|
const MAX_RESTORE = 8
|
|
|
|
return [].concat(raw)
|
|
.join(',')
|
|
.split(',')
|
|
.map(value => parseInt(value, 10))
|
|
.filter(value => Number.isInteger(value) && value > 0)
|
|
.filter((value, index, all) => all.indexOf(value) === index)
|
|
.slice(0, MAX_RESTORE)
|
|
},
|
|
openNote(id, event = null){
|
|
|
|
const intId = parseInt(id, 10)
|
|
|
|
// Also absorbs the undefined the '$route.params.id' watcher now gets
|
|
// when we normalise /notes/open/5 into /notes?open=5
|
|
if(!Number.isInteger(intId) || intId < 1){ return }
|
|
|
|
//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 }
|
|
}
|
|
|
|
if(this.openNotes.includes(intId)){
|
|
// Already open, but an external push('/notes/open/5') still needs
|
|
// the URL normalised back to the full set
|
|
this.syncOpenNotesToUrl()
|
|
return
|
|
}
|
|
|
|
this.openNotes.push(intId)
|
|
this.syncOpenNotesToUrl()
|
|
},
|
|
closeNote(noteId, modified, syncUrl = true){
|
|
|
|
// parseInt: indexOf is strict, and ids arriving from the URL are
|
|
// strings. Without this the splice silently misses.
|
|
const intId = parseInt(noteId, 10)
|
|
const openIndex = this.openNotes.indexOf(intId)
|
|
const didClose = (openIndex !== -1)
|
|
|
|
if(didClose){
|
|
this.openNotes.splice(openIndex, 1)
|
|
}
|
|
|
|
// Only write the URL when the array actually changed. NoteInputPanel
|
|
// emits close_active_note twice per note (closeButtonAction -> close(),
|
|
// then beforeUnmount -> close()); the second arrives after the splice.
|
|
if(didClose && syncUrl){
|
|
this.syncOpenNotesToUrl()
|
|
}
|
|
|
|
if(modified){
|
|
this.$store.dispatch('fetchAndUpdateUserTotals')
|
|
//Focus and animate if modified
|
|
this.updateSingleNote(noteId, modified)
|
|
}
|
|
},
|
|
closeAllNotes(){
|
|
|
|
// Backwards, so the in-loop splice can't skip an entry. URL writes are
|
|
// deferred so this is one navigation rather than N.
|
|
for (let i = this.openNotes.length - 1; i >= 0; i--) {
|
|
this.closeNote(this.openNotes[i], false, false)
|
|
}
|
|
|
|
this.syncOpenNotesToUrl()
|
|
},
|
|
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){
|
|
|
|
if(!this.scrollLoadEnabled){
|
|
return
|
|
}
|
|
|
|
clearTimeout(this.loadingBatchTimeout)
|
|
this.loadingBatchTimeout = setTimeout(() => {
|
|
|
|
//Detect distance scrolled down the page
|
|
const scrolledDown = window.pageYOffset + window.innerHeight
|
|
//Get height of div to properly detect scroll distance down
|
|
const height = document.getElementById('app').scrollHeight
|
|
|
|
//Load if less than 500px from the bottom
|
|
if(((height - scrolledDown) < 500) && this.scrollLoadEnabled){
|
|
|
|
this.search(true, this.batchSize, true)
|
|
}
|
|
|
|
}, 50)
|
|
|
|
|
|
return
|
|
},
|
|
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, focuseAndAnimate = true){
|
|
|
|
// console.log('updating single note', noteId)
|
|
|
|
noteId = parseInt(noteId)
|
|
|
|
//Find local note, if it exists; continue
|
|
let note = null
|
|
if(this.$refs['note-'+noteId]?.[0]?.note){
|
|
note = this.$refs['note-'+noteId][0].note
|
|
//Show that note is working on updating
|
|
this.$refs['note-'+noteId][0].showWorking = true
|
|
}
|
|
|
|
this.rebuildNoteCategorise()
|
|
// return
|
|
|
|
//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]
|
|
|
|
if(newNote === undefined){
|
|
return
|
|
}
|
|
|
|
// if old note data and new note data exists
|
|
if(note && newNote){
|
|
|
|
//go through each prop and update it with new values
|
|
Object.keys(newNote).forEach(prop => {
|
|
note[prop] = newNote[prop]
|
|
})
|
|
|
|
//Push new note to front if its modified or we want it to
|
|
if( note.updated != newNote.updated ){
|
|
|
|
// Find note, in section, move to front
|
|
Object.keys(this.noteSections).forEach( key => {
|
|
this.noteSections[key].forEach( (searchNote, index) => {
|
|
if(searchNote.id == noteId){
|
|
//Remove note from location and push to front
|
|
this.noteSections[key].splice(index, 1)
|
|
this.noteSections[key].unshift(note)
|
|
return
|
|
}
|
|
})
|
|
})
|
|
|
|
}
|
|
|
|
if( focuseAndAnimate ){
|
|
this.$nextTick( () => {
|
|
//Trigger close animation on note
|
|
this.$refs['note-'+noteId][0].justClosed()
|
|
this.$refs['note-'+noteId][0].showWorking = false
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
//New notes don't exist in list, push them to the front
|
|
if(note == null){
|
|
this.noteSections.notes.unshift(newNote)
|
|
//Trigger close animation on note
|
|
if(this.$refs['note-'+noteId] && this.$refs['note-'+noteId][0]){
|
|
this.$refs['note-'+noteId][0].justClosed()
|
|
this.$refs['note-'+noteId][0].showWorking = false
|
|
}
|
|
}
|
|
|
|
if(this.$refs['note-'+noteId] && this.$refs['note-'+noteId][0]){
|
|
this.$refs['note-'+noteId][0].showWorking = false
|
|
}
|
|
|
|
//Trigger section rebuild
|
|
this.rebuildNoteCategorise()
|
|
})
|
|
.catch(error => {
|
|
console.log(error)
|
|
this.$bus.$emit('notification', 'Failed to Update Note')
|
|
})
|
|
},
|
|
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 = 10, mergeExisting = false){
|
|
return new Promise((resolve, reject) => {
|
|
|
|
//Don't double load note batches
|
|
if(this.showLoading){
|
|
console.log('Loading already in progress')
|
|
return resolve(false)
|
|
}
|
|
|
|
if(!mergeExisting){
|
|
this.batchOffset = 0 // Reset batch offset if we are not merging note batches or new set will be offset from current and overwrite current set with second batch
|
|
}
|
|
|
|
//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.showLoading = showLoading
|
|
this.scrollLoadEnabled = false
|
|
axios.post('/api/note/search', postData)
|
|
.then(response => {
|
|
|
|
//Reset a lot of stuff if we are not merging batches
|
|
if(!mergeExisting){
|
|
Object.keys(this.noteSections).forEach( key => {
|
|
this.noteSections[key] = []
|
|
})
|
|
}
|
|
this.searchResultsCount = 0
|
|
|
|
// console.timeEnd('Fetch TitleCard Batch '+notesInNextLoad)
|
|
|
|
//Save the number of notes just loaded
|
|
this.batchOffset += response.data.notes.length
|
|
|
|
//Enable scroll loading if endpoint retured notes
|
|
this.scrollLoadEnabled = response.data.notes.length > 0
|
|
|
|
if(response.data.total > 0){
|
|
this.searchResultsCount = response.data.total
|
|
}
|
|
|
|
this.showLoading = false
|
|
this.generateNoteCategories(response.data.notes, mergeExisting)
|
|
|
|
//cache initial notes for faster reloads
|
|
if(!mergeExisting && this.showClear == false){
|
|
const cachedNotesJson = JSON.stringify(response.data.notes)
|
|
localStorage.setItem('snippetCache', cachedNotesJson)
|
|
}
|
|
|
|
return resolve(true)
|
|
})
|
|
.catch(error => {
|
|
console.error('[NotesPage] search failed:', 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 => {
|
|
|
|
if(this.searchTerm.length > 0){
|
|
if(note.pinned == 1){
|
|
this.noteSections.pinned.push(note)
|
|
return
|
|
}
|
|
|
|
//Push to default note section
|
|
this.noteSections.notes.push(note)
|
|
return
|
|
}
|
|
|
|
//Display all tags in tag section
|
|
if(this.searchTags.length >= 1){
|
|
this.noteSections.tagged.push(note)
|
|
return
|
|
}
|
|
|
|
//Only show trashed notes when trashed
|
|
if(this.fastFilters.onlyShowTrashed == 1){
|
|
|
|
if(note.trashed == 1){
|
|
this.noteSections.trashed.push(note)
|
|
}
|
|
return
|
|
}
|
|
if(note.trashed == 1){
|
|
return
|
|
}
|
|
|
|
//Show archived notes
|
|
if(this.fastFilters.onlyArchived == 1){
|
|
|
|
if(note.pinned == 1 && note.archived == 1){
|
|
this.noteSections.pinned.push(note)
|
|
return
|
|
}
|
|
if(note.archived == 1){
|
|
this.noteSections.archived.push(note)
|
|
}
|
|
return
|
|
}
|
|
if(note.archived == 1){ return }
|
|
|
|
//Only show sent notes section if shared is selected
|
|
if(this.fastFilters.onlyShowSharedNotes == 1){
|
|
|
|
if(note.shared == 2){
|
|
this.noteSections.sent.push(note)
|
|
}
|
|
if(note.shareUsername != null){
|
|
this.noteSections.shared.push(note)
|
|
}
|
|
return
|
|
}
|
|
//Show shared notes on main list but not notes shared with you
|
|
if(note.shareUsername != null){ return }
|
|
|
|
// Pinned notes are always first, they can appear in the archive
|
|
if(note.pinned == 1){
|
|
this.noteSections.pinned.push(note)
|
|
return
|
|
}
|
|
|
|
//Push to default note section
|
|
this.noteSections.notes.push(note)
|
|
|
|
return
|
|
})
|
|
|
|
},
|
|
reset(){
|
|
this.showClear = false
|
|
this.scrollLoadEnabled = true
|
|
this.searchTerm = ''
|
|
this.searchTags = []
|
|
this.tagSuggestions = []
|
|
this.fastFilters = {}
|
|
this.foundAttachments = [] //Remove all attachments
|
|
|
|
this.updateFastFilters(5) //This loads notes
|
|
|
|
},
|
|
updateFastFilters(index){
|
|
|
|
//clear out tags
|
|
this.searchTags = []
|
|
this.tagSuggestions = []
|
|
this.showLoading = false
|
|
this.searchTerm = ''
|
|
this.$bus.$emit('reset_fast_filters') //Clear out search
|
|
|
|
const options = [
|
|
'withLinks', // 'Only Show Notes with Links'
|
|
'withTags', // 'Only Show Notes with Tags'
|
|
'onlyArchived', //'Only Show Archived Notes'
|
|
'onlyShowSharedNotes', //Only show shared notes
|
|
'onlyShowTrashed',
|
|
'notesHome',
|
|
]
|
|
|
|
let filter = {}
|
|
filter[options[index]] = 1
|
|
|
|
this.fastFilters = filter
|
|
|
|
//If notes exist in cache, load them up
|
|
let showLoading = true
|
|
const cachedNotesJson = localStorage.getItem('snippetCache')
|
|
const cachedNotes = JSON.parse(cachedNotesJson)
|
|
if(cachedNotes && cachedNotes.length > 0 && !this.showClear){
|
|
|
|
//Load cache. do not merge existing
|
|
this.generateNoteCategories(cachedNotes, false)
|
|
showLoading = false
|
|
}
|
|
|
|
//Fetch First batch of notes with new filter
|
|
this.search(showLoading, this.batchSize, false)
|
|
// .then( r => this.search(false, this.batchSize, true))
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style type="text/css" scoped>
|
|
|
|
.text-fix {
|
|
padding: 8px 0 0 15px;
|
|
display: inline-block;
|
|
color: var(--menu-accent);
|
|
}
|
|
.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;
|
|
}
|
|
.loading-section {
|
|
color: var(--main-accent);
|
|
box-shadow: 0 1px 3px 0 var(--main-accent);
|
|
border-radius: 6px;
|
|
background-color: var(--small_element_bg_color);
|
|
display: inline-block;
|
|
width: 100%;
|
|
margin: 15px 0;
|
|
}
|
|
.floating-list {
|
|
z-index: 1000;
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 25%;
|
|
height: 100vh;
|
|
background-color: var(--small_element_bg_color);
|
|
padding: 15px 5px 0px 10px;
|
|
overflow-y: scroll;
|
|
overflow-x: hidden;
|
|
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
|
scrollbar-width: none; /* Firefox */
|
|
background-color: var(--border_color);
|
|
}
|
|
.floating-list::-webkit-scrollbar {
|
|
display: none; /* Safari and Chrome */
|
|
}
|
|
.note-panel-container {
|
|
position: fixed;
|
|
width: 75%;
|
|
height: 100vh;
|
|
background: gray;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 1000;
|
|
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
justify-content: center;
|
|
align-items: stretch;
|
|
align-content: stretch;
|
|
|
|
z-index: 1000;
|
|
}
|
|
.note-panel-fullwidth {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.note-panel-container > div {
|
|
flex: 1;
|
|
position: relative;
|
|
}
|
|
.hidden-floating-list {
|
|
left: -1000px !important;
|
|
}
|
|
.show-hidden-note-list-button {
|
|
position: fixed;
|
|
top: 25px;
|
|
left: 0;
|
|
min-width: 45px;
|
|
background-color: var(--main-accent);
|
|
color: var(--text_color);
|
|
display: block;
|
|
z-index: 1100;
|
|
cursor: pointer;
|
|
border-bottom-right-radius: 5px;
|
|
border-top-right-radius: 5px;
|
|
padding: 8px 0px 8px 13px;
|
|
text-align: left;
|
|
font-size: 1.4em;
|
|
}
|
|
|
|
@media (min-width:320px) { /* smartphones, iPhone, portrait 480x320 phones */
|
|
.floating-list {
|
|
left: -1000px;
|
|
}
|
|
.note-panel-container {
|
|
width: 100%;
|
|
}
|
|
}
|
|
@media (min-width:481px) { /* portrait e-readers (Nook/Kindle), smaller tablets @ 600 or @ 640 wide. */
|
|
.floating-list {
|
|
left: 0px;
|
|
}
|
|
.note-panel-container {
|
|
width: 75%;
|
|
}
|
|
}
|
|
@media (min-width:641px) { /* portrait tablets, portrait iPad, landscape e-readers, landscape 800x480 or 854x480 phones */
|
|
|
|
}
|
|
@media (min-width:961px) { /* tablet, landscape iPad, lo-res laptops ands desktops */
|
|
|
|
}
|
|
@media (min-width:1025px) { /* big landscape tablets, laptops, and desktops */
|
|
|
|
}
|
|
@media (min-width:1281px) { /* hi-res laptops and desktops */
|
|
|
|
}
|
|
@media (min-width:2000px) { /* BIG hi-res laptops and desktops */
|
|
.floating-list {
|
|
left: 180px;
|
|
width: calc(30% - 180px);
|
|
}
|
|
.note-panel-container {
|
|
width: 70%;
|
|
}
|
|
}
|
|
|
|
.master-note-edit {
|
|
position: absolute;
|
|
width: 100%;
|
|
background: var(--small_element_bg_color);
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
overflow: hidden;
|
|
}
|
|
.master-note-edit + .master-note-edit {
|
|
border-left: 2px solid var(--main-accent);
|
|
border-left: 5px solid var(--border_color);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*html, body {
|
|
height: 100%;
|
|
}
|
|
|
|
.wrap {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}*/
|
|
|
|
|
|
|
|
|
|
|
|
</style> |