Compare commits

...

13 Commits

Author SHA1 Message Date
Max G
8833a213a7 Added some realtime events to the app
* When a user gets a new shared message, it will popup instantly
* When a new website is scraped, it will update in real time
* Various other little bug fixes and improvements
* Sharing displays correct notes and handles shared notes correctly
* Tags were not displaying on notes, they do now. They better.
2020-02-14 01:08:46 +00:00
Max G
f833845452 * Search bar only appears in header menu on mobile
* Added tooltip to logout button
* Tags follow archived, inbox, main note fast filters
2020-02-12 05:29:56 +00:00
Max G
05152cd5a4 Added counts to each category
Counts update on certain events and show or hide various elements
Fixed various little ui element issues

fixes #6
2020-02-11 21:11:14 +00:00
Max G
cf3289aac6 Fixing quick notes
Updating all the icons
making search bar thinner
2020-02-11 06:05:28 +00:00
Max G
acf72ca67e * Tags can now be toggled by clicking
* Side slide component now respects note colors
2020-02-10 22:21:06 +00:00
Max G
7f93925f74 fixes #4 for real
* Deleting a link deletes the thumbnails
* Joining thumbnails now ignores not visible
2020-02-10 21:09:09 +00:00
Max G
d2c1dedffb fixes #4 Hidden attachment images don't appear on note title display card
* Fixed typo on home page
2020-02-10 20:43:34 +00:00
Max G
003c7e32b1 re #2 Force HTTPS on production when loading home page
* Updated text on home page
2020-02-10 20:03:14 +00:00
Max G
de646cf1de Created a uniform menu for notes that works on mobile
Added list sorting
Added shared notes
Fixed some little bugs here and there
2020-02-10 17:44:43 +00:00
Max G
2828cc9462 Remove TinyMce Added Squire 2020-02-01 22:21:22 +00:00
Max G
f99d6ed430 Some minor bug fixing 2020-01-03 01:54:11 +00:00
Max G
4216c1825e I swear, I'm going to start doing regular commits
+ Added a ton of shit
+ About to add socket.io oh god.
2020-01-03 01:26:55 +00:00
Max G
8d07a8e11a I need to get back into using git. The hell is wrong with me!? 2019-12-20 05:50:50 +00:00
81 changed files with 53612 additions and 928 deletions

18
backupDatabase.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
BACKUPDIR="/home/mab/databaseBackupPi"
mkdir -p $BACKUPDIR
cd $BACKUPDIR
NOW=$(date +"%Y-%m-%d_%H-%M")
ssh mab@avidhabit.com -p 13328 "mysqldump --all-databases --user root -pRootPass1234!" > "backup-$NOW.sql"
cp "backup-$NOW.sql" "/mnt/Windows Data/DatabaseBackups/backup-$NOW.sql"
echo "Database Backup Complete on $NOW"
#Restore DB
# copy file over, run restore
# scp -P 13328 backup-2019-12-04_03-00.sql mab@avidhabit.com:/home/mab
# mysql -u root -p < backup-2019-12-04_03-00.sql

View File

@@ -5,19 +5,16 @@
# Push built release files to production server
#
echo -e "\e[32m\nStarting Build, hold onto your parts... \n\e[0m"
echo -e "\e[32m\nStarting Build. \n\e[0m"
# Build out new release
cd client
npm run build
cd ..
# Remove old releases
rm release.tar.gz
# only compress client/dist and server with node_modules
echo -e "\e[32m\nCompressing client and server code... \n\e[0m"
tar -czf release.tar.gz server node_modules client/dist package.json
tar -czf release.tar.gz server node_modules client/dist staticFiles/assets
#send compressed release to remote machine
echo -e "\e[32m\nMoving compressed release to production... \n\e[0m"
@@ -28,7 +25,7 @@ rm release.tar.gz
#uncompress release on server
echo -e "\e[32m\nExtracting release on production... \n\e[0m"
ssh mab@avidhabit.com -p 13328 "cd /home/mab/pi/; rm -r server node_modules client; tar -xzf *.tar.gz; rm *.tar.gz; pm2 reload all"
ssh mab@avidhabit.com -p 13328 "cd /home/mab/pi/; rm -r server node_modules client; tar -xzf *.tar.gz --overwrite; rm *.tar.gz; pm2 reload all"
#Congratulate how awesome you are
echo -e "\e[32m\nRelease Complete! Nice Work! \n\e[0m"

View File

@@ -58,11 +58,11 @@ module.exports = {
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
test: /\.(eot|ttf|otf|woff|woff2)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
name: utils.assetsPath('fonts/[name].[ext]')
}
}
]

View File

@@ -17,6 +17,9 @@ const devWebpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true })
},
watchOptions: {
ignored: ['uploads', 'node_modules']
},
// cheap-module-eval-source-map is faster for development
devtool: config.dev.devtool,

View File

@@ -4,4 +4,4 @@ const prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"'
})
})

View File

@@ -13,7 +13,7 @@ module.exports = {
proxyTable: {},
// Various Dev Server settings
host: 'localhost', // can be overwritten by process.env.HOST
host: '0.0.0.0',//'localhost', // can be overwritten by process.env.HOST
port: 8444, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
autoOpenBrowser: false,
errorOverlay: true,

View File

@@ -3,10 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>client</title>
<link rel="icon" href="/api/static/assets/favicon.ico" type="image/ico"/>
<link rel="shortcut icon" href="/api/static/assets/favicon.ico" type="image/x-icon"/>
<meta name="theme-color" content="#000" />
<link rel="manifest" href="/api/static/assets/manifest.webmanifest">
<title>Notes</title>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
<!-- built files will be auto injected, somewhere around here -->
</body>
</html>

View File

@@ -11,20 +11,12 @@
"build": "node build/build.js"
},
"dependencies": {
"@ckeditor/ckeditor5-build-classic": "^12.3.1",
"@ckeditor/ckeditor5-build-decoupled-document": "^12.3.1",
"@ckeditor/ckeditor5-dev-utils": "^12.0.2",
"@ckeditor/ckeditor5-dev-webpack-plugin": "^8.0.2",
"@ckeditor/ckeditor5-indent": "^10.0.1",
"@ckeditor/ckeditor5-paragraph": "^11.0.4",
"@ckeditor/ckeditor5-theme-lark": "^14.1.1",
"@ckeditor/ckeditor5-vue": "^1.0.0-beta.2",
"axios": "^0.18.0",
"ckeditor5-indent-text": "^1.0.8",
"es6-promise": "^4.2.6",
"pell": "^1.0.6",
"postcss-loader": "^2.1.6",
"raw-loader": "^0.5.1",
"semantic-ui": "^2.4.2",
"socket.io-client": "^2.3.0",
"vue": "^2.5.2",
"vue-router": "^3.0.1",
"vuex": "^3.1.0"
@@ -46,6 +38,7 @@
"file-loader": "^1.1.4",
"friendly-errors-webpack-plugin": "^1.6.1",
"html-webpack-plugin": "^2.30.1",
"ip": "^1.1.5",
"node-notifier": "^5.1.2",
"optimize-css-assets-webpack-plugin": "^3.2.0",
"ora": "^1.2.0",

View File

@@ -1,37 +1,25 @@
<template>
<div id="app">
<link href="https://fonts.googleapis.com/css?family=Open+Sans&display=swap" rel="stylesheet">
<global-site-menu />
<!-- Hide this menu on the notes page -->
<div class="ui basic segment" v-if="
this.$router.currentRoute.name != 'NotesPage'
">
<div class="ui container">
<div class="ui tabular menu">
<router-view />
<router-link class="item" exact-active-class="active" to="/">Home</router-link>
<router-link v-if="loggedIn" exact-active-class="active" class="item" to="/notes">Notes</router-link>
<router-link class="item" exact-active-class="active" to="/help">Help</router-link>
<router-link v-if="!loggedIn" exact-active-class="active" class="item" to="/login">Login</router-link>
<div v-if="loggedIn" v-on:click="destroyLoginToken" class="item">Logout</div>
</div>
</div>
</div>
<router-view/>
<global-notification />
</div>
</template>
<script>
import { mapGetters } from 'vuex'
// import io from 'socket.io-client'
import axios from 'axios'
export default {
components: {
'global-site-menu': require('@/components/GlobalSiteMenu.vue').default,
'global-notification':require('@/components/GlobalNotificationComponent.vue').default
},
data: function(){
return {
// loggedIn:
@@ -39,26 +27,41 @@ export default {
},
beforeCreate: function(){
//Puts token into state on page load
let token = localStorage.getItem('loginToken')
let username = localStorage.getItem('username')
// const socket = io({ path:'/socket' });
const socket = this.$io
socket.on('connect', () => {
this.$store.commit('setSocketIoSocket', socket.id)
this.$io.emit('user_connect', token)
})
//Detect if user is on a mobile browser and set a flag in store
this.$store.commit('detectIsUserOnMobile')
//Set color theme based on local storage
if(localStorage.getItem('nightMode') == 'true'){
this.$store.commit('toggleNightMode')
}
//Puts token into state on page load
let token = localStorage.getItem('loginToken')
let username = localStorage.getItem('username')
//Put user data into global store on load
if(token){
this.$store.commit('setLoginToken', {token, username})
} else {
this.$store.commit('destroyLoginToken')
this.$router.push({'path':'/'})
}
//Detect if user is on a mobile browser and set a flag in store
this.$store.commit('detectIsUserOnMobile')
},
mounted: function(){
//Update totals for entire app on event
this.$io.on('update_counts', () => {
console.log('Got event, update totals')
this.$store.dispatch('fetchAndUpdateUserTotals')
})
},
computed: {
loggedIn () {
@@ -69,6 +72,13 @@ export default {
methods: {
destroyLoginToken() {
this.$store.commit('destroyLoginToken')
},
loginGateway() {
if(!this.loggedIn){
console.log('This user is not logged in')
this.$router.push({'path':'/login'})
return
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,110 +1,357 @@
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(/static/fonts/roboto-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local('Roboto Bold'), local('Roboto-Bold'), url(/static/fonts/roboto-latin-bold.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--background_color: #fff;
--text_color: #3d3d3d;
--outline_color: rgba(34,36,38,.15);
--background_color: #fff;
--text_color: #3d3d3d;
--outline_color: rgba(34,36,38,.15);
--border_color: rgba(34,36,38,.20);
/*Global purple menu styles */
--menu-border: #534c68;
--menu-background: #221f2b;
}
html {
/*scrollbar-width: none;*/
}
div.ui.basic.segment.no-fluf-segment {
margin-top: 0px;
}
/* OVERWRITE DEFAULT SEMANTIC STYLES FOR CUSTOM/NIGHT MODES*/
body{
body {
color: var(--text_color);
background-color: var(--background_color);
background-color: var(--background_color);
font-family: 'Roboto', 'Helvetica Neue', Arial, Helvetica, sans-serif;
}
.ui.form input:not([type]),
.ui.form input:not([type]):focus {
color: var(--text_color);
background-color: var(--background_color);
border-color: var(--border_color);
.ui.form input:not([type]):focus,
.ui.form textarea:not([type]),
.ui.form textarea:not([type]):focus {
color: var(--text_color);
background-color: var(--background_color);
border-color: var(--border_color);
}
.ui.basic.label {
color: var(--text_color);
background-color: var(--background_color);
border-color: var(--border_color);
.ui.basic.label, .ui.header, .ui.header div.sub.header {
color: var(--text_color);
background-color: var(--background_color);
border-color: var(--border_color);
}
div.ui.basic.green.label {
background-color: var(--background_color) !important;
background-color: var(--background_color) !important;
}
.ui.basic.button, .ui.basic.buttons .button {
background-color: var(--background_color) !important;
color: var(--text_color) !important;
border: 1px solid;
border-color: var(--border_color) !important;
box-shadow: none;
background-color: var(--background_color) !important;
color: var(--text_color) !important;
border: 1px solid;
border-color: var(--border_color) !important;
box-shadow: none;
}
.ui.basic.button:focus, .ui.basic.button:hover {
background-color: var(--background_color) !important;
color: var(--text_color) !important;
box-shadow: none;
background-color: var(--background_color) !important;
color: var(--text_color) !important;
box-shadow: none;
}
.ui.tabular.menu .item {
background-color: var(--background_color) !important;
color: var(--text_color) !important;
background-color: var(--background_color) !important;
color: var(--text_color) !important;
}
.ui.tabular.menu .item.active {
background-color: var(--background_color) !important;
color: var(--text_color) !important;
border-color: var(--border_color) !important;
background-color: var(--background_color) !important;
color: var(--text_color) !important;
border-color: var(--border_color) !important;
}
/* OVERWRITE DEFAULT SEMANTIC STYLES FOR CUSTOM/NIGHT MODES*/
.color-picker {
color: var(--text_color);
background-color: var(--background_color);
position: absolute;
width: 175px;
height: 100px;
top: 26px;
padding: 10px;
border-radius: 5px;
left: -63px;
z-index: 100;
border: 1px solid;
border-color: var(--border_color) !important;
/* Styles for public display pages */
.fun {
color: rgba(0, 0, 0, 0.87);
color: var(--text_color);
}
.color-picker .button{
border: 1px solid !important;
border-color: var(--border_color) !important;
color: var(--border_color) !important;
.fun h1 {
font-size: 2em;
}
.fun h2 {
font-size: 1.9em;
}
.fun h3 {
font-size: 1.7em;
}
.fun p {
/*font-size: 1.5em;*/
}
.fun blockquote {
border-left: 5px solid cornflowerblue;
padding-left: 25px;
margin-left: 5px;
}
/* Styles for public display pages */
a:hover {
text-decoration: underline;
}
/*//
// Purple Global Menu
//*/
.note-menu {
width: 100%;
/*display: block;*/
display: inline-table;
background: var(--menu-background);
color: white;
/*overflow: hidden;*/
border: 1px solid var(--menu-border);
/*height: 50px;*/
}
.note-menu > .nm-button {
padding: 10px 15px;
cursor: pointer;
text-align: center;
box-sizing: border-box;
font-size: 1.2em;
vertical-align: middle;
/*height: 40px;*/
display: table-cell;
position: relative;
}
.nm-button i.icon {
margin: 0;
}
.nm-button span {
font-size: 0.9em;
}
.nm-button.right {
float: right;
border-left: 1px solid var(--menu-border);
}
.nm-button:hover {
background-color: #534c68;
color: white;
}
.nm-button + .nm-button {
border-left: 1px solid #534c68;
}
/*.shrink-icons-on-mobile.note-menu span {
display: none;
}*/
/* Shrink button text for mobile */
@media only screen and (max-width: 740px) {
.note-menu .nm-button span {
font-size: 0.7em;
line-height: 0.4em;
margin-left: 0;
}
.nm-button i.icon {
width: 100%;
}
/*prevents buttons from being jammed into corners of round phones*/
.shrink-icons-on-mobile.note-menu {
padding: 0 20px;
}
.shrink-icons-on-mobile .nm-button {
padding: 2px 3px;
}
.shrink-icons-on-mobile .nm-button i.icon {
font-size: 0.7em;
}
}
/*//
// Purple Global Menu
//*/
.note-status-indicator {
float: right;
width: 100px;
padding: 9px 0;
box-sizing: border-box;
text-align: center;
color: var(--text_color);
position: absolute;
width: 100px;
padding: 16px 0;
box-sizing: border-box;
text-align: center;
color: var(--text_color);
bottom: -13px;
right: -7px;
z-index: 100;
cursor: pointer;
}
/* squire text styles */
.squire-box {
border: none;
height: calc(100% - 69px);
box-sizing: border-box;
padding: 10px 15px 10px;
background: transparent;
overflow-x: scroll;
/*color: var(--text_color);*/
font-size: 1.2em;
line-height: 1.5em;
word-wrap: break-word;
/*border-bottom: 1px solid #ccc;*/
scrollbar-width: none;
}
/*Makes the first line real big */
.squire-box p:first-child {
font-size: 1.4em;
line-height: 1.7em;
}
.squire-box:focus {
outline: none;
}
.squire-box span.size {
line-height: 1.3em;
}
.squire-box a {
cursor: pointer;
}
.note-card-text i,
.squire-box i {
padding: 0.5em 0.99em;
border: 1px solid #CCC;
margin: 1px;
border-radius: 9px;
display: inline-block;
}
.squire-box p {
margin-bottom: 0;
}
.squire-box blockquote {
margin: 0;
padding: 0.8em;
border-left: 2px solid blue;
}
.note-card-text img {
max-width:100%;
height: auto;
max-height: 200px;
}
.squire-box img {
max-width:100%;
height: auto;
}
.note-card-text li > p,
.squire-box p,
.squire-box li > p {
margin-bottom: 0;
}
.note-card-text ul > li,
.squire-box ul > li {
position: relative;
list-style-type: none;
}
.note-card-text ul > li:before,
.squire-box ul > li:before {
content: "\f111";
font-family: 'Icons';
backface-visibility: hidden;
font-style: normal;
font-weight: normal;
text-decoration: inherit;
text-align: center;
line-height: 1.4em;
font-size: 0.75em;
height: 17px;
width: 17px;
display: inline-block;
position: absolute;
left: -30px;
/*border: 2px solid #444;*/
/*border-radius: 4px;*/
bottom: 0;
top: 4px;
cursor: pointer;
opacity: 0.7;
}
ul > li.active:before {
font-family: 'Icons';
content: "\f058";
color: #21BA45;
opacity: 1;
}
/* adjust checkboxes for mobile. Make them a little bigger, easier to click */
@media only screen and (max-width: 740px) {
.note-card-text ul > li,
.squire-box ul > li {
min-height: 30px;
}
.note-card-text ul > li:before,
.squire-box ul > li:before {
content: "\f111";
font-family: outline-icons;
height: 24px;
width: 24px;
left: -40px;
bottom: 0;
top: 0px;
cursor: pointer;
line-height: 0.9em;
font-size: 1.4em;
}
ul > li.active:before {
font-family: 'Icons';
content: "\f058";
color: #21BA45;
opacity: 1;
}
}
.clickable {
cursor: pointer;
}
.relative {
position: relative;
position: relative;
}
.float-right {
float: right;
float: right;
}
.textarea-height {
height: calc(100% - 90px);
height: calc(100% - 90px);
}
.ck-content {
font-family: 'Open Sans' !important;
font-size: 1.3rem !important;
background-color: rgba(255, 255, 255, 0);
height: calc(100% - 40px);
overflow: hidden;
}
.ck .ck-editor__nested-editable:focus {
background-color: var(--background_color) !important;
.mobile-textarea-height {
height: 100%;
}
.ui.white.button {
background: #FFF;
}
.input-floating-button {
position: absolute;
top: 19px;
transform: translateY(-50%);
right: 1px;
}
.fade-in-fwd {
animation: fade-in-fwd 0.8s both;
@@ -116,12 +363,12 @@ div.ui.basic.green.label {
* ----------------------------------------
*/
@keyframes fade-in-fwd {
0% {
transform: translateZ(-80px);
opacity: 0;
}
100% {
transform: translateZ(0);
opacity: 1;
}
0% {
transform: translateZ(-80px);
opacity: 0;
}
100% {
transform: translateZ(0);
opacity: 1;
}
}

File diff suppressed because one or more lines are too long

4995
client/src/assets/squire.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
<style type="text/css" scoped>
.numtainer {
height: 1.1em;
font-size: 1em;
overflow: hidden;
display: inline-block;
box-sizing: border-box;
}
.start-high {
color: #4dc86a;
animation: startHigh 0.5s forwards;
}
.start-low {
color: #4dc86a;
animation: startLow 0.5s forwards;
}
@keyframes startLow {
0% {
margin-top: 0;
}
100% {
margin-top: -1.2em;
}
}
@keyframes startHigh {
0% {
margin-top: -1.2em;
}
100% {
margin-top: 0;
}
}
</style>
<template>
<div class="numtainer">
<div v-if="animateUp">
<div class="start-high">{{ newNumber }}</div>
<div>{{ oldNumber }}</div>
</div>
<div v-if="animateDown">
<div class="start-low">{{ oldNumber }}</div>
<div>{{ newNumber }}</div>
</div>
<div v-if="totals">{{ totals[numberId] }}</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'AnimatedCounterComponent',
props: [ 'numberId' ],
data () {
return {
oldNumber: 100,
newNumber: 99,
animateUp: false,
animateDown: false,
}
},
computed: {
...mapGetters(['totals'])
},
watch:{
totals(newVal, oldVal){
if(oldVal && newVal && newVal[this.numberId] != oldVal[this.numberId]){
console.log('New number ', newVal[this.numberId])
this.oldNumber = oldVal[this.numberId]
this.newNumber = newVal[this.numberId]
if(this.oldNumber > this.newNumber){
this.animateDown = true
} else {
this.animateUp = true
}
setTimeout( () => {
this.animateUp = false
this.animateDown = false
}, 550)
}
}
},
beforeMount(){
},
mounted(){
},
methods: {
onFileClick(file){
},
}
}
</script>

View File

@@ -0,0 +1,219 @@
<style type="text/css" scoped>
.attachment-display-card {
width: 100%;
padding: 10px;
display: inline-block;
border: 1px solid;
border-color: var(--border_color);
border-radius: 4px;
margin: 0 0 15px;
max-height: 10000px;
}
.attachment-image {
max-width: 100%;
height: auto;
max-height: 300px;
}
.image-placeholder {
width: 100%;
height: 100%;
max-height: 100px;
}
.image-placeholder:after {
content: 'No Image';
display: block;
width: 20px;
height: 20px;
background:
green;
position: absolute;
top: 0;
left: 0;
}
.text {
width: 100%;
/*height: 100%;*/
background: transparent;
border: none;
border-top: 1px solid;
border-bottom: 1px solid;
border-color: var(--border_color);
font-size: 1.2em;
line-height: 1.3em;
/*margin: 0 0 10px;*/
padding: 10px 0 10px;
color: var(--text_color);
overflow: hidden;
resize: none;
/*transition: height 0.4s ease; This breaks the resize */
}
.link {
font-size: 1.4em;
margin: 20px 0 20px;
display: inline-block;
white-space: nowrap;
overflow:hidden;
text-overflow: ellipsis;
width: 100%;
line-height: 1.4em;
}
.flip-out {
animation: flip-out-hor-top 0.5s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
overflow: hidden;
transition: max-height 0.3s ease;
max-height: 0;
}
@keyframes flip-out-hor-top {
0% {
transform: rotateX(0);
opacity: 1;
}
100% {
transform: rotateX(70deg);
opacity: 0;
}
}
</style>
<template>
<div class="attachment-display-card" :class="{ 'flip-out':!unfolded }" v-if="visible">
<div class="ui stackable grid">
<!-- image and text -->
<div class="six wide center aligned middle aligned column">
<a :href="linkUrl" target="_blank" >
<img v-if="item.file_location" class="attachment-image" :src="`/api/static/thumb_${item.file_location}`">
<span v-else>
<img class="image-placeholder" loading="lazy" src="/api/static/assets/marketing/void.svg">
No Image
</span>
</a>
</div>
<div class="ten wide column">
<textarea ref="edit" class="text" v-on:blur="saveIt()" v-on:keyup="checkKeyup" v-model="text"></textarea>
<!-- link -->
<a class="link" :href="linkUrl" target="_blank">{{linkText}}</a>
<!-- Buttons -->
<div class="ui small compact basic button" v-on:click="openNote">
<i class="file outline icon"></i>
Open Note
</div>
<div class="ui small compact basic button" v-on:click="openEditAttachments"
:class="{ 'disabled':this.searchParams.noteId }">
<i class="folder open outline icon"></i>
Note Files
</div>
<div class="ui small compact basic icon button" v-on:click="deleteAttachment">
<i v-if="!working" class="trash alternate outline icon"></i>
<i v-if="working" class="purple spinner loading icon icon"></i>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
props: [ 'item', 'searchParams' ],
data: function(){
return {
text: '',
type: null,
linkText: 'Link',
linkUrl:null,
unfolded:true,
visible: true,
working: false,
}
},
beforeCreate: function(){
},
mounted: function(){
this.text = this.item.text
this.type = this.item.attachment_type
//1 = URL, 2 = image, >= 3 files
if(this.type == 1 && this.item.url != null){
this.linkText = this.item.url
this.linkUrl = this.item.url
}
if(this.type == 2){
this.linkText = 'Download'
this.linkUrl = `/api/static/${this.item.file_location}`
}
this.$nextTick(() => {
this.checkKeyup()
})
},
methods: {
checkKeyup(){
let elm = this.$refs.edit
if(elm){
elm.style.height = '0'
elm.style.height = elm.scrollHeight +'px'
}
},
openNote(){
const noteId = this.item.note_id
this.$router.push('/notes/open/'+noteId)
},
openEditAttachments(){
const noteId = this.item.note_id
this.$router.push('/attachments/note/'+noteId)
},
deleteAttachment(){
//No double clicks
if(this.working){ return }
this.working = true
axios.post('/api/attachment/delete', {'attachmentId':this.item.id})
.then( ({data}) => {
if(data){
this.unfolded = false
setTimeout( () => {
this.visible = false
this.$store.dispatch('fetchAndUpdateUserTotals')
}, 600)
}
})
},
saveIt(){
//Don't save text if it didn'th change
if(this.item.text == this.text){
return
}
const data = {
'attachmentId': this.item.id,
'updatedText': this.text,
'noteId': this.item.note_id
}
//Save it, and don't think about it.
axios.post('/api/attachment/update', data)
},
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div :style="{ 'background-color':allStyles['noteBackground'], 'color':allStyles['noteText']}">
<div class="ui basic segment">
<div class="ui grid">
<div class="ui sixteen wide center aligned column">
<div class="ui fluid button" v-on:click="clearStyles">
<i class="refresh icon"></i>
Clear All Styles
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<br>
<p>Note Color</p>
<div v-for="color in getReducedColors()"
class="color-button"
:style="{ backgroundColor:color }"
v-on:click="chosenColor(color)"
></div>
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<p>Note Icon
<span v-if="allStyles.noteIcon" >
<i :class="`large ${allStyles.noteIcon} icon`" :style="{ 'color':allStyles.iconColor }"></i>
</span>
</p>
<div v-for="icon in icons" class="icon-button" v-on:click="chosenIcon(icon)" >
<i :class="`large ${icon} icon`" :style="{ 'color':allStyles.iconColor }"></i>
</div>
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<p>Icon Color</p>
<div v-for="color in getReducedColors()"
class="color-button"
:style="{ backgroundColor:color }"
v-on:click="chooseIconColor(color)"
>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ColorPicker',
props: [ 'location', 'styleObject' ],
data () {
return {
allStyles:{ 'noteText':null,'noteBackground':null, 'noteIcon':null, 'iconColor':null },
blankStyle:{ 'noteText':null,'noteBackground':null, 'noteIcon':null, 'iconColor':null },
colors: [
"#ffebee","#ffcdd2","#ef9a9a","#e57373","#ef5350","#f44336","#e53935","#d32f2f","#c62828","#b71c1c","#fce4ec","#f8bbd0","#f48fb1","#f06292","#ec407a","#e91e63","#d81b60","#c2185b","#ad1457","#880e4f","#f3e5f5","#e1bee7","#ce93d8","#ba68c8","#ab47bc","#9c27b0","#8e24aa","#7b1fa2","#6a1b9a","#4a148c","#ede7f6","#d1c4e9","#b39ddb","#9575cd","#7e57c2","#673ab7","#5e35b1","#512da8","#4527a0","#311b92","#e8eaf6","#c5cae9","#9fa8da","#7986cb","#5c6bc0","#3f51b5","#3949ab","#303f9f","#283593","#1a237e","#e3f2fd","#bbdefb","#90caf9","#64b5f6","#42a5f5","#2196f3","#1e88e5","#1976d2","#1565c0","#0d47a1","#e1f5fe","#b3e5fc","#81d4fa","#4fc3f7","#29b6f6","#03a9f4","#039be5","#0288d1","#0277bd","#01579b","#e0f7fa","#b2ebf2","#80deea","#4dd0e1","#26c6da","#00bcd4","#00acc1","#0097a7","#00838f","#006064","#e0f2f1","#b2dfdb","#80cbc4","#4db6ac","#26a69a","#009688","#00897b","#00796b","#00695c","#004d40","#e8f5e9","#c8e6c9","#a5d6a7","#81c784","#66bb6a","#4caf50","#43a047","#388e3c","#2e7d32","#1b5e20","#f1f8e9","#dcedc8","#c5e1a5","#aed581","#9ccc65","#8bc34a","#7cb342","#689f38","#558b2f","#33691e","#f9fbe7","#f0f4c3","#e6ee9c","#dce775","#d4e157","#cddc39","#c0ca33","#afb42b","#9e9d24","#827717","#fffde7","#fff9c4","#fff59d","#fff176","#ffee58","#ffeb3b","#fdd835","#fbc02d","#f9a825","#f57f17","#fff8e1","#ffecb3","#ffe082","#ffd54f","#ffca28","#ffc107","#ffb300","#ffa000","#ff8f00","#ff6f00","#fff3e0","#ffe0b2","#ffcc80","#ffb74d","#ffa726","#ff9800","#fb8c00","#f57c00","#ef6c00","#e65100","#fbe9e7","#ffccbc","#ffab91","#ff8a65","#ff7043","#ff5722","#f4511e","#e64a19","#d84315","#bf360c","#efebe9","#d7ccc8","#bcaaa4","#a1887f","#8d6e63","#795548","#6d4c41","#5d4037","#4e342e","#3e2723","#fafafa","#f5f5f5","#eeeeee","#e0e0e0","#bdbdbd","#9e9e9e","#757575","#616161","#424242","#212121","#eceff1","#cfd8dc","#b0bec5","#90a4ae","#78909c","#607d8b","#546e7a","#455a64","#37474f","#263238","#ffffff","#000000"],
icons: ['ambulance','anchor','balance scale','bath','bed','beer','bell','bell slash','bell slash outline','bicycle','binoculars','birthday cake','blind','bomb','book','bookmark','briefcase','building','car','coffee','crosshairs','dollar sign','eye','eye slash','fighter jet','fire','fire extinguisher','flag','flag checkered','flask','gamepad','gavel','gift','glass martini','globe','graduation cap','h square','heart','heart outline','heartbeat','home','hospital','hospital outline','image','image outline','images','images outline','industry','info','info circle','key','leaf','lemon','lemon outline','life ring','life ring outline','lightbulb','lightbulb outline','location arrow','low vision','magnet','male','map','map outline','map marker','map marker alternate','map pin','map signs','medkit','money bill alternate','money bill alternate outline','motorcycle','music','newspaper','newspaper outline','paw','phone','phone square','phone volume','plane','plug','plus','plus square','plus square outline','print','recycle','road','rocket','search','search minus','search plus','ship','shopping bag','shopping basket','shopping cart','shower','street view','subway','suitcase','tag','tags','taxi','thumbtack','ticket alternate','tint','train','tree','trophy','truck','tty','umbrella','university','utensil spoon','utensils','wheelchair','wifi','wrench']
}
},
watch:{
styleObject: function(updatedStyles){
this.allStyles = updatedStyles
}
},
mounted(){
this.allStyles = this.styleObject
},
methods: {
getReducedColors(){
let reduced = []
this.colors.forEach((color,i) => {
if(i%20 <= 10){
return
}
let mod = (i % 10)+1 //1 - 10
let lines = [3, 5, 8, 9, 10]
if(lines.includes(mod)){
reduced.push(color)
}
})
reduced.push("#000")
return reduced
},
clearStyles(){
this.$emit('changeColor', this.blankStyle)
},
closeThisBitch(){
this.$emit('close')
},
chosenColor(inColor){
//Set not background to color that was chosen
this.allStyles.noteBackground = inColor
//Automatically select note text color
// Convert hex color to RGB - http://gist.github.com/983661
let color = +("0x" + inColor.slice(1).replace(inColor.length < 5 && /./g, '$&$&'));
let r = color >> 16;
let g = color >> 8 & 255;
let b = color & 255;
//Convert RGB to HSP
const hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) );
//If it has a BG color, default to sold black text
this.allStyles.noteText = '#000'
if(hsp < 127.5){
this.allStyles.noteText = '#FFF' //If color is dark, we need brighter text
}
this.$emit('changeColor', this.allStyles)
},
chosenIcon(inIcon){
this.allStyles.noteIcon = inIcon
this.$emit('changeColor', this.allStyles)
},
chooseIconColor(inColor){
this.allStyles.iconColor = inColor
this.$emit('changeColor', this.allStyles)
}
}
}
</script>
<style type="text/css" scoped>
.icon-button {
height: 40px;
width: 14.2%;
display: inline-block;
cursor: pointer;
font-size: 1.3em;
}
.color-button {
height: 50px;
width: 20%;
display: block;
cursor: pointer;
float: left;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<p>Crunch Menu</p>
<div v-for="(item, index) in items">
<slot :name="index"></slot>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'CrunchMenu',
data () {
return {
items: []
}
},
beforeMount(){
},
mounted(){
console.log(this)
// console.log(this.$slots.default)
this.$slots.default.forEach( vnode => {
if(vnode.tag && vnode.tag.length > 0){
this.items.push(vnode)
}
})
console.log(this.items)
},
methods: {
onClickTag(index){
console.log('yup')
},
}
}
</script>
<style type="text/css" scoped>
</style>

View File

@@ -20,9 +20,6 @@
'Order by Last Edited' :'lastEdited',
'Order by Last Opened' :'lastOpened',
'Order by Last Created' :'lastCreated',
'Only Show Notes with Links' :'withLinks',
'Only Show Notes with Tags' :'withTags',
'Only Show Archived Notes' :'onlyArchived',
}
}
},
@@ -32,9 +29,6 @@
})
},
methods:{
confirmDelete(){
this.click++
},
displayString(){
return this.orderString.replace('Order by','').replace('Only Show','')
},
@@ -54,7 +48,7 @@
<style type="text/css" scoped>
.filter-header {
width: 270px;
width: 200px;
padding: 0 0 0 10px;
border: 1px solid rgba(0,0,0,0);
border-bottom: none;
@@ -62,24 +56,27 @@
box-sizing: border-box;
border-top-right-radius: 5px;
border-top-left-radius: 5px;
margin: 0 0 0 -11px;
float: right;
}
.filter-menu {
color: var(--text_color);
background-color: var(--background_color);
border-color: var(--border_color);
border: 1px solid;
border-top: none;
position: absolute;
top: 100%;
width: 270px;
width: 200px;
left: -1px;
z-index: 10;
padding-top: 10px;
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
border-color: var(--border_color);
}
.filter-active {
border: 1px solid;

View File

@@ -0,0 +1,91 @@
<style type="text/css" scoped>
.hidden-up {
opacity: 0;
position: absolute;
top: -50000px;
}
</style>
<template>
<form data-tooltip="Upload File" data-inverted>
<label :for="`upfile-${noteId}`" class="clickable">
<nm-button icon="upload" :text="uploadStatusText"/>
</label>
<input class="hidden-up" type="file" :id="`upfile-${noteId}`" ref="file" v-on:change="handleFileUpload()" />
<!-- <button v-if="file" v-on:click="uploadFileToServer()">Submit</button> -->
</form>
</template>
<script>
import axios from 'axios'
export default {
name: 'FileUploadButton',
props: [ 'noteId' ],
components: {
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
},
data () {
return {
file: null,
uploadStatusText: 'Upload',
}
},
mounted(){
// console.log(this.noteId)
},
methods: {
uploadFileToServer() {
let formData = new FormData();
formData.append('file', this.file);
formData.append('noteId', this.noteId)
console.log('>> formData >> ', formData);
// You should have a server side REST API
axios.post('/api/attachment/upload',
formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: ( progressEvent ) => {
this.uploadStatusText = parseInt(
Math.round( ( progressEvent.loaded * 100 ) / progressEvent.total ) )
}
}
).then(results => {
this.uploadStatusText = 'Working'
this.file = null
// console.log('File upload results')
// console.log(results.data)
const name = results.data.fileName
const location = results.data.goodFileName
this.$bus.$emit('notification', 'Processing Upload')
if(name && location){
this.uploadStatusText = 'Upload File'
const imageCode = `<img alt="image" src="/api/static/thumb_${location}">`
this.$bus.$emit('new_file_upload', {noteId: this.noteId, imageCode})
this.$store.dispatch('fetchAndUpdateUserTotals')
}
})
.catch(results => {
this.uploadStatusText = 0
})
},
handleFileUpload() {
//Grab file and push note id to into data
this.file = this.$refs.file.files[0]
console.log('>>>> 1st element in files array >>>> ')
console.log(this.file)
if(this.file){
this.uploadFileToServer()
}
}
}
}
</script>

View File

@@ -0,0 +1,82 @@
<style type="text/css" scoped>
.popup-body {
position: fixed;
bottom: 15px;
right: 15px;
min-height: 50px;
min-width: 200px;
max-width: calc(100% - 20px);
z-index: 1002;
border-top: 2px solid #21ba45;
box-shadow: 0px 0px 5px 2px rgba(140,140,140,1);
border-top-right-radius: 4px;
border-top-left-radius: 4px;
color: var(--text_color);
background-color: var(--background_color);
}
.popup-row {
padding: 1em 5px;
cursor: pointer;
white-space: nowrap;
}
.popup-row > span {
width: calc(100% - 50px);
display: inline-block;
text-align: center;
box-sizing: border-box;
padding: 0 10px 0;
font-size: 1.25em;
}
.popup-row + .popup-row {
border-top: 1px solid #000;
}
</style>
<template>
<div class="popup-body" v-on:click="dismiss" v-if="notifications.length > 0">
<div class="popup-row" v-for="item in notifications">
<i class="disabled angle left icon"></i>
<span>{{ item }}</span>
<i class="disabled angle right icon"></i>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'GlobalNotificationComponent',
data () {
return {
notifications: [],
totalTimeout: null,
}
},
beforeMount(){
this.$bus.$on('notification', info => {
this.displayNotification(info)
})
},
mounted(){
},
methods: {
displayNotification(newNotification){
this.notifications.push(newNotification)
clearTimeout(this.totalTimeout)
this.totalTimeout = setTimeout(() => {
this.dismiss()
}, 4000)
},
dismiss(){
this.notifications = []
}
}
}
</script>

View File

@@ -0,0 +1,323 @@
<style scoped>
.slotholder {
height: 100vh;
width: 140px;
display: block;
float: left;
}
.global-menu {
width: 140px;
background: #221f2b;
margin: 0;
padding: 0;
box-sizing: border-box;
display: block;
position: fixed;
z-index: 111;
top: 0;
left: 0;
bottom: 0;
}
.menu-item {
color: #fff;
padding: 0.8em 10px 0.8em 10px;
display: inline-block;
width: 100%;
font-size: 1.15em;
box-sizing: border-box;
}
.menu-item i.icon {
margin-right: 10px;
}
.sub {
padding-left: 20px;
}
.menu-section {}
.menu-section + .menu-section {
border-top: 1px solid #534c68;
}
.menu-button {
cursor: pointer;
}
.menu-button:hover {
background-color: #534c68;
text-decoration: none;
}
.router-link-active i {
/*color: #16ab39;*/
}
.router-link-active {
background-color: #534c68;
}
.shade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.7);
z-index: 100;
cursor: pointer;
}
.top-menu-bar {
/*color: var(--text_color);*/
/*width: 100%;*/
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: var(--background_color);
border-bottom: 1px solid;
border-color: var(--border_color);
padding: 5px 1rem 5px;
}
.place-holder {
width: 100%;
height: 50px;
}
.top-menu-bar img {
width: 30px;
height: 30px;
}
</style>
<template>
<div>
<div class="place-holder" v-if="collapsed && !menuOpen"></div>
<!-- collapsed menu, appears as a bar -->
<div class="top-menu-bar" v-if="(collapsed || mobile) && !menuOpen">
<div class="ui grid">
<div class="seven wide column">
<div class="ui large basic compact icon button" v-on:click="collapseMenu">
<i class="green bars icon"></i>
</div>
<router-link class="ui large basic compact icon button" to="/notes" v-on:click.native="emitReloadEvent()">
<i class="green home icon"></i>
</router-link>
<div v-on:click="toggleNightMode" class="ui large basic compact icon button">
<i class="green moon outline icon"></i>
</div>
</div>
<div class="six wide center aligned column">
<img v-if="!loggedIn" src="/api/static/assets/favicon.ico" alt="logo" />
<search-input v-if="loggedIn && mobile"></search-input>
</div>
<div class="three wide right aligned column">
<!-- mobile create note button -->
<div v-if="loggedIn">
<div v-if="!disableNewNote" @click="createNote" class="ui large basic compact icon button">
<i class="green plus icon"></i>
</div>
<div v-if="disableNewNote" class="ui large basic compact icon button">
<i class="grey plus icon"></i>
</div>
</div>
</div>
</div>
</div>
<div class="shade" v-if="mobile && !collapsed" v-on:click="collapseMenu"></div>
<div class="slotholder" v-if="!collapsed && !mobile">
</div>
<div class="global-menu" v-if="!collapsed" v-on:click="menuClicked">
<div class="menu-section">
<div class="menu-item menu-button" v-on:click="collapseMenu">
<i class="angle left icon"></i>
</div>
</div>
<div class="menu-section" v-if="loggedIn">
<div v-if="!disableNewNote" @click="createNote" class="menu-item menu-item menu-button">
<i class="green plus icon"></i>New Note
</div>
<div v-if="disableNewNote" class="menu-item menu-item menu-button">
<i class="purple plus icon"></i>Creating
</div>
</div>
<div class="menu-section" v-if="loggedIn">
<router-link exact-active-class="active" class="menu-item menu-button" to="/notes" v-on:click.native="emitReloadEvent()">
<i class="file outline icon"></i>Notes
<counter class="float-right" number-id="totalNotes" />
</router-link>
<div>
<!-- <div class="menu-item sub">Show Only <i class="caret down icon"></i></div> -->
<!-- <div v-on:click="updateFastFilters(0)" class="menu-item menu-button sub"><i class="grey linkify icon"></i>Links</div> -->
<!-- <div v-on:click="updateFastFilters(1)" class="menu-item menu-button sub"><i class="grey tags icon"></i>Tags</div> -->
</div>
</div>
<div class="menu-section" v-if="loggedIn && $store.getters.totals && $store.getters.totals['totalFiles']">
<router-link class="menu-item menu-button" exact-active-class="active" to="/attachments">
<i class="folder open outline icon"></i>Files
<counter class="float-right" number-id="totalFiles" />
</router-link>
</div>
<div class="menu-section" v-if="loggedIn">
<router-link v-if="loggedIn" exact-active-class="active" class="menu-item menu-button" to="/quick">
<i class="paper plane outline icon"></i>Quick
</router-link>
</div>
<div class="menu-section" v-if="!loggedIn">
<router-link v-if="!loggedIn" class="menu-item menu-button" exact-active-class="active" to="/">
<i class="home icon"></i>Welcome
</router-link>
<router-link exact-active-class="active" class="menu-item menu-button" to="/login">
<i class="plug icon"></i>Login
</router-link>
</div>
<div class="menu-section">
<div v-on:click="toggleNightMode" class="menu-item menu-button">
<span v-if="$store.getters.getIsNightMode">
<i class="moon outline icon"></i>Light Theme</span>
<span v-else>
<i class="moon outline icon"></i>Dark Theme</span>
</div>
</div>
<div class="menu-section" v-if="loggedIn" data-tooltip="Click to log out" data-inverted="" data-position="right center">
<div v-if="loggedIn" v-on:click="destroyLoginToken" class="menu-item menu-button">
<i class="user outline icon"></i>{{ucWords($store.getters.getUsername)}}
</div>
</div>
<!-- <router-link class="ui basic compact button" exact-active-class="active" to="/help">
<i class="question mark icon"></i>Help
</router-link> -->
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
components: {
'search-input': require('@/components/SearchInput.vue').default,
'counter':require('@/components/AnimatedCounterComponent.vue').default,
},
data: function(){
return {
username: '',
collapsed: false,
mobile: false,
disableNewNote: false,
menuOpen: true,
}
},
beforeCreate: function(){
},
mounted: function(){
this.mobile = this.$store.getters.getIsUserOnMobile
this.collapsed = this.$store.getters.getIsUserOnMobile
// {{ totals['totalNotes'] }}
if(this.mobile){
this.menuOpen = false
}
},
computed: {
loggedIn () {
//Map logged in from state
return this.$store.getters.getLoggedIn
}
},
methods: {
menuClicked(){
//Collapse menu when item is clicked in mobile
if(this.mobile && !this.collapsed){
this.collapsed = true
this.menuOpen = false
}
},
collapseMenu(){
this.collapsed = !this.collapsed
if(!this.collapsed){
this.menuOpen = true
} else {
this.menuOpen = false
}
},
createNote(event){
const title = ''
this.disableNewNote = true
axios.post('/api/note/create', {title})
.then(response => {
if(response.data && response.data.id){
this.$router.push('/notes/open/'+response.data.id)
this.$bus.$emit('open_note', response.data.id)
this.disableNewNote = false
}
})
},
destroyLoginToken() {
this.$bus.$emit('notification', 'Logged Out')
this.$store.commit('destroyLoginToken')
this.$router.push('/')
},
toggleNightMode(){
this.$store.commit('toggleNightMode')
},
ucWords(str){
return (str + '')
.replace(/^(.)|\s+(.)/g, function ($1) {
return $1.toUpperCase()
})
},
emitReloadEvent(){
//Reloads note page to initial state
this.$bus.$emit('note_reload')
},
updateFastFilters(index){
//A little hacky, brings user to notes page then filters on click
if(this.$route.name != 'NotesPage'){
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
]
let filter = {}
filter[options[index]] = 1
this.$bus.$emit('update_fast_filters', filter)
}
}
}
</script>

View File

@@ -1,9 +1,9 @@
<template>
<span>
<span class="clickable" @click="confirmDelete()" v-if="click == 0" data-tooltip="Delete">
<span class="clickable" @click="confirmDelete()" v-if="click == 0" data-tooltip="Delete" data-inverted="" data-position="top right">
<i class="grey trash alternate icon"></i>
</span>
<span class="clickable" @click="actuallyDelete()" @mouseleave="reset" v-if="click == 1" data-tooltip="Click again to delete." data-position="left center">
<span class="clickable" @click="actuallyDelete()" @mouseleave="reset" v-if="click == 1" data-tooltip="Click again to delete." data-position="top right" data-inverted="">
<i class="red trash alternate icon"></i>
</span>
</span>
@@ -26,9 +26,11 @@
this.click++
},
actuallyDelete(){
axios.post('/api/note/delete', {'noteId':this.noteId}).then(response => {
if(response.data == true){
this.$bus.$emit('note_deleted')
this.$bus.$emit('note_deleted', this.noteId)
this.$store.dispatch('fetchAndUpdateUserTotals')
}
})
},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<template>
<div class="nm-button" :class="moreClass" :data-tooltip="tip" data-inverted>
<!-- Display Icon and text -->
<i v-if="icon" :class="`${icon} icon`"></i>
<span v-if="(text && mobile) || (text && showText)">{{text}}</span>
<slot></slot>
</div>
</template>
<script>
/*
Menu button
Single Icon View
Single Icon With small text on mobile
Tooltips on desktop
Tooltip above or below
*/
export default {
name: 'NoteMenuButtonComponent',
props: [ 'icon', 'text', 'tooltip', 'moreClass', 'showText', 'tip'],
data () {
return {
files: [],
mobile: false,
showTooltip: false,
}
},
beforeMount(){
this.mobile = this.$store.getters.getIsUserOnMobile
},
mounted(){
// console.log('Im a button')
},
methods: {
onFileClick(file){
},
}
}
</script>

View File

@@ -1,32 +1,36 @@
<template>
<div v-on:mouseover="fullTagEdit = true">
<!-- simple string view -->
<div v-if="!fullTagEdit" class="ui basic segment">
<div class="simple-tag-display">
<!-- Show Loading -->
<span v-if="!loaded">Loading Tags...</span>
<!-- Default count -->
<span v-if="loaded">
<i class="tags icon"></i> <b>{{tags.length}} Tags</b>
</span>
<!-- No tags default text -->
<span v-if="tags.length == 0 && loaded" class="ui small compact green button">
<i class="plus icon"></i> Add a tag
</span>
<!-- display tags in comma delimited list -->
<span v-if="tags.length > 0">
<span v-for="(tag, i) in tags"><span v-if="i > 0">, </span>{{ucWords(tag.text)}}</span>
</span>
</div>
</div>
<div>
<!-- hover over view -->
<div v-if="fullTagEdit" class="full-tag-area fade-in-fwd" v-on:mouseleave="fullTagEdit = false; clearSuggestions()">
<div v-if="fullTagEdit" class="full-tag-area fade-in-fwd">
<div class="ui grid">
<div class="sixteen wide column">
<h2><i class="green tags icon"></i>Edit Tags</h2>
</div>
<div class="sixteen wide column">
<h3>All Tags</h3>
<h4 v-if="allTags.length == 0">No tags yet, add a tag.</h4>
<div v-if="allTags.length > 0">
<div class="ui icon large label clickable" v-for="tag in allTags" :class="{ 'green':isTagOnNote(tag.id) }" v-on:click="toggleTag(tag.text, tag.id, tag.entryId)">
{{ ucWords(tag.text) }}
</div>
</div>
</div>
<div class="sixteen wide column">
<h3>Tags on Note</h3>
<div v-if="allTags.length > 0 && noteTagIds.length > 0">
<div class="ui icon large label" v-for="tag in noteTagIds">
{{ getTagTextById(tag['tagId']) }} <i class="delete icon" v-on:click="removeTag(tag['entryId'])"></i>
</div>
</div>
</div>
</div>
<!-- tag input and suggestion popup -->
<div class="ui form">
@@ -39,19 +43,12 @@
v-on:focus="onFocus"
/>
<div class="suggestion-box" v-if="suggestions.length > 0">
<div class="suggestion-item" v-for="(item, index) in suggestions" :class="{ 'active':(index == selection) }" v-on:click="onClickTag(index)">
<div class="suggestion-item" v-for="(item, index) in suggestions" :class="{ 'active':(index == selection) }" v-on:click="onSuggestionClick(index)">
{{ucWords(item.text)}} <span class="suggestion-tip" v-if="index == selection">Press Enter to add</span>
</div>
</div>
</div>
<!-- existing tags -->
<div class="delete-tag-display" v-if="tags.length > 0">
<div class="ui icon large label" v-for="tag in tags" :class="{ 'green':(newTagInput == tag.text) }">
{{ucWords(tag.text)}} <i class="delete icon" v-on:click="removeTag(tag.id)"></i>
</div>
</div>
</div>
</div>
</template>
@@ -69,9 +66,11 @@
newTagInput: '',
typeDebounce: null,
allTags: [],
noteTagIds: [],
suggestions: [],
selection: 0,
fullTagEdit: false,
fullTagEdit: true,
loaded: false,
}
},
@@ -84,14 +83,38 @@
methods: {
getTags(){
//Get Note Tags -> /api/tags/get
let vm = this
axios.post('/api/tag/get', {'noteId': this.noteId})
.then(response => {
vm.loaded = true
//Set up local data
vm.tags = response.data
.then( ({data}) => {
this.loaded = true
this.allTags = data.allTags
this.noteTagIds = data.noteTagIds
})
},
isTagOnNote(id){
for (let i = 0; i < this.noteTagIds.length; i++) {
const current = this.noteTagIds[i]
if(current && current['tagId'] == id){
return true
}
}
return false
},
getTagTextById(id){
let tag = this.getTagById(id)
if(tag && tag.text){
return this.ucWords( tag.text )
}
},
getTagById(id){
for (let i = 0; i < this.allTags.length; i++) {
const current = this.allTags[i]
if(current && current['id'] == id){
return current
}
}
},
tagInput(event){
let vm = this
@@ -150,9 +173,38 @@
}
}, 300)
},
onClickTag(index){
onSuggestionClick(index){
this.newTagInput = this.suggestions[index].text
this.addTag()
},
toggleTag(tagText, id){
//remove tag
if(this.isTagOnNote(id)){
//Find database ID for tag
let entryId = null
this.noteTagIds.forEach(tag => {
if(tag.tagId == id){
entryId = tag.entryId
return
}
})
//Submit database entry to be removed
if(entryId){
this.removeTag(entryId)
}
return
}
//Add Tag
this.newTagInput = tagText
this.addTag()
return
},
addTag(){
@@ -162,7 +214,7 @@
}
let postData = {
'tagText':this.newTagInput,
'tagText':this.newTagInput.trim(),
'noteId':this.noteId
}
let vm = this
@@ -176,6 +228,7 @@
})
},
onFocus(){
return
//Show suggested tags
let vm = this
let postData = {
@@ -206,14 +259,16 @@
},
removeTag(tagId){
console.log(tagId)
let postData = {
'tagId':tagId,
'noteId':this.noteId
}
let vm = this
axios.post('/api/tag/removefromnote', postData)
.then(response => {
vm.getTags()
this.getTags()
})
},
clearSuggestions(){
@@ -233,28 +288,24 @@
/* note tag edit area */
.full-tag-area {
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: var(--text_color);
background-color: var(--background_color);
padding: 15px;
border: 1px solid;
/*padding: 15px;*/
/*border: 1px solid;*/
border-color: var(--border_color);
}
.full-tag-area .delete-tag-display {
margin-top: 15px;
/*margin-top: 15px;*/
}
.full-tag-area .ui.label {
margin-bottom: 5px;
}
.simple-tag-display {
width: 100%;
width: calc(100% - 0px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 35px;
color: var(--text_color);
cursor: pointer;
}
/* tag suggestion box styles */
@@ -269,8 +320,8 @@
height: 40px;
padding: 10px 15px;
cursor: pointer;
background: white;
color: black;
background-color: var(--background_color);
color: var(--text_color);
}
.suggestion-item.active {
background: green;

View File

@@ -1,71 +1,82 @@
<template>
<div class="note-title-display-card fade-in-fwd"
:style="{'background-color':color, 'color':fontColor}"
<div class="note-title-display-card"
:style="{'background-color':color, 'color':fontColor, 'border-color':color }"
:class="{'currently-open':currentlyOpen}"
>
<!-- fade-in-fwd -->
<div v-if="noteIcon" class="badge">
<i :class="`large ${noteIcon} icon`" :style="{ 'color':iconColor }"></i>
</div>
<div class="ui grid max-height">
<!-- Show title and snippet below it -->
<div class="top aligned row" @click.stop="onClick(note.id)">
<div class="sixteen wide column overflow-hidden" v-if="isShowingSearchResults()">
<div class="top aligned row" @click.self="onClick(note.id)">
<div class="sixteen wide column overflow-hidden note-card-text" @click="e => onClick(note.id, e)">
<div class="subtext" v-if="note.shareUsername">Shared by {{ note.shareUsername }}</div>
<div class="subtext" v-if="note.shared == 2">You Shared</div>
<!-- Title display -->
<div v-if="note.title.length > 0"
data-test-id="title"
:class="{ 'big-text':(note.titleLength <= 100), 'small-text-title':(note.titleLength >= 100) }"
v-html="note.title"></div>
<!-- Sub text display -->
<div v-if="note.subtext.length > 0 && !isShowingSearchResults()"
data-test-id="subtext"
:class="{ 'big-text':(note.subtextLength <= 100 && note.titleLength <= 100), 'small-text':(note.subtextLength >= 100) }"
v-html="note.subtext"></div>
<!-- Display highlights from solr results -->
<div v-if="note.note_highlights.length > 0" class="term-usage">
<h4><i class="paragraph icon"></i> Found in Text</h4>
<div class="usage-row" v-for="highlight in note.note_highlights" v-html="cleanHighlight(highlight)"></div>
<div v-if="note.note_highlights.length > 0 && textResults" class="term-usage">
<div
class="usage-row"
v-for="highlight in note.note_highlights"
:class="{ 'big-text':(highlight <= 100), 'small-text-title':(highlight >= 100) }"
v-html="cleanHighlight(highlight)"></div>
</div>
<div v-if="note.attachment_highlights.length > 0" class="term-usage">
<h4><i class="linkify icon"></i> Found in URL</h4>
<div class="usage-row" v-for="highlight in note.attachment_highlights" v-html="cleanHighlight(highlight)"></div>
</div>
<div v-if="note.tag_highlights.length > 0" class="term-usage">
<h4><i class="tags icon"></i> Found in Tags</h4>
<div class="usage-row" v-for="highlight in note.tag_highlights">
<span
v-for="tag in splitTags(highlight)"
class="ui label"
>
<span v-html="tag"></span>
</div>
</div>
<!-- Toolbar on the bottom -->
<div class="bottom aligned row" @click.self="onClick(note.id)">
<div class="sixteen wide column">
<div class="ui grid reduced-padding">
<div class="thirteen wide column clickable icon-bar" @click="onClick(note.id)">
<!-- {{$helpers.timeAgo(note.updated)}} -->
<span v-if="note.tags">
<span v-for="tag in (note.tags.split(','))" class="little-tag">{{ tag }}</span>
</span>
<span v-if="note.pinned == 1" data-position="top right" data-tooltip="Pinned" data-inverted="">
<i class="green pin icon"></i>
</span>
<span v-if="note.archived == 1" data-position="top right" data-tooltip="Archived" data-inverted="">
<i class="green archive icon"></i>
</span>
</div>
<div class="three wide right aligned column">
<delete-button :class="{ 'hover-hide':(!$store.getters.getIsUserOnMobile) }" :note-id="note.id" />
</div>
<div class="row" v-if="note.thumbs">
<div class="tiny-thumb-box" v-on:click="openEditAttachment">
<img v-for="thumb in note.thumbs.split(',').reverse()" class="tiny-thumb" :src="`/api/static/thumb_${thumb}`">
</div>
</div>
</div>
</div>
<div class="sixteen wide column overflow-hidden">
<h3 class="clickable">{{note.title}}</h3>
</div>
<div class="sixteen wide column overflow-hidden">
<p class="clickable">{{note.subtext}}</p>
</div>
</div>
<div class="bottom aligned row" @click.self.stop="onClick(note.id)">
<div class="six wide column clickable" @click.stop="onClick(note.id)">
{{$helpers.timeAgo(note.updated)}}
</div>
<div class="ten wide right aligned column split-spans">
<span v-if="note.pinned == 1" data-tooltip="Pinned">
<i class="green pin icon"></i>
</span>
<span v-if="note.archived == 1" data-tooltip="Archived">
<i class="green archive icon"></i>
</span>
<span v-if="note.attachment_count > 0">
<i class="linkify icon"></i> {{note.attachment_count}}
</span>
<span v-if="note.tag_count == 1" data-tooltip="Note has 1 tag">
<i class="tags icon"></i> {{note.tag_count}}
</span>
<span v-if="note.tag_count > 1" :data-tooltip="`Note has ${note.tag_count} tags`">
<i class="tags icon"></i> {{note.tag_count}}
</span>
<delete-button :note-id="note.id" />
</div>
</div>
</div>
</div>
</template>
@@ -73,7 +84,7 @@
export default {
name: 'NoteTitleDisplayCard',
props: [ 'onClick', 'data', 'currentlyOpen' ],
props: [ 'onClick', 'data', 'currentlyOpen', 'textResults', 'attachmentResults', 'tagResults' ],
components: {
'delete-button': require('@/components/NoteDeleteButtonComponent.vue').default,
},
@@ -93,37 +104,86 @@
},
splitTags(text){
return text.split(',')
}
},
openEditAttachment(){
this.$router.push('/attachments/note/'+this.note.id)
},
},
data () {
return {
note: null,
color: null, //'#FFF',
fontColor: null, //'#000'
color: null,
fontColor: null,
noteIcon: null,
iconColor: null,
}
},
beforeMount(){
this.note = this.data
if(this.note.color != null && this.note.color != '#FFF'){
this.color = this.note.color
this.fontColor = '#FFF'
if(this.note.color != null){
const styles = JSON.parse(this.note.color)
//Set background color
if(styles.noteBackground){
this.color = styles.noteBackground
}
//set text color
if(styles.noteText){
this.fontColor = styles.noteText
}
if(styles.noteIcon){
this.noteIcon = styles.noteIcon
}
if(styles.iconColor){
this.iconColor = styles.iconColor
}
}
}
}
</script>
<style type="text/css">
/*Strict font sizes for card display*/
.small-text, .small-text > p, .small-text > h1, .small-text > h2 {
/*font-size: 1.0em !important;*/
font-size: 15px !important;
}
.small-text > p, , .small-text > h1, .small-text > h2 {
margin-bottom: 0.5em;
}
.big-text, .big-text > p, .big-text > h1, .big-text > h2 {
/*font-size: 1.3em !important;*/
font-size: 16px !important;
font-weight: bold;
}
.big-text > p, .big-text > h1, .big-text > h2 {
margin-bottom: 0.3em;
}
.note-title-display-card h3 {
font-size: 1rem;
font-weight: bold;
line-height: 1.5rem;
}
.term-usage {
border-bottom: 1px solid #DDD;
padding-bottom: 10px;
margin-bottom: 10px;
/*border-bottom: 1px solid #DDD;*/
/*padding-bottom: 10px;*/
margin-top: 15px;
width: 100%;
}
.term-usage em {
color: green;
font-weight: bold;
font-size: 1.1rem;
font-style: normal;
}
.usage-row + .usage-row {
padding: 8px 0 0;
@@ -133,22 +193,77 @@
.note-title-display-card {
position: relative;
box-shadow: 0 1px 2px 0 rgba(34,36,38,.15);
margin: 0 15px 15px 0;
padding: 1em;
/*box-shadow: 0 1px 2px 0 rgba(34,36,38,.15);*/
box-shadow: 0 0px 5px 1px rgba(34,36,38,0);
margin: 5px;
padding: 0.7em 1em;
border-radius: .28571429rem;
border: 1px solid;
border-color: var(--border_color);
width: calc(33.333% - 15px);
transition: box-shadow 0.3s;
/*width: calc(33.333% - 10px);*/
width: calc(25% - 10px);
/*transition: box-shadow 0.3s;*/
box-sizing: border-box;
cursor: pointer;
line-height: 1.8rem;
letter-spacing: 0.02rem;
}
.note-title-display-card:hover {
box-shadow: 0 1px 2px -0 rgba(34,36,38,.50);
/*box-shadow: 0 3px 6px -0 rgba(34,36,38,.50);*/
box-shadow: 0 0px 5px 1px rgba(34,36,38,0.3);
}
.icon-bar {
opacity: 0.8;
/*margin-top: -2.2rem;*/
}
.hover-hide {
opacity: 0.0;
}
.little-tag {
font-size: 0.7em;
padding: 5px 5px;
border: 1px solid var(--border_color);
margin: 5px 3px 0 0;
border-radius: 3px;
white-space: nowrap;
max-width: 100px;
overflow: hidden;
display: inline-block;
line-height: 0.8em;
text-overflow: ellipsis;
}
.tiny-thumb-box {
max-height: 70px;
overflow: hidden;
width: 100%;
display: inline-block;
background-color: rgba(200, 200, 200, 0.2);
white-space: nowrap;
overflow-x: scroll;
border: 1px solid var(--border_color);
border-left: none;
border-right: none;
text-align: center;
scrollbar-width: none;
}
.tiny-thumb {
max-height: 70px;
display: inline-block;
}
.note-title-display-card:hover .icon-bar {
opacity: 1;
}
.note-title-display-card:hover .hover-hide {
opacity: 1;
}
.one-column .note-title-display-card {
margin-right: 65%;
width: 33%;
/*margin-right: 65%;*/
/*width: 33%;*/
width: 100%;
}
.overflow-hidden {
overflow: hidden;
@@ -162,6 +277,7 @@
.currently-open:after {
content: 'Open';
position: absolute;
cursor: default;
top: 0;
bottom: 0;
left: 0;
@@ -176,10 +292,17 @@
font-size: 3rem;
}
.badge {
position: absolute;
top: 7px;
right: 6px;
}
/* Tweak mobile display to show only one column */
@media only screen and (max-width: 740px) {
.note-title-display-card {
width: 100%;
margin: 15px 0 0 0;
width: calc(100% + 10px);
margin: 0px -5px 10px -5px;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="ui form">
<div class="ui left icon fluid input">
<input v-model="searchTerm" @keyup="searchKeyUp" @:keyup.enter="search" placeholder="Search Notes and Files" />
<i class="search icon"></i>
</div>
</div>
</template>
<script>
export default {
data: function(){
return {
searchTerm: '',
searchTimeout: null,
searchDebounceDuration: 300,
}
},
beforeCreate: function(){
},
mounted: function(){
//search clear
this.$bus.$on('reset_fast_filters', () => {
this.searchTerm = ''
})
},
methods: {
searchKeyUp(){
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
this.search()
}, this.searchDebounceDuration)
},
search(){
this.$bus.$emit('update_search_term', this.searchTerm)
},
}
}
</script>

View File

@@ -0,0 +1,112 @@
<style type="text/css" scoped>
</style>
<template>
<div>
<div class="ui grid" v-if="this.shareUsername == null">
<div class="row">
<div class="eight wide column">
<div class="ui form">
<div class="field">
<input type="text" placeholder="Share with someone" v-model="shareUserInput" v-on:keyup="onKeyup">
</div>
</div>
</div>
<div class="eight wide column">
<div class="ui disabled button" v-if="shareUserInput.length == 0">
Share
</div>
<div class="ui green button" v-if="shareUserInput.length > 0" v-on:click="onSubmitClick">
Share
</div>
</div>
</div>
<div class="sixteen wide column" v-if="sharedWithUsers.length > 0">
<h3>Users who can edit this note</h3>
</div>
<div class="row" v-for="item in sharedWithUsers">
<div class="eight wide middle aligned column">
<h3><i class="green user circle icon"></i>{{item.username}}</h3>
</div>
<div class="eight wide column">
<div class="ui basic compact button" v-on:click="onRevokeAccess(item.noteId)">Remove Access</div>
</div>
</div>
</div>
<div class="ui grid" v-if="this.shareUsername != null">
<div class="sixteen wide column">
Shared with you by <h3><i class="green user circle icon"></i>{{shareUsername}}</h3>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'ShareNoteComponent',
props: [ 'noteId', 'rawTextId', 'shareUsername' ],
data () {
return {
sharedWithUsers: [],
shareUserInput: '',
debounce: null,
enableSubmitShare: false,
}
},
beforeMount(){
},
mounted(){
if(this.shareUsername == null){
this.loadShareList()
}
},
methods: {
loadShareList(){
axios.post('/api/note/getshareusers', {'rawTextId':this.rawTextId })
.then( ({data}) => {
this.sharedWithUsers = data
})
},
onRevokeAccess(noteId){
axios.post('/api/note/shareremoveuser', {'noteId':noteId})
.then( ({data}) => {
console.log(data)
if(data == true){
this.loadShareList()
}
})
},
onKeyup(event){
if(event.keyCode == 13){
this.onSubmitClick()
return
}
},
onSubmitClick(){
axios.post('/api/note/shareadduser', {'noteId':this.noteId, 'rawTextId':this.rawTextId, 'username':this.shareUserInput })
.then( ({data}) => {
if(data == true){
this.shareUserInput = ''
this.loadShareList()
} else {
this.$bus.$emit('notification', 'User not found')
}
})
},
}
}
</script>

View File

@@ -0,0 +1,148 @@
<style type="text/css" scoped>
.slide-container {
position: fixed;
top: 0;
left: 0;
right: 55%;
bottom: 0;
z-index: 400;
overflow: hidden;
height: 100%;
color: var(--text_color);
background-color: var(--background_color);
}
.slide-content {
box-sizing: border-box;
/*padding: 1em 1.5em;*/
height: calc(100% - 43px);
border-right: 1px solid var(--menu-border);
/*background-color: var(--background_color);*/
overflow-x: scroll;
}
.slide-shadow {
position: fixed;
top: 0;
left: 0;
right: 50%;
bottom: 0;
color: red;
background-color: rgba(0,0,0,0.5);
/*background: linear-gradient(90deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 55%);*/
z-index: 399;
overflow: hidden;
cursor: pointer;
}
.note-menu {
height: 43px;
}
@media only screen and (max-width: 740px) {
.slide-shadow {
background-color: rgba(0,0,0,0.5);
}
.slide-content {
height: calc(100% - 55px);
}
.slide-container {
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.note-menu {
height: 55px;
padding: 0 30px;
}
}
.modal-fade-enter,
.modal-fade-leave-active {
opacity: 0;
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity .5s ease;
}
</style>
<template>
<transition name="modal-fade">
<div>
<div class="slide-container" :style="{ 'background-color':bgColor, 'color':textColor}">
<!-- content of the editor -->
<div class="slide-content">
<slot></slot>
</div>
<!-- close menu on bottom -->
<div class="note-menu">
<nm-button more-class="right" icon="close" text="close" :show-text="true" v-on:click.native="close" />
</div>
</div>
<div class="slide-shadow" v-on:click="close"></div>
</div>
</transition>
</template>
<script>
export default {
name: 'SideSlideMenu',
props: [ 'name', 'styleObject' ],
components: {
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
},
data () {
return {
items: [],
bgColor: null,
textColor: null,
}
},
beforeMount(){
//Other panels will tell this one to close
this.$bus.$on('destroy_all_other_side_panels', (name) => {
if(this.name != name){
this.close()
}
})
},
beforeDestroy(){
},
mounted(){
//If note style object is set, use that on the slide menu
if(this.styleObject && this.styleObject.noteText){
this.textColor = this.styleObject.noteText
}
if(this.styleObject && this.styleObject.noteBackground){
this.bgColor = this.styleObject.noteBackground
}
//Close all other panels that are not this one
this.$nextTick( () => {
this.$bus.$emit('destroy_all_other_side_panels', this.name)
})
},
methods: {
onClickTag(index){
console.log('yup')
},
close() {
this.$emit('close');
},
}
}
</script>

View File

@@ -0,0 +1,119 @@
<style type="text/css" scoped>
.img-container {
display: flex;
flex-wrap: wrap;
}
.img-row {
height: 30vh;
flex-grow: 1;
}
.img-row:last-child {
/* There's no science in using "10" here. In all my testing, this delivered the best results. */
flex-grow: 10;
}
.img-row > img {
max-height: calc(100% - 10px);
min-width: calc(100% - 10px);
max-width: calc(100% - 10px);
object-fit: cover;
vertical-align: bottom;
/*padding: 5px;*/
box-shadow: 0px 2px 2px 1px rgba(34,36,38,0.3);
cursor: pointer;
}
</style>
<template>
<div>
<div v-if="uploadedToNote.length > 0">
<h2>Images Uploaded to Note</h2>
<div class="ui fluid green button" v-on:click="$router.push('/attachments/note/'+noteId)">
Manage Files on this Note
<i class="chevron circle right icon"></i>
</div>
<p></p>
<div class="img-container">
<div v-for="file in uploadedToNote" class="img-row" v-on:click="onFileClick(file)">
<img :src="`/api/static/thumb_${file.file_location}`">
</div>
<!-- extra row helps it display properly -->
<div class="img-row"></div>
</div>
</div>
<!-- Add images to note -->
<div v-if="files.length > 0">
<h2>All other Images</h2>
<div class="ui fluid green button" v-on:click="$router.push('/attachments')">
Manage All Files
<i class="chevron circle right icon"></i>
</div>
<p></p>
<div class="img-container">
<div v-for="file in files" class="img-row" v-on:click="onFileClick(file)">
<img :src="`/api/static/thumb_${file.file_location}`">
</div>
<!-- extra row helps it display properly -->
<div class="img-row"></div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'SimpleAttachmentNoteComponent',
props: [ 'noteId', 'squireEditor' ],
data () {
return {
files: [],
uploadedToNote: [],
}
},
beforeMount(){
},
mounted(){
axios.post('/api/attachment/search', {'attachmentType':2})
.then( ({data}) => {
//Sort files into two categories
data.forEach(file => {
if(file['note_id'] == this.noteId){
this.uploadedToNote.push(file)
} else {
this.files.push(file)
}
})
})
},
methods: {
onFileClick(file){
const imageCode = `<img alt="image" src="/api/static/thumb_${file.file_location}">`
this.$bus.$emit('new_file_upload', {noteId: this.noteId, imageCode})
if(this.$store.getters.getIsUserOnMobile){
this.close()
}
},
close() {
this.$emit('close');
},
}
}
</script>

View File

@@ -1,5 +1,6 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import Vuex from 'vuex'
@@ -9,10 +10,43 @@ import store from './stores/mainStore';
import App from './App'
import router from './router'
require('./assets/semantic-min.css')
require('./assets/semantic-helper.css')
// Fonts
require('./assets/roboto-latin.woff2')
require('./assets/roboto-latin-bold.woff2')
require('./assets/themes/default/assets/fonts/icons.eot')
require('./assets/themes/default/assets/fonts/icons.otf')
require('./assets/themes/default/assets/fonts/icons.svg')
require('./assets/themes/default/assets/fonts/icons.ttf')
require('./assets/themes/default/assets/fonts/icons.woff')
require('./assets/themes/default/assets/fonts/icons.woff2')
require('./assets/themes/default/assets/fonts/outline-icons.eot')
require('./assets/themes/default/assets/fonts/outline-icons.svg')
require('./assets/themes/default/assets/fonts/outline-icons.ttf')
require('./assets/themes/default/assets/fonts/outline-icons.woff')
require('./assets/themes/default/assets/fonts/outline-icons.woff2')
require('./assets/squire.js')
//Import socket io, init using nginx configured socket path
import io from 'socket.io-client';
const socket = io({ path:'/socket' });
//integrate connected socket into vue instance
Object.defineProperties(Vue.prototype, {
$io: {
get: () => socket
}
})
// This callback runs before every route change, including on page load.
// Sets the title of the page using vue router
router.beforeEach((to, from, next) => {
document.title = to.meta.title;
next();
});
@@ -21,16 +55,10 @@ router.beforeEach((to, from, next) => {
import EventBus from './EventBus'
import Helpers from './Helpers'
import CKEditor from '@ckeditor/ckeditor5-vue';
Vue.use( CKEditor )
require('./assets/semantic-min.css')
require('./assets/semantic-helper.css')
Vue.use(Vuex)
Vue.config.productionTip = false
/* eslint-disable no-new */
new Vue({
el: '#app',
router,

View File

@@ -0,0 +1,108 @@
<template>
<div class="ui basic segment no-fluf-segment">
<div class="ui grid">
<div class="ui sixteen wide column">
<h2 class="ui header">
<i class="folder open outline icon"></i>
<div class="content">
Files
<div class="sub header">Uploaded Files and Websites from notes.</div>
</div>
</h2>
</div>
<div class="sixteen wide column" v-if="searchParams.noteId">
<div class="ui green button" v-on:click="clearNote">
<i class="chevron circle left icon"></i>
Show All Attachments
</div>
<div class="ui green button" v-on:click="openNote">
<i class="file outline icon"></i>
Open Note
</div>
</div>
<div class="sixteen wide column" v-if="searchParams['noteId'] && attachments.length == 0">
<h3>There are no attachments for this note.</h3>
<h3>Attachments are links or files added to the note.</h3>
</div>
<div class="sixteen wide column">
<attachment-display
v-for="item in attachments"
:item="item"
:key="item.id"
:search-params="searchParams"
/>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
components: {
'attachment-display': require('@/components/AttachmentDisplayCard').default,
},
data: function(){
return {
attachments: [],
searchParams: {}
}
},
beforeCreate: function(){
//
// Perform Login check
//
this.$parent.loginGateway()
},
mounted: function(){
//Mount notes on load if note ID is set
this.openNoteAttachments()
this.searchAttachments()
},
watch:{
$route (to, from){
//Open or close notes on route change
this.openNoteAttachments()
this.searchAttachments()
}
},
methods: {
openNoteAttachments(){
if(this.$route.params && this.$route.params.id){
const inputNoteId = this.$route.params.id
this.searchParams['noteId'] = inputNoteId
}
},
openNote(){
const noteId = this.searchParams['noteId']
this.$router.push('/notes/open/'+noteId)
},
clearNote(){
this.$router.push('/attachments/')
delete this.searchParams.noteId
},
searchAttachments (){
axios.post('/api/attachment/search', this.searchParams)
.then( results => {
this.attachments = results.data
})
},
}
}
</script>
<style type="text/css" scoped>
.attachment-display-area {
width: 100%;
margin-top: 15px;
box-sizing: border-box;
padding: 0 5%;
}
</style>

View File

@@ -1,13 +1,325 @@
<style type="text/css" scoped>
.hero {
background-size: 50%;
background-color: #0a2f13;
background: linear-gradient(270deg, #21ba45, #3710a4);
background-size: 400% 400%;
overflow: hidden;
-webkit-animation: fadeorama 16s ease infinite;
-moz-animation: fadeorama 16s ease infinite;
animation: fadeorama 16s ease infinite;
}
.lightly-padded {
margin-top: 10px;
}
.massive-text {
color: white;
font-size: 4rem;
}
.blinking {
animation:blinkingText 1.5s linear infinite;
}
@keyframes blinkingText{
0%{ opacity: 0.9; }
50%{ opacity: 0; }
100%{ opacity: 0.9; }
}
.subtext {
border-bottom: 1px solid white;
border-right: 1px solid white;
color: white;
font-size: 1.5rem;
padding: 0 0 0 10px;
}
.stand-out {
color: white;
text-shadow:
2px 2px 1px black,
-2px -2px 1px black,
-2px 2px 1px black,
2px -2px 1px black;
}
h2, h3 {
font-weight: normal;
}
@-webkit-keyframes fadeorama {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@-moz-keyframes fadeorama {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
@keyframes fadeorama {
0%{background-position:0% 50%}
50%{background-position:100% 50%}
100%{background-position:0% 50%}
}
/*safari fix - prevents page from being below the menu */
.dont-pad-me {
margin-right: 0 !important;
margin-left: 0 !important;
}
</style>
<template>
<div class="ui basic segment">
<div class="ui container">
<h1>Welcome</h1>
<div class="lightly-padded">
<div class="ui centered vertically divided stackable grid">
<div class="row hero fadeBg" :style="{ 'height':(height+'px') }">
<!-- All marketing images if you need to review -->
<div v-if="false" class="sixteen wide column">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/add.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/gardening.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/growth.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/icecream.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/investing.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/onboarding.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/robot.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/solution.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/watching.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/cloud.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/grandma.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/hamburger.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/idea.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/notebook.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/plan.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/secure.svg" alt="">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/void.svg" alt="">
</div>
<div class="one wide large screen only column"></div>
<!-- desktop column - large screen only -->
<div class="seven wide middle aligned left aligned column">
<h2 class="massive-text">Take Notes, <br>Like Never Before</h2>
<h3 class="subtext">
Using an online note application <i class="i cursor icon blinking"></i>
</h3>
<p>Assuming you have never used a note application previously in your life.</p>
<br>
<i class="huge inverted chevron circle down icon"></i>
</div>
<div class="eight wide middle aligned left aligned column">
<img loading="lazy" width="90%" src="/api/static/assets/marketing/notebook.svg" alt="The Venus fly laptop about to capture another victim">
</div>
</div>
<!-- set -->
<div class="middle aligned centered row">
<div class="six wide column">
<h2>Everyone has knowledge that need to be expressed</h2>
<h3>Utilize action potential to create notes by encoding raw brainwaves converted to written language</h3>
</div>
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/idea.svg" alt="Explosion of New Ideas">
</div>
</div>
<div class="middle aligned centered row">
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/gardening.svg" alt="Pruning the mind garden">
</div>
<div class="six wide column">
<h2>Dream it, then do it</h2>
<h3>Easily record your unlimited imagination. Ideas, stories, notes, plays, poems anything, that can reasonably be put into text</h3>
</div>
</div>
<!-- set -->
<div class="middle aligned centered green row">
<div class="six wide column">
<h2>Unbridled Input</h2>
<h3>Revolutionary technology allows the use of any keyboard with up to 395 keys</h3>
</div>
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/add.svg" alt="A shpere of newness">
</div>
</div>
<div class="middle aligned centered row">
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/solution.svg" alt="Hypercube of Solutions">
</div>
<div class="six wide column">
<h2>Solutions with the Internet</h2>
<h3>With the power to save any combination of letters, you can easily inscribe thoughts</h3>
</div>
</div>
<!-- set -->
<div class="middle aligned centered row">
<div class="six wide column">
<h2>Search your data</h2>
<h3>Type in a word and find that same word but somewhere else</h3>
</div>
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/cloud.svg" alt="Girl falling into the spiral of digital chaos">
</div>
</div>
<div class="middle aligned centered row">
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/plan.svg" alt="Scheme for planetary destruction">
</div>
<div class="six wide column">
<h2>Embrace the Void</h2>
<h3>Remove unnecessary clutter for your brain and save it to the cloud, allowing you to easily embrace the gaping abyss</h3>
</div>
</div>
<!-- set -->
<div class="middle aligned centered row">
<div class="six wide column">
<h2>Space for Growth</h2>
<h3>Groom a clear path for new expressions and innovations. Elevate your being and lower your cholesterol</h3>
</div>
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/growth.svg" alt="Endless progress at the cost of sanity and health">
</div>
</div>
<div class="middle aligned centered row">
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/onboarding.svg" alt="Shrunken man near giant tablet">
</div>
<div class="six wide column">
<h2>Become your Data</h2>
<h3>We exist as electrical impulses, no different from data on a computer</h3>
</div>
</div>
<!-- set -->
<div class="middle aligned centered row">
<div class="six wide column">
<h2>Ice Cream</h2>
<h3>Get excited without all the screaming</h3>
</div>
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/icecream.svg" alt="Emergence of a 4th dimensional being perceived as a large ice cream ">
</div>
</div>
<div class="middle aligned centered row">
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/secure.svg" alt="marketing mumbo jumbo">
</div>
<div class="six wide column">
<h2>Data Backups</h2>
<h3>Nothing you do will be forgotten.<br>You can never take back what you have done</h3>
</div>
</div>
<div class="middle aligned centered row">
<div class="six wide column">
<h2>Freedom to unleash yourself</h2>
<h3>Imagine an awakening of what could be</h3>
</div>
<div class="six wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/grandma.svg" alt="Drinking the blood of the elderly">
</div>
</div>
<!-- final slide -->
<div class="middle aligned centered green row">
<div class="twelve wide center aligned column">
<br>
<br>
<br>
<br>
<h2>What are you waiting for?<br>Sign up now.</h2>
<br>
<router-link class="ui huge white labeled icon button" to="/login">
<i class="plug icon"></i>Sign Me Up!
</router-link>
<br>
<br>
<br>
OR
<br>
<br>
<br>
<span class="ui button" v-on:click="showRealInformation">View real information about this site</span>
</div>
</div>
<div v-if="realInformation" class="middle aligned centered row" ref="real">
<div class="six wide column">
<h2 class="ui center aligned">
What is this really?
</h2>
<h3>Its just a little web app for taking notes. This page is mocking the "over the top" marketing sites use to sell their products.</h3>
<p>
This App exists because I was tired of all my data being owned by big companies, having it farmed out for marketing, and leaving the contents of my life exposed to corporations.
</p>
<p>
If you want to give it a shot, feel free to make an account. There are no ads. None of this data is shared or public. I don't make any money.
</p>
<p>
If you see anything broken or want to see a feature implemented, I'm open to suggestions. <i class="thumbs up icon"></i>
</p>
<p>Hero Slide Photo Credit - <a target="_blank" href="https://unsplash.com/@tkaslik14">https://unsplash.com/@tkaslik14</a></p>
<p>Generic Marketing Images - <a target="_blank" href="https://undraw.co/">https://unDraw.co/</a></p>
</div>
<div class="four wide column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/watching.svg" alt="Drinking the blood of the elderly">
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'WelcomePage'
name: 'WelcomePage',
data(){
return {
height: null,
realInformation: false,
}
},
beforeMount(){
//Force HTTPS on prod, always. Dev doesn't have certs
const isDev = process.env['NODE_ENV'] == 'development'
if (!isDev && location.protocol != 'https:'){
window.location.replace('https://www.avidhabit.com')
}
//Don't change hero banner on mobile
if(!this.$store.getters.getIsUserOnMobile){
let windowHeight = window.innerHeight
this.height = windowHeight - (windowHeight * 0.10)
}
},
methods: {
showRealInformation(){
this.realInformation = !this.realInformation
if(this.realInformation){
this.$nextTick(() => {
this.$refs.real.scrollIntoView({'behavior':'smooth'})
})
}
}
}
}
</script>

View File

@@ -1,41 +1,41 @@
<template>
<div class="ui container">
<h3>Login</h3>
<p>Begin the login process by typing your username or email.</p>
<p>To create an account, type in the username you want to use followed by the password.</p>
<p>You will remain logged in on this browser, until you log out.</p>
<div class="ui segment" v-on:keyup.enter="submit">
<div class="ui large form">
<div class="field">
<div class="ui input">
<input v-model="username" type="text" name="email" placeholder="Username or E-mail address" autofocus>
</div>
</div>
<div class="field" v-if="username.length > 0">
<div class="ui input">
<input v-model="password" type="password" name="password" placeholder="Password">
</div>
</div>
<div :class="{ 'disabled':(username.length == 0 || password.length == 0)}" v-on:click="submit" class="ui massive compact fluid green submit button">Login</div>
</div>
</div>
<div class="ui basic segment no-fluf-segment">
<div class="ui grid">
<div class="ui sixteen wide column">
<p><b>Create an account:</b> type in the username you want to use followed by the password.</p>
<div class="ui segment" v-on:keyup.enter="submit">
<div class="ui large form">
<div class="field">
<div class="ui input">
<input v-model="username" type="text" name="email" placeholder="Username or E-mail address" autofocus>
</div>
</div>
<div class="field">
<div class="ui input">
<input v-model="password" type="password" name="password" placeholder="Password">
</div>
</div>
<div :class="{ 'disabled':(username.length == 0 || password.length == 0)}" v-on:click="submit" class="ui massive compact fluid green submit button">Login</div>
</div>
</div>
<p>You will remain logged in on this browser, until you log out.</p>
</div>
</div>
</div>
</template>
<script>
//ajax calls
import axios from 'axios';
import { mapGetters } from 'vuex'
export default {
name: 'Login',
data () {
return {
message: 'Login stuff',
enabled: false,
username: '',
password: ''
@@ -61,26 +61,24 @@
axios.post('/api/user/login', data)
.then(response => {
console.log(response)
if(response.data.success){
const token = response.data.token
const username = response.data.username
vm.$store.commit('setLoginToken', {token, username})
//Redirect user to notes section after login
this.$router.push('/notes')
vm.$router.push('/notes')
} else {
this.$bus.$emit('notification', 'Incorrect Username or Password')
vm.$store.commit('destroyLoginToken')
}
})
.catch(error => {
console.log('There was an error with log in request')
this.$bus.$emit('notification', 'Incorrect Username or Password')
})
}
},
computed: {
...mapGetters([
'getRudeMessage'
])
}
}
</script>

View File

@@ -1,120 +1,157 @@
<template>
<div class="ui basic segment">
<div class="ui basic segment no-fluf-segment">
<div class="ui equal width grid">
<div class="ui grid" :class="{ 'mush-it-up':showOneColumn() }" ref="content">
<!-- <div class="ui row">{{ $store.getters.getIsUserOnMobile ? 'Mobile Device':'Normal Browser' }}</div> -->
<!-- mobile search menu -->
<div class="ui mobile only row">
<!-- Small screen new note button -->
<div class="ui four wide column">
<div @click="createNote" class="ui fluid green icon button">
<i class="plus icon"></i>
</div>
</div>
<div class="ui twelve wide column">
<div class="ui form">
<input v-model="searchTerm" @keyup="searchKeyUp" @:keyup.enter="search" placeholder="Search Notes" />
</div>
</div>
</div>
<!-- search menu -->
<div class="ui large screen only row">
<div class="ui two wide column">
<div @click="createNote" class="ui fluid green button">
<i class="plus icon"></i>
New Note
</div>
</div>
<div class="sixteen wide column">
<!-- :class="{ 'sixteen wide column':showOneColumn(), 'sixteen wide column':!showOneColumn() }" -->
<div class="ui five wide column">
<div class="ui form">
<input v-model="searchTerm" @keyup="searchKeyUp" @:keyup.enter="search" placeholder="Search Notes" />
</div>
</div>
<div class="ui grid">
<div class="ui nine wide column">
<div class="six wide column" v-if="!$store.getters.getIsUserOnMobile">
<search-input></search-input>
</div>
<router-link class="ui basic button" to="/help">Help</router-link>
<div class="ten wide column" :class="{ 'sixteen wide column':$store.getters.getIsUserOnMobile }">
<div v-on:click="toggleNightMode" class="ui basic icon button">
<i class="eye icon"></i>&nbsp;Dark Theme:
<span v-if="$store.getters.getIsNightMode">On</span>
<span v-else>Off</span>
<div class="ui basic button"
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" 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>
</div>
<div v-on:click="toggleArchivedVisible" class="ui basic icon button">
<i class="archive icon"></i>&nbsp;Archived:
<span v-if="showArchived == 1">Visible</span>
<span v-else>Hidden</span>
<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>Back to All Notes
</span>
</div>
<div class="ui right floated basic button"
data-tooltip="Log Out" data-position="left center"
v-on:click="destroyLoginToken"><i class="user icon"></i> {{username}}</div>
</div>
</div>
<div class="ui row">
<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>
<!-- tags display -->
<div class="ui two wide large screen only column" v-if="activeNoteId1 == null && activeNoteId2 == null">
<div class="ui small basic fluid button" @click="reset">
<i class="undo icon"></i>Reset Filters
<div v-if="commonTags.length > 0" class="sixteen wide column">
<h4><i class="green tags icon"></i>Tags</h4>
<span v-for="tag in commonTags" @click="toggleTagFilter(tag.id)">
<span class="ui clickable basic label" :class="{ 'green':(searchTags.includes(tag.id)) }">
{{ucWords(tag.text)}} <span class="detail">{{tag.usages}}</span>
</span>
</span>
</div>
<!-- Note title card display -->
<div class="sixteen wide column">
<h3 v-if="searchTerm.length > 0 && notes.length == 0">No notes found. Check your spelling, try completing the word or using a different phrase.</h3>
<h3 v-if="$store.getters.totals && $store.getters.totals['totalNotes'] == 0">
No Notes Yet. Create one when you feel ready.
</h3>
<!-- <div v-if="working">
<div class="ui active inline loader"></div> Working...
</div> -->
<!-- Go to one wide column, do not do this on mobile interface -->
<div v-if="notes !== null && notes.length > 0"
:class="{'one-column':(
(activeNoteId1 != null || activeNoteId2 != null) &&
!$store.getters.getIsUserOnMobile
)}">
<!-- pinned notes -->
<div v-if="containsPinnednotes > 0" class="note-card-section">
<!-- ({{containsPinnednotes}}) -->
<h4><i class="green pin icon"></i>Pinned</h4>
<div class="note-card-display-area">
<note-title-display-card
v-for="note in notes"
v-if="note.pinned"
:onClick="openNote"
:data="note"
: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"
/>
</div>
</div>
<div class="ui divider"></div>
<div class="ui section list">
<div class="item" v-for="tag in commonTags" @click="toggleTagFilter(tag.id)">
<div class="ui clickable basic fluid large label" :class="{ 'green':(searchTags.includes(tag.id)) }">
{{ucWords(tag.text)}} <div class="detail">{{tag.usages}}</div>
</div>
<!-- normal notes -->
<div v-if="containsNormalNotes > 0" class="note-card-section">
<!-- ({{containsNormalNotes}}) -->
<h4><i class="green file icon"></i>Notes</h4>
<div class="note-card-display-area">
<note-title-display-card
v-for="note in notes"
v-if="note.note_highlights && !note.pinned"
:onClick="openNote"
:data="note"
: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"
/>
</div>
</div>
<!-- found in text -->
<div v-if="containsTextResults" class="note-card-section">
<h4><i class="green paragraph icon"></i> Found in Text ({{containsTextResults}})</h4>
<div class="note-card-display-area">
<note-title-display-card
v-for="note in notes"
v-if="note.note_highlights && note.note_highlights.length"
:textResults="true"
:onClick="openNote"
:data="note"
: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"
/>
</div>
</div>
</div>
<!-- Note title cards -->
<div class="ui fourteen wide computer sixteen wide mobile column">
<h2>
({{notes.length}}) <fast-filters />
</h2>
<h3 v-if="searchTerm.length > 0 && notes.length == 0">No notes found. Check your spelling, try completing the word or using a different phrase.</h3>
<h3 v-if="searchTerm.length == 0 && notes.length == 0">Create your first note. Click the "New Note" button.</h3>
<div v-if="working"><div class="ui active inline loader"></div> Working...</div>
<div v-if="notes !== null && !working"
class="note-card-display-area"
:class="{'one-column':(activeNoteId1 != null || activeNoteId2 != null )}
">
<note-title-display-card
v-for="note in notes"
:onClick="openNote"
:data="note"
: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"
/>
</div>
</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" />
<input-notes v-if="activeNoteId2 != null" :noteid="activeNoteId2" :position="activeNote2Position" />
<input-notes v-if="activeNoteId1 != null" :noteid="activeNoteId1" :position="activeNote1Position" ref="note1" />
<input-notes v-if="activeNoteId2 != null" :noteid="activeNoteId2" :position="activeNote2Position" ref="note2" />
</div>
</template>
<script>
import axios from 'axios';
import axios from 'axios'
export default {
name: 'SearchBar',
@@ -122,60 +159,162 @@
'input-notes': require('@/components/NoteInputPanel.vue').default,
'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
},
data () {
return {
username:'',
initComponent: true,
commonTags: [],
searchTerm: '',
searchTags: [],
notes: [],
highlights: [],
searchDebounce: null,
fastFilters: {},
showArchived: 0,
working: false,
//Load up notes in batches
firstLoadBatchSize: 30, //First set of rapidly loaded notes
batchSize: 100, //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,
fetchTags: false,
//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: [],
noteSections: {
'pinned': {},
'archived': {},
'recieved': {},
'sent':{},
'notes':{},
'textMatch':{}
}
}
},
beforeMount(){
let username = this.$store.getters.getUsername
this.username = this.ucWords(username)
this.$parent.loginGateway()
this.$bus.$on('close_active_note', position => {
//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(noteId)
}
})
this.$bus.$on('note_deleted', () => {
this.search()
this.$bus.$on('note_deleted', (noteId) => {
//Remove deleted note from set, its deleted
this.notes.forEach( (note, index) => {
if(note.id == noteId){
if(note.pinned == 1){
this.containsPinnednotes--
} else {
this.containsNormalNotes--
}
this.notes.splice(index, 1)
}
})
})
this.$bus.$on('update_fast_filters', newFilter => {
this.fastFilters = newFilter
this.search()
//Fast filters always return all the results and tags
this.search(true, this.batchSize, false).then( () => {
return this.fetchUserTags()
})
})
//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()
return this.fetchUserTags()
})
})
//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
console.log('About to load note ', 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)
//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() {
this.search()
//Loads initial batch and tags
this.reset()
},
methods: {
openNote(id){
showOneColumn(){
//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 }
}
//Do not open same note twice
if(this.activeNoteId1 == id || this.activeNoteId2 == id){
@@ -196,7 +335,6 @@
this.activeNote2Position = 2 //Left side of page
return
}
//2 notes open
if(this.activeNoteId2 != null && this.activeNoteId1 == null){
this.activeNoteId1 = id
@@ -219,12 +357,17 @@
this.activeNoteId2 = null
}
this.$router.push('/notes')
//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
this.search(false)
},
toggleTagFilter(tagId){
@@ -234,55 +377,261 @@
this.searchTags.push(tagId)
}
this.search()
},
search(showLoading = true){
//Add archived to fast filters
this.fastFilters['archived'] = 0
if(this.showArchived == 1){
this.fastFilters['archived'] = 1
//Reset note set and load up notes and tags
if(this.searchTags.length > 0){
this.search(true, this.batchSize)
return
}
let postData = {
//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 )
// console.log( percentageDown + '%' )
//If greater than 80 of the way down the page, load the next batch
if(percentageDown >= 80){
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,'')
//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()
if(this.$refs.note1 && this.$refs.note1.currentNoteId == noteIdToClose){
this.$refs.note1.close()
}
if(this.$refs.note2 && this.$refs.note2.currentNoteId == noteIdToClose){
this.$refs.note2.close()
}
}
},
visibiltyChangeAction(event){
//@TODO - set a timeout on this like 2 minutes or just dont do shit and update it via socket.io
//If user leaves page then returns to page, reload the first batch
if(this.lastVisibilityState == 'hidden' && document.visibilityState == 'visible'){
// console.log('Welcome back. Reloading a batch')
//Load initial batch, then tags, then other batch
this.search(false, this.firstLoadBatchSize)
.then( () => {
return this.fetchUserTags()
})
}
this.lastVisibilityState = document.visibilityState
},
// @TODO Don't even trigger this if the note wasn't changed
updateSingleNote(noteId){
//Lookup one note using passed in ID
const postData = {
searchQuery: this.searchTerm,
searchTags: this.searchTags,
fastFilters: this.fastFilters,
fastFilters:{
noteIdSet:[noteId]
}
}
if(showLoading){
this.working = true
}
axios.post('/api/note/search', postData)
.then(results => {
//Perform search
let vm = this
axios.post('/api/note/search', postData).
then(response => {
vm.commonTags = response.data.tags
vm.notes = response.data.notes
vm.highlights = response.data.highlights
this.working = false
//Pull note data out of note set
let newNote = results.data.notes[0]
let foundNote = false
if(newNote === undefined){
console.log('Note not visible on this page')
return
}
//Go through each note and find the one just updated
this.notes.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){
console.log('Tags changed, update those bitches')
this.fetchUserTags()
}
//go through each prop and update it with new values
Object.keys(newNote).forEach(prop => {
note[prop] = newNote[prop]
})
this.notes.splice(index, 1)
this.notes.unshift(note)
}
})
//This note was not found, update it in list
if(foundNote == false){
if(newNote.pinned == 1){
this.containsPinnednotes++
} else {
this.containsNormalNotes++
}
this.notes.unshift(newNote)
}
})
},
searchAttachments(){
axios.post('/api/attachment/textsearch', {'searchTerm':this.searchTerm})
.then(results => {
console.log('Attachment Results')
console.log(results.data)
this.foundAttachments = results.data
})
},
search(showLoading = true, notesInNextLoad = null, mergeExisting = false){
return new Promise((resolve, reject) => {
//Don't double load note batches
if(this.loadingInProgress){
return resolve()
}
//Reset a lot of stuff if we are not merging batches
if(!mergeExisting){
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
}
//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,
}
if(showLoading){
this.working = true
}
//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
axios.post('/api/note/search', postData).
then(response => {
//Save the number of notes just loaded
this.batchOffset += response.data.notes.length
//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
}
//Either reload all notes with return data or merge return data
if(!mergeExisting){
this.notes = response.data.notes
} else {
this.notes = this.notes.concat(response.data.notes)
}
//Go through each note and see which section to display
let textResultsCount = 0
let pinnedResultsCount = 0
let normalNotesCount = 0
response.data.notes.forEach(note => {
if(note.note_highlights.length > 0){
textResultsCount++
return
}
if(note.pinned == 1){
pinnedResultsCount++
return
}
normalNotesCount++
})
if(!mergeExisting){
this.containsNormalNotes = normalNotesCount
this.containsPinnednotes = pinnedResultsCount
this.containsTextResults = textResultsCount
} else {
this.containsNormalNotes += normalNotesCount
this.containsPinnednotes += pinnedResultsCount
this.containsTextResults += textResultsCount
}
this.working = false
this.loadingInProgress = false
return resolve(true)
})
})
},
searchKeyUp(){
let vm = this
clearTimeout(vm.searchDebounce)
vm.searchDebounce = setTimeout(() => {
vm.search()
}, 300)
},
createNote(event){
const title = ''
let vm = this
axios.post('/api/note/create', {title})
.then(response => {
if(response.data && response.data.id){
vm.openNote(response.data.id)
}
})
this.search(true, this.batchSize)
.then( () => {
return this.fetchUserTags()
})
}, 500)
},
ucWords(str){
return (str + '')
@@ -291,31 +640,74 @@
})
},
reset(){
this.showClear = false
this.searchTerm = ''
this.searchTags = []
this.fastFilters = {}
this.foundAttachments = [] //Remove all attachments
this.$bus.$emit('reset_fast_filters')
this.search()
//Load initial batch, then tags, then other batch
this.search(true, this.firstLoadBatchSize)
.then( () => {
return this.fetchUserTags()
})
.then( () => {
//Load a larger batch once first batch has loaded
return this.search(false, this.batchSize, true)
})
.then( i => {
//Thats how you promise chain
})
},
destroyLoginToken() {
this.$store.commit('destroyLoginToken')
this.$router.push('/')
fetchUserTags(){
return new Promise((resolve, reject) => {
let postData = {
searchQuery: this.searchTerm,
searchTags: this.searchTags,
fastFilters: this.fastFilters,
}
axios.post('/api/tag/usertags', postData)
.then( ({data}) => {
this.commonTags = data
resolve(data)
})
})
},
toggleNightMode(){
this.$store.commit('toggleNightMode')
},
toggleArchivedVisible(){
if(this.showArchived == 0){
this.showArchived = 1
} else {
this.showArchived = 0
updateFastFilters(index){
//clear out tags
this.searchTags = []
//A little hacky, brings user to notes page then filters on click
if(this.$route.name != 'NotesPage'){
this.$router.push('/notes')
setTimeout( () => {
this.updateFastFilters(index)
}, 500 )
}
this.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
]
let filter = {}
filter[options[index]] = 1
this.$bus.$emit('update_fast_filters', filter)
}
}
}
</script>
<style type="text/css" scoped>
.mush-it-up {
width: calc(50% - 130px);
}
.detail {
float: right;
}
@@ -323,4 +715,14 @@
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>

View File

@@ -0,0 +1,127 @@
<template>
<div class="ui basic segment no-fluf-segment">
<div class="ui grid">
<div class="ui sixteen wide column">
<h2 class="ui header">
<i class="paper plane outline icon"></i>
<div class="content">
Quick
<div class="sub header">Add new information with great speed</div>
</div>
</h2>
</div>
<div class="ui sixteen wide column">
<div class="ui form">
<div class="field">
<textarea
class="quick-note-input"
rows="1"
ref="fastInput"
v-model="newText"
v-on:keydown="checkKeyup"
placeholder="Push to the top of the quick note."
></textarea>
</div>
<div class="field">
<div v-on:click="appendQuickNote" class="ui green button">Save (CRTL + Enter)</div>
<div v-if="quickNoteId" class="ui right floated basic button" v-on:click="$router.push('/attachments/note/'+quickNoteId)">
<i class="folder open outline icon"></i>
Quick Files + Links
</div>
<div v-if="quickNoteId" v-on:click="openNoteEdit" class="ui right floated basic button">
<i class="file outline icon"></i>
Edit
</div>
</div>
</div>
</div>
<div class="fun" v-html="savedQuickNoteText"></div>
</div>
</div>
</template>
<style type="text/css" scoped>
.quick-note-input {
box-sizing: border-box !important;
resize: none !important;
}
</style>
<script>
import axios from 'axios'
export default {
data: function(){
return {
newText: '',
savedQuickNoteText: '',
quickNoteId: null
}
},
beforeCreate: function(){
//
// Perform Login check
//
this.$parent.loginGateway()
},
mounted: function(){
if(this.$refs.fastInput){
//Load up note text
this.getQuickNote()
//Set focus to input pane
this.$nextTick(() => {
this.$refs.fastInput.focus()
})
}
},
computed: {
loggedIn () {
return this.$store.getters.getLoggedIn
}
},
methods: {
openNoteEdit(){
this.$router.push({'path':'/notes/open/'+this.quickNoteId})
},
checkKeyup(event){
let element = event.target
let padding = 0
element.style.height = 'auto';
element.style.height = (element.scrollHeight + padding) +'px';
//If command+enter or control+enter is pressed, submit
if((event.metaKey || event.ctrlKey) && [13].includes(event.keyCode)){
this.appendQuickNote()
}
},
appendQuickNote(){
//Don't submit empty note
if(this.newText.trim() == ''){ return }
axios.post('/api/quick-note/update', { 'pushText':this.newText.trim() } )
.then( results => {
this.newText = '' //Clear text area
this.$refs.fastInput.style.height = 'auto' //Back to normal size
this.savedQuickNoteText = results.data.text
this.quickNoteId = results.data.id
})
},
getQuickNote (){
axios.post('/api/quick-note/get')
.then( results => {
this.savedQuickNoteText = results.data.text
this.quickNoteId = results.data.id
})
},
}
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="ui basic segment">
<div class="ui container">
<div class="fun" :style="{'color':color}" v-if="noteText" v-html="noteText"></div>
</div>
<div class="ui basic segment"></div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'SharePage',
data(){
return {
noteText: null,
color: '#000'
}
},
beforeMount(){
//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)
}
},
methods:{
openNote(noteId){
axios.post('/api/public/note', {'noteId': noteId})
.then( response => {
let colors = JSON.parse(response.data.color)
if(colors && colors.noteBackground){
document.body.style.background = colors.noteBackground
}
if(colors && colors.noteText){
this.color = colors.noteText
}
this.noteText = response.data.text
})
}
}
}
</script>

View File

@@ -1,10 +1,23 @@
import Vue from 'vue'
import Router from 'vue-router'
import HomePage from '@/pages/HomePage'
import LoginPage from '@/pages/LoginPage'
//Breaking components into function sections allows webpack to load them dynamically
//import HomePage from '@/pages/HomePage'
const HomePage = () => import('@/pages/HomePage')
// import LoginPage from '@/pages/LoginPage'
const LoginPage = () => import('@/pages/LoginPage')
// import HelpPage from '@/pages/HelpPage'
const HelpPage = () => import('@/pages/HelpPage')
// import SharePage from '@/pages/SharePage'
const SharePage = () => import('@/pages/SharePage')
//These guys can all be loaded as a chunk
import NotesPage from '@/pages/NotesPage'
import HelpPage from '@/pages/HelpPage'
import QuickPage from '@/pages/QuickPage'
import AttachmentsPage from '@/pages/AttachmentsPage'
Vue.use(Router)
@@ -40,5 +53,29 @@ export default new Router({
meta: {title:'Help'},
component: HelpPage
},
{
path: '/share/:id',
name: 'Share',
meta: {title:'Shared'},
component: SharePage
},
{
path: '/quick',
name: 'Quick',
meta: {title:'Quick'},
component: QuickPage
},
{
path: '/attachments',
name: 'Attachments',
meta: {title:'Attachments'},
component: AttachmentsPage
},
{
path: '/attachments/note/:id',
name: 'Attachments',
meta: {title:'Attachments'},
component: AttachmentsPage
},
]
})

View File

@@ -6,17 +6,15 @@ Vue.use(Vuex);
export default new Vuex.Store({
state: {
count: 0,
message: 'Get out me yard ya wankers',
token: null,
username: null,
nightMode: false,
isUserOnMobile: false,
isNoteSettingsOpen: false, //Little note settings pane
socket: null,
userTotals: null,
},
mutations: {
increment (state) {
state.count++
},
setLoginToken(state, userData){
const username = userData.username
@@ -29,7 +27,7 @@ export default new Vuex.Store({
localStorage.setItem('username', username)
//Set default token to axios, every request will have header
axios.defaults.headers.common['Authorization'] = token
axios.defaults.headers.common['authorizationtoken'] = token
state.token = token
state.username = username
@@ -39,7 +37,7 @@ export default new Vuex.Store({
//Remove login token from local storage and from headers
localStorage.removeItem('loginToken')
localStorage.removeItem('username')
delete axios.defaults.headers.common['Authorization']
delete axios.defaults.headers.common['authorizationtoken']
state.token = null
state.username = null
},
@@ -54,6 +52,7 @@ export default new Vuex.Store({
'background_color': '#fff',
'text_color': '#3d3d3d',
'outline_color': 'rgba(34,36,38,.15)',
'border_color': 'rgba(34,36,38,.20)',
}
//Night mode colors
if(state.nightMode){
@@ -61,6 +60,7 @@ export default new Vuex.Store({
'background_color': '#000',
'text_color': '#a98457',
'outline_color': '#a98457',
'border_color': '#a98457',
}
}
@@ -80,13 +80,27 @@ export default new Vuex.Store({
state.isUserOnMobile = true
}
})(navigator.userAgent||navigator.vendor||window.opera, state);
},
toggleNoteSettingsPane(state){
state.isNoteSettingsOpen = !state.isNoteSettingsOpen
},
setSocketIoSocket(state, socket){
//Put socket id in axios headers
axios.defaults.headers.common['socketId'] = socket
state.socket = socket
},
setUserTotals(state, totalsObject){
//Save all the totals for the user
state.userTotals = totalsObject
// Object.keys(totalsObject).forEach( key => {
// console.log(key + ' -- ' + totalsObject[key])
// })
}
},
getters: {
getRudeMessage: state => {
return state.message
},
getUsername: state => {
return state.username
},
@@ -102,6 +116,23 @@ export default new Vuex.Store({
},
getIsUserOnMobile: state => {
return state.isUserOnMobile
},
getIsNoteSettingsOpen: state => {
return state.isNoteSettingsOpen
},
getSocket: state => {
return state.socket
},
totals: state => {
return state.userTotals
},
},
actions: {
fetchAndUpdateUserTotals ({ commit }) {
axios.post('/api/user/totals')
.then( ({data}) => {
commit('setUserTotals', data)
})
}
}
})

View File

@@ -1,32 +1,31 @@
##
#
# This is just a mock config file, describing what is needed to run the app
# The app currently only needs two paths / and /api
#
##
#
# This is needed to define any ports the app may use from node
#
upstream expressapp {
server 127.0.0.1:3000;
keepalive 8;
}
server {
listen 80;
server_name logiclabs.icu;
root /home/mab/pi/client/dist;
access_log /var/log/nginx/httpslocalhost.access.log;
error_log /var/log/nginx/httpslocalhost.error.log;
#
# Needed to server up static, compiled JS files and index.html
#
location / {
autoindex on;
#try_files $uri $uri/ /index.html;
}
location /app {
proxy_pass http://127.0.0.1:8444;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /api {
#
# define the api route to connect to the backend and serve up static files
#
location /api {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
@@ -34,15 +33,6 @@ server {
proxy_pass http://expressapp;
proxy_redirect off;
}
location /solr {
proxy_pass http://127.0.0.1:8983;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
}

View File

@@ -9,4 +9,6 @@ bundle.js
common.js
*/unminified/bundle.*
bundle.*
client/dist*
server/public/*
client/dist*

View File

@@ -12,10 +12,14 @@
"body-parser": "^1.18.3",
"cheerio": "^1.0.0-rc.3",
"express": "^4.16.4",
"gm": "^1.23.1",
"jsonwebtoken": "^8.5.1",
"multer": "^1.4.2",
"mysql2": "^1.6.5",
"node-tesseract-ocr": "^1.0.0",
"request": "^2.88.0",
"request-promise": "^4.2.4",
"socket.io": "^2.3.0",
"solr-node": "^1.2.1"
},
"_moduleAliases": {

View File

@@ -0,0 +1,31 @@
module.exports = {
apps : [{
name: 'NoteServer',
script: 'index.js',
// Options reference: https://pm2.io/doc/en/runtime/reference/ecosystem-file/
// args: 'one two',
instances: 1,
autorestart: true,
watch: true,
ignore_watch : ["node_modules", "staticFiles"],
max_memory_restart: '1G',
env: {
NODE_ENV: 'development'
},
env_production: {
NODE_ENV: 'production'
}
}],
deploy : {
production : {
user : 'node',
host : '212.83.163.1',
ref : 'origin/master',
repo : 'git@github.com:repo.git',
path : '/var/www/production',
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production'
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
let ProcessText = module.exports = {}
ProcessText.removeHtml = (string) => {
if(string == undefined || string == null || string.length == 0){
return ''
}
return string
.replace(/&[[#A-Za-z0-9]+A-Za-z0-9]+;/g,' ') //Rip out all HTML entities
.replace(/<[^>]+>/g, ' ') //Rip out all HTML tags
.replace(/\s+/g, ' ') //Remove all whitespace
.trim()
}
ProcessText.getUrlsFromString = (string) => {
const urlPattern = /(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[A-Z0-9+&@#/%=~_|$])/igm
return string.match(urlPattern)
}
/*
Pulls out title and subtext of note
+ Title is always first line
+ Empty lines are skipped
+ URLs are turned into links
+ All URLs are givent the target="_blank" property
+ Lists are given extra display characters
+ If note starts as a list, skip the title
*/
ProcessText.deduceNoteTitle = (inString) => {
let title = '' //Title of note
let sub = '' //sub text below note
if(!inString || inString == null || inString.length == 0){
return {title, sub}
}
//Remove inline styles that may be added by editor
inString = inString.replace(/style=".*?"/g,'')
//Match full line and closing tag or just closing tag
let lines = inString.match(/[<[a-zA-Z0-9]+>(.*?)<\/[a-zA-Z0-9]+>|<\/[a-zA-Z0-9>]+?>/g)
if(lines == null){ lines = [inString] }
//.match(/[^\r\n]+/g) //Match return or newline
// console.log(lines)
let finalLines = []
const startTags = ['<ol','<li','<ul']
const endTags = ['</o','</l','</u']
let totalLines = Math.min(lines.length, 6)
let charLimit = 250
let listStart = false
let noTitleJustList = false
for(let i=0; i < totalLines; i++){
//Just in case 'i' gets bigger than array
if(lines[i] === undefined){
continue
}
//Various empty chars are possible
const cleanLine = ProcessText.removeHtml(lines[i])
.replace('<br>','')
.trim()
const lineStart = lines[i].trim().substring(0, 3)
charLimit -= cleanLine.length
//Close out list if char limit is hit
if(charLimit <= 0 && listStart){
finalLines.push(lines[i])
break
}
//Images appear as empty, push em!
if(cleanLine.length == 0 && lines[i].indexOf('<img') != -1){
finalLines.push(lines[i])
continue
}
//Check if note starts with a list, don't include title, just show list
if(finalLines.length == 0 && startTags.includes(lineStart)){
noTitleJustList = true
}
//Empty line, may be a list open or close
if(cleanLine.length == 0 && (startTags.includes(lineStart) || endTags.includes(lineStart) )){
if(listStart == false){
charLimit = 400 //Double size for list notes
}
finalLines.push(lines[i])
totalLines++
listStart = true
continue
}
//If line is part of a list, up counter, we want the whole list
if(startTags.includes(lineStart)){
totalLines++
}
//Skip empty lines
if(!cleanLine || cleanLine.length == 0){
totalLines++
continue
}
//turn urls into links, don't process if its already an <a href=
const containsUrls = ProcessText.getUrlsFromString(cleanLine)
if(containsUrls && containsUrls.length == 1 && lines[i].indexOf('</a>') == -1){
const url = containsUrls[0]
lines[i] = lines[i].replace(url, `<a href="${url}">${url}</a>`)
}
//Insert target=_blank into links if set, do it for every link in line
if(lines[i].indexOf('</a>') > 0){
lines[i] = lines[i].replace(/<a /g, '<a target="_blank" ')
}
//Limit output characters
//Check character limit
if(charLimit <= 0 && listStart == false){
//Cut the string down to character limit
const cutString = lines[i].substring(0, lines[i].length+charLimit)
//Find last space and cut off everything after it
let cleanCutString = cutString.substring(0, cutString.lastIndexOf(' '))
//Some strings may not contain a space resulting in no string
if(cleanCutString.length == 0){
cleanCutString = cutString
}
finalLines.push(cleanCutString + '...')
break;
}
finalLines.push(lines[i])
}
//Pull out title if its not an empty string
if(ProcessText.removeHtml(finalLines[0]).trim().replace('&nbsp','').length > 0 && !noTitleJustList){
title = finalLines.shift()
}
sub = finalLines.join('')
//Return final display lengths
let titleLength = ProcessText.removeHtml(title).trim().replace('&nbsp','').length
let subtextLength = ProcessText.removeHtml(sub).trim().replace('&nbsp','').length
return { title, sub, titleLength, subtextLength }
}

View File

@@ -7,20 +7,96 @@ const express = require('express')
const app = express()
const port = 3000
var http = require('http').createServer(app);
var io = require('socket.io')(http, {
path:'/socket'
});
// Make io accessible to our router
app.use(function(req,res,next){
req.io = io;
next();
});
io.on('connection', function(socket){
// console.log('New user ', socket.id)
//When a user connects, add them to their own room
// This allows the server to emit events to that specific user
// access socket.io in the controller with req.io
socket.on('user_connect', token => {
Auth.decodeToken(token)
.then(userData => {
socket.join(userData.id)
}).catch(error => {
console.log(error)
})
})
socket.on('join_room', roomId => {
// console.log('Join room ', roomId)
socket.join(roomId)
const usersInRoom = io.sockets.adapter.rooms[roomId]
if(usersInRoom){
// console.log('Users in room', usersInRoom.length)
io.to(roomId).emit('update_user_count', usersInRoom.length)
}
})
socket.on('leave_room', roomId => {
socket.leave(roomId)
// console.log('User Left room')
const usersInRoom = io.sockets.adapter.rooms[roomId]
if(usersInRoom){
// console.log('Users in room', usersInRoom.length)
io.to(roomId).emit('update_user_count', usersInRoom.length)
}
})
socket.on('note_diff', data => {
//Each user joins a room when they open the app.
io.in(data.id).clients((error, clients) => {
if (error) throw error;
//Go through each client in note room and send them the diff
clients.forEach(socketId => {
if(socketId != socket.id){
io.to(socketId).emit('incoming_diff', data.diff)
}
})
});
})
socket.on('disconnect', function(){
// console.log('user disconnected');
});
});
http.listen(3001, function(){
console.log('socket.io liseting on port 3001');
});
//Enable json body parsing in requests. Allows me to post data in ajax calls
app.use(express.json())
app.use(express.json({limit: '2mb'}))
//Prefix defied by route in nginx config
const prefix = '/api'
//App Auth, all requests will come in with a token, decode the token and set global var
app.use(function(req, res, next){
let token = req.headers.authorization
//auth token set by axios in headers
let token = req.headers.authorizationtoken
if(token && token != null && typeof token === 'string'){
Auth.decodeToken(token)
.then(userData => {
req.headers.userId = userData.id //Update headers for the rest of the application
next()
}).catch(error => {
@@ -36,17 +112,32 @@ app.use(function(req, res, next){
//Test
app.get(prefix, (req, res) => res.send('The api is running'))
//Init user endpoint
//Serve up uploaded files
app.use(prefix+'/static', express.static( __dirname+'/../staticFiles' ))
//Public routes
var public = require('@routes/publicController')
app.use(prefix+'/public', public)
//user endpoint
var user = require('@routes/userController')
app.use(prefix+'/user', user)
//Init notes endpoint
//notes endpoint
var notes = require('@routes/noteController')
app.use(prefix+'/note', notes)
//Init tags endpoint
//tags endpoint
var tags = require('@routes/tagController')
app.use(prefix+'/tag', tags)
//notes endpoint
var attachment = require('@routes/attachmentController')
app.use(prefix+'/attachment', attachment)
//quick notes endpoint
var quickNote = require('@routes/quicknoteController')
app.use(prefix+'/quick-note', quickNote)
//Output running status
app.listen(port, () => console.log(`Listening on port ${port}!`))

View File

@@ -2,48 +2,268 @@ let db = require('@config/database')
let Attachment = module.exports = {}
const cheerio = require('cheerio');
const rp = require('request-promise');
const cheerio = require('cheerio')
const rp = require('request-promise')
const request = require('request')
const fs = require('fs')
const gm = require('gm')
const tesseract = require("node-tesseract-ocr")
const filePath = '../staticFiles/'
// Attachment.migrateOld
Attachment.textSearch = (userId, searchTerm) => {
return new Promise((resolve, reject) => {
const front = 5
const tail = 150
const query = `
SELECT
*,
substring(
text,
IF(LOCATE(?, text) > ${tail}, LOCATE(?, text) - ${front}, 1),
${tail} + LENGTH(?) + ${front}
) as snippet
FROM attachment
WHERE user_id = ?
AND MATCH(text)
AGAINST(? IN NATURAL LANGUAGE MODE)
LIMIT 1000`
db.promise()
.query(query, [searchTerm, searchTerm, searchTerm, userId, searchTerm])
.then((rows, fields) => {
resolve(rows[0]) //Return all attachments found by query
})
.catch(console.log)
})
}
Attachment.search = (userId, noteId, attachmentType) => {
return new Promise((resolve, reject) => {
let params = [userId]
let query = 'SELECT * FROM attachment WHERE user_id = ? AND visible = 1 '
if(noteId && noteId > 0){
query += 'AND note_id = ? '
params.push(noteId)
}
if(Number.isInteger(attachmentType)){
query += 'AND attachment_type = ? '
params.push(attachmentType)
}
query += 'ORDER BY last_indexed DESC '
db.promise()
.query(query, params)
.then((rows, fields) => {
resolve(rows[0]) //Return all attachments found by query
})
.catch(console.log)
})
}
//Returns all attachments
Attachment.forNote = (userId, noteId) => {
return new Promise((resolve, reject) => {
db.promise()
.query(`SELECT * FROM attachment WHERE user_id = ? AND note_id = ? AND attachment_type = 1;`, [userId, noteId])
.query(`SELECT * FROM attachment WHERE user_id = ? AND note_id = ? AND visible = 1 ORDER BY last_indexed DESC;`, [userId, noteId])
.then((rows, fields) => {
resolve(rows[0]) //Return all tags found by query
resolve(rows[0]) //Return all attachments found by query
})
.catch(console.log)
})
}
Attachment.delete = (attachmentId) => {
Attachment.urlForNote = (userId, noteId) => {
return new Promise((resolve, reject) => {
db.promise()
.query(`DELETE FROM attachment WHERE id = ?`, [attachmentId])
.query(`SELECT * FROM attachment WHERE user_id = ? AND note_id = ? AND attachment_type = 1 ORDER BY last_indexed DESC;`, [userId, noteId])
.then((rows, fields) => {
resolve(rows[0]) //Return all tags found by query
resolve(rows[0]) //Return all attachments found by query
})
.catch(console.log)
})
}
Attachment.scanTextForWebsites = (userId, noteId, noteText) => {
//Update attachment in database
Attachment.update = (userId, attachmentId, updatedText, noteId) => {
return new Promise((resolve, reject) => {
db.promise()
.query(`UPDATE attachment SET text = ? WHERE id = ? AND user_id = ?`,
[updatedText, attachmentId, userId])
.then((rows, fields) => {
resolve(true)
})
.catch(console.log)
})
}
Attachment.delete = (userId, attachmentId, urlDelete = false) => {
return new Promise((resolve, reject) => {
db.promise()
.query('SELECT * FROM attachment WHERE id = ? AND user_id = ? LIMIT 1', [attachmentId, userId])
.then((rows, fields) => {
//Attachment doesn't exist, return done
if(rows[0].length == 0){
return resolve(true)
}
//Pull data we want out of
let row = rows[0][0]
let url = row.url
const noteId = row.note_id
//Try to delete file and thumbnail
try {
fs.unlinkSync(filePath+row.file_location)
} catch(err) { console.error('File Does not exist') }
try {
fs.unlinkSync(filePath+'thumb_'+row.file_location)
} catch(err) { console.error('Thumbnail Does not exist') }
//Do not delete link attachments, just hide them. They will be deleted if removed from note
if(row.attachment_type == 1 && !urlDelete){
db.promise()
.query(`UPDATE attachment SET visible = 0 WHERE id = ?`, [attachmentId])
.then((rows, fields) => { })
.catch(console.log)
return resolve(true)
}
db.promise()
.query(`DELETE FROM attachment WHERE id = ?`, [attachmentId])
.then((rows, fields) => { })
.catch(console.log)
return resolve(true)
})
})
}
Attachment.processUploadedFile = (userId, noteId, fileObject) => {
return new Promise((resolve, reject) => {
const rawFilename = fileObject.filename
const extension = '.'+fileObject.originalname.split('.').pop()
const goodFileName = rawFilename+extension
const fileName = fileObject.originalname //Actual name of the file, dog.jpg
//Rename random file name to one with an extension
fs.rename(filePath+rawFilename, filePath+goodFileName, (err) => {
const created = Math.round((+new Date)/1000)
db.promise()
.query(`
INSERT INTO attachment
(note_id, user_id, attachment_type, \`text\`, last_indexed, file_location)
VALUES
(?, ?, ?, ?, ?, ?)
`, [noteId, userId, 2, 'Add a description to -> '+fileName, created, goodFileName])
.then((rows, fields) => {
Attachment.generateThumbnail(goodFileName)
//If its an image, scrape text
if(true){
// https://github.com/tesseract-ocr/tesseract/wiki/ImproveQuality
//psm 3 - default, 11 - as much text as possible
const config = { lang: "eng", oem: 1, psm: 3 }
tesseract.recognize(filePath+goodFileName, config)
.then(text => {
text = text.slice(0, -1).trim()
if(text.length > 5){
console.log('Inserting text')
db.promise().query(
`UPDATE attachment SET text = ? WHERE id = ? AND user_id = ? LIMIT 1`,
[text, rows[0].insertId, userId]
).then(results => {
resolve({ fileName, goodFileName })
})
} else {
return resolve({ fileName, goodFileName })
}
})
.catch(error => {
console.log(error.message)
})
} else {
resolve({ fileName, goodFileName })
}
})
.catch(console.log)
})
})
}
Attachment.generateThumbnail = (fileName) => {
return new Promise((resolve, reject) => {
gm(filePath+fileName)
.resize(550) //Resize to width of 550 px
.quality(75) //compression level 0 - 100 (best)
.write(filePath + 'thumb_'+fileName, function (err) {
resolve(fileName)
})
})
}
//Scans text for websites, returns all attachments
Attachment.scanTextForWebsites = (io, userId, noteId, noteText) => {
return new Promise((resolve, reject) => {
let solrAttachmentText = '' //Final searchable scrape text for note
if(noteText.length == 0){ resolve(solrAttachmentText) }
Attachment.forNote(userId, noteId).then(attachments => {
Attachment.urlForNote(userId, noteId).then(attachments => {
//Find all URLs in text
//@TODO - Use the process text library for this function
const urlPattern = /(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[A-Z0-9+&@#/%=~_|$])/igm
let allUrls = noteText.match(urlPattern)
//Remove all duplicates
let foundUrls = [...new Set(allUrls)]
if(allUrls == null){
allUrls = []
}
//Go through each attachment, check for existing URLs
//Every URL needs HTTPS!!!
let foundUrls = []
allUrls.forEach( (item, index) => {
//Every URL should have HTTPS
if(item.indexOf('https://') == -1 && item.indexOf('http://') == -1){
allUrls[index] = 'https://'+item
}
//URLs should all have HTTPS!!!
if(item.indexOf('http://') >= 0){
allUrls[index] = item.replace('http://','https://')
}
})
//Remove all duplicates
foundUrls = [...new Set(allUrls)]
//Go through each saved URL, remove new URLs from saved URLs
//If a URL is not found, delete it
attachments.forEach(attachment => {
//URL already scraped, push text and continue
let urlIndex = foundUrls.indexOf( attachment.url )
@@ -52,18 +272,24 @@ Attachment.scanTextForWebsites = (userId, noteId, noteText) => {
solrAttachmentText += attachment.text
foundUrls.splice(urlIndex, 1) //Remove existing from set of found
} else {
Attachment.delete(attachment.id)
//If existing attachment is not found in note, remove it
Attachment.delete(userId, attachment.id, true)
}
})
//No newly scraped URLs, resolve with looked up attachment text
if(foundUrls == null || foundUrls.length == 0){
resolve(solrAttachmentText)
return resolve(solrAttachmentText)
}
//Process the remaining URLs into attachments
Attachment.scrapeUrlsCreateAttachments(userId, noteId, foundUrls).then( freshlyScrapedText => {
//Once everything is done being scraped, emit new attachment events
if(io){
io.to(userId).emit('update_counts')
}
solrAttachmentText += freshlyScrapedText
resolve(solrAttachmentText)
})
@@ -75,11 +301,13 @@ Attachment.scanTextForWebsites = (userId, noteId, noteText) => {
Attachment.scrapeUrlsCreateAttachments = (userId, noteId, foundUrls) => {
return new Promise((resolve, reject) => {
if(foundUrls == null || foundUrls.length == 0){
return resolve('')
}
console.log('About to scrape')
console.log(foundUrls)
if(foundUrls == null || foundUrls.length == 0){resolve('')}
let processedCount = 0
let scrapedText = ''
@@ -99,8 +327,56 @@ Attachment.scrapeUrlsCreateAttachments = (userId, noteId, foundUrls) => {
})
}
Attachment.downloadFileFromUrl = (url) => {
return new Promise((resolve, reject) => {
if(url == null){
resolve(null)
}
const random = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
const extension = '.'+url.split('.').pop() //This is throwing an error
let fileName = random+'_scrape'+extension
const thumbPath = 'thumb_'+fileName
console.log('Scraping image url')
console.log(url)
console.log('Getting ready to scrape ', url)
request(url)
.on('error', error => {
console.log(error)
resolve(null)
})
.on('response', res => {
console.log(res.statusCode)
console.log(res.headers['content-type'])
})
.pipe(fs.createWriteStream(filePath+thumbPath))
.on('close', () => {
//resize image if its real big
gm(filePath+thumbPath)
.resize(550) //Resize to width of 550 px
.quality(75) //compression level 0 - 100 (best)
.write(filePath+thumbPath, function (err) {
if(err){ console.log(err) }
})
console.log('Saved Image')
resolve(fileName)
})
})
}
Attachment.processUrl = (userId, noteId, url) => {
const scrapeTime = 20*1000;
return new Promise((resolve, reject) => {
const excludeWords = ['share','facebook','twitter','reddit','be','have','do','say','get','make','go','know','take','see','come','think','look','want',
@@ -112,12 +388,10 @@ Attachment.processUrl = (userId, noteId, url) => {
var removeWhitespace = /\s+/g
// console.log('Scraping ', website)
const options = {
uri: url,
simple: true,
timeout: 1000 * 10, // 10 seconds
timeout: scrapeTime,
headers: {
'User-Agent':'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' //Simulate google headers
},
@@ -127,8 +401,24 @@ Attachment.processUrl = (userId, noteId, url) => {
}
let requestTimeout = null
let thumbnail = null
let request = null
let created = Math.round((+new Date)/1000)
let insertedId = null
let request = rp(options)
//Create a shell attachment for each URL, put in processing state
db.promise()
.query(`INSERT INTO attachment
(note_id, user_id, attachment_type, text, url, last_indexed, file_location)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[noteId, userId, 1, 'Processing...', url, created, null])
.then((rows, fields) => {
//Set two bigger variables then return request for processing
request = rp(options)
insertedId = rows[0].insertId
return request
})
.then($ => {
clearTimeout(requestTimeout)
@@ -138,8 +428,15 @@ Attachment.processUrl = (userId, noteId, url) => {
let pageTitle = $('title').text().replace(removeWhitespace, " ")
desiredSearchText += pageTitle + "\n"
let header = $('h1').text().replace(removeWhitespace, " ")
desiredSearchText += header + "\n"
// let header = $('h1').text().replace(removeWhitespace, " ")
// desiredSearchText += header + "\n"
//Scrape metadata for page image
let metadata = $('meta[property="og:image"]')
if(metadata && metadata[0] && metadata[0].attribs){
thumbnail = metadata[0].attribs.content
}
let majorContent = ''
majorContent += $('[class*=content]').text()
@@ -179,27 +476,52 @@ Attachment.processUrl = (userId, noteId, url) => {
});
let finalWords = []
for(let i=0; i<15; i++){
for(let i=0; i<5; i++){
if(sortable[i] && sortable[i][0]){
finalWords.push(sortable[i][0])
}
}
desiredSearchText += finalWords.join(', ')
console.log('TexT Scraped')
console.log(desiredSearchText)
if(finalWords.length > 0){
desiredSearchText += 'Keywords: ' + finalWords.join(', ')
}
const created = Math.round((+new Date)/1000)
// console.log('TexT Scraped')
// console.log(desiredSearchText)
//Create attachment in DB with scrape text and provided data
db.promise()
.query(`INSERT INTO attachment
(note_id, user_id, attachment_type, text, url, last_indexed)
VALUES (?, ?, ?, ?, ?, ?)`, [noteId, userId, 1, desiredSearchText, url, created])
.then((rows, fields) => {
resolve(desiredSearchText) //Return found text
created = Math.round((+new Date)/1000)
//Scrape URL for thumbnail - take filename and save in attachment
Attachment.downloadFileFromUrl(thumbnail)
.then(thumbnailFilename => {
//Update text and thumbnail filename
created = Math.round((+new Date)/1000)
db.promise()
.query(`UPDATE attachment SET
text = ?,
last_indexed = ?,
file_location = ?
WHERE id = ?
`, [desiredSearchText, created, thumbnailFilename, insertedId])
.then((rows, fields) => {
resolve(desiredSearchText) //Return found text
})
.catch(console.log)
//Create attachment in DB with scrape text and provided data
// db.promise()
// .query(`INSERT INTO attachment
// (note_id, user_id, attachment_type, text, url, last_indexed, file_location)
// VALUES (?, ?, ?, ?, ?, ?, ?)`, [noteId, userId, 1, desiredSearchText, url, created, thumbnailFilename])
// .then((rows, fields) => {
// resolve(desiredSearchText) //Return found text
// })
// .catch(console.log)
})
.catch(console.log)
})
.catch(error => {
@@ -212,19 +534,30 @@ Attachment.processUrl = (userId, noteId, url) => {
console.log('Cancel the request, its taking to long.')
request.cancel()
desiredSearchText = 'Unable to Scrape URL at this time'
const created = Math.round((+new Date)/1000)
desiredSearchText = 'No Description for -> '+url
//Create attachment in DB with scrape text and provided data
created = Math.round((+new Date)/1000)
db.promise()
.query(`INSERT INTO attachment
(note_id, user_id, attachment_type, text, url, last_indexed)
VALUES (?, ?, ?, ?, ?, ?)`, [noteId, userId, 1, desiredSearchText, url, created])
.query(`UPDATE attachment SET
text = ?,
last_indexed = ?,
WHERE id = ?
`, [desiredSearchText, created, insertedId])
.then((rows, fields) => {
resolve(desiredSearchText) //Return found text
})
.catch(console.log)
}, (5000))
//Create attachment in DB with scrape text and provided data
// db.promise()
// .query(`INSERT INTO attachment
// (note_id, user_id, attachment_type, text, url, last_indexed)
// VALUES (?, ?, ?, ?, ?, ?)`, [noteId, userId, 1, desiredSearchText, url, created])
// .then((rows, fields) => {
// resolve(desiredSearchText) //Return found text
// })
// .catch(console.log)
}, scrapeTime )
})
}

View File

@@ -2,21 +2,103 @@ let db = require('@config/database')
let Tags = require('@models/Tag')
let Attachment = require('@models/Attachment')
let ShareNote = require('@models/ShareNote')
let ProcessText = require('@helpers/ProcessText')
const DiffMatchPatch = require('@helpers/DiffMatchPatch')
var rp = require('request-promise');
var SolrNode = require('solr-node');
const fs = require('fs')
let Note = module.exports = {}
// Create client
var client = new SolrNode({
host: '127.0.0.1',
port: '8983',
core: 'note',
protocol: 'http'
});
Note.create = (userId, noteText) => {
const gm = require('gm')
// --------------
Note.migrateNoteTextToNewTable = () => {
return new Promise((resolve, reject) => {
db.promise()
.query('SELECT id, text FROM note WHERE note_raw_text_id IS NULL')
.then((rows, fields) => {
rows[0].forEach( ({id, text}) => {
db.promise()
.query('INSERT INTO note_raw_text (text) VALUES (?)', [text])
.then((rows, fields) => {
db.promise()
.query(`UPDATE note SET note_raw_text_id = ? WHERE (id = ?)`, [rows[0].insertId, id])
.then((rows, fields) => {
return 'Nice'
})
})
})
resolve('Its probably running... :-D')
})
})
}
Note.fixAttachmentThumbnails = () => {
const filePath = '../staticFiles/'
db.promise()
.query(`SELECT * FROM attachment WHERE file_location NOT LIKE "%.%"`)
.then( (rows, fields) => {
rows[0].forEach(line => {
const rawFilename = line['file_location']
const goodFileName = rawFilename+'.jpg'
//Rename file to have jpg extension, create thumbnail, update database
fs.rename(filePath+rawFilename, filePath+goodFileName, (err) => {
db.promise()
.query(`UPDATE attachment SET file_location = ? WHERE id = ?`,[goodFileName, line['id'] ])
.then( (rows, fields) => {
gm(filePath+goodFileName)
.resize(550) //Resize to width of 550 px
.quality(75) //compression level 0 - 100 (best)
.write(filePath + 'thumb_'+goodFileName, function (err) {
console.log('Done for -> ', goodFileName)
})
})
})
})
})
}
Note.stressTest = () => {
return new Promise((resolve, reject) => {
db.promise()
.query(`
SELECT text FROM note;
`)
.then((rows, fields) => {
console.log()
rows[0].forEach(item => {
Note.create(68, item['text'])
})
resolve(true)
})
.catch(console.log)
})
}
// --------------
Note.create = (userId, noteText, quickNote = 0) => {
return new Promise((resolve, reject) => {
if(userId == null || userId < 10){ reject('User Id required to create note') }
@@ -24,48 +106,94 @@ Note.create = (userId, noteText) => {
const created = Math.round((+new Date)/1000)
db.promise()
.query('INSERT INTO note (user_id, text, created) VALUES (?,?,?)', [userId, noteText, created])
.query(`INSERT INTO note_raw_text (text) VALUE (?)`, [noteText])
.then( (rows, fields) => {
const rawTextId = rows[0].insertId
return db.promise()
.query('INSERT INTO note (user_id, note_raw_text_id, updated, created, quick_note) VALUES (?,?,?,?,?)',
[userId, rawTextId, created, created, quickNote])
})
.then((rows, fields) => {
// Indexing is done on save
resolve(rows[0].insertId) //Only return the new note ID when creating a new note
})
.catch(console.log)
})
}
Note.update = (userId, noteId, noteText, fancyInput, color, pinned, archived) => {
Note.reindex = (userId, noteId) => {
return new Promise((resolve, reject) => {
Note.get(userId, noteId)
.then(note => {
const noteText = note.text
//
// Update Solr index
//
Tags.string(userId, noteId)
.then(tagString => {
const fullText = ProcessText.removeHtml(noteText) +' '+ tagString
db.promise()
.query(`
INSERT INTO note_text_index (note_id, user_id, text)
VALUES (?,?,?)
ON DUPLICATE KEY UPDATE text = ?
`, [noteId, userId, fullText, fullText])
.then((rows, fields) => {
resolve(true)
})
.catch(console.log)
})
})
})
}
Note.update = (io, userId, noteId, noteText, color, pinned, archived) => {
return new Promise((resolve, reject) => {
//Prevent note loss if it saves with empty text
if(ProcessText.removeHtml(noteText) == ''){
console.log('Not saving empty note')
resolve(false)
}
const now = Math.round((+new Date)/1000)
db.promise()
.query('UPDATE note SET text = ?, raw_input = ?, pinned = ?, archived = ?, updated = ?, color = ? WHERE id = ? AND user_id = ? LIMIT 1',
[noteText, fancyInput, pinned, archived, now, color, noteId, userId])
.query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ?', [noteId, userId])
.then((rows, fields) => {
//Process note text and attachment data
Attachment.scanTextForWebsites(userId, noteId, noteText).then( attachmentText => {
//
// Update Solr index
//
Tags.string(userId, noteId).then(tagString => {
// JSON Data
var data = {
'id': noteId,//string - ID of note
'user_id': userId,//int
'note_text': noteText,
'note_tag': tagString,
'attachment_text': attachmentText,
};
// Update document to Solr server
client.update(data, function(err, result) {
if (err) { console.log(err); return; }
console.log('Note Solr Update, node/solrid ('+noteId+'):');
console.log(result.responseHeader)
});
})
})
const textId = rows[0][0]['note_raw_text_id']
//Update Note text
return db.promise()
.query('UPDATE note_raw_text SET text = ? WHERE id = ?', [noteText, textId])
})
.then( (rows, fields) => {
//Update other note attributes
return db.promise()
.query('UPDATE note SET pinned = ?, archived = ?, updated = ?, color = ? WHERE id = ? AND user_id = ? LIMIT 1',
[pinned, archived, now, color, noteId, userId])
})
.then((rows, fields) => {
//Async solr note reindex
Note.reindex(userId, noteId)
//Async attachment reindex
Attachment.scanTextForWebsites(io, userId, noteId, noteText)
//Send back updated response
resolve(rows[0])
@@ -74,25 +202,164 @@ Note.update = (userId, noteId, noteText, fancyInput, color, pinned, archived) =>
})
}
//
// Delete a note and all its remaining parts
//
Note.delete = (userId, noteId) => {
return new Promise((resolve, reject) => {
db.promise().query('DELETE FROM note WHERE note.id = ? AND note.user_id = ?', [noteId,userId])
.then((rows, fields) => {
db.promise().query('DELETE FROM attachment WHERE attachment.note_id = ? AND attachment.user_id = ?', [noteId,userId])
.then((rows, fields)=> {
db.promise().query('DELETE FROM note_tag WHERE note_tag.note_id = ? AND note_tag.user_id = ?', [noteId,userId])
.then((rows, fields)=> {
//
// Delete, note, text, search index and associated tags
// Leave the attachments, they can be deleted on their own
// Leave Tags, their text is shared
let rawTextId = null
let noteTextCount = 0
// Lookup the note text ID, we need this to count usages
db.promise()
.query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ?', [noteId, userId])
.then((rows, fields) => {
//Save the raw text ID
rawTextId = rows[0][0]['note_raw_text_id']
return db.promise()
.query('SELECT count(id) as count FROM note WHERE note_raw_text_id = ?', [rawTextId])
})
.then((rows, fields) => {
//Save the number of times the note is used
noteTextCount = rows[0][0]['count']
//Don't delete text if its shared
if(noteTextCount == 1){
//If text is only used on one note, delete it (its not shared)
return db.promise()
.query('SELECT count(id) as count FROM note WHERE note_raw_text_id = ?', [rawTextId])
} else {
return new Promise((resolve, reject) => {
resolve(true)
})
}
})
.then( results => {
// Delete Note entry for this user.
return db.promise()
.query('DELETE FROM note WHERE note.id = ? AND note.user_id = ?', [noteId, userId])
})
.then((rows, fields) => {
// Delete search index
return db.promise()
.query('DELETE FROM note_text_index WHERE note_text_index.note_id = ? AND note_text_index.user_id = ?', [noteId,userId])
})
.then((rows, fields) => {
// delete tags
return db.promise()
.query('DELETE FROM note_tag WHERE note_tag.note_id = ? AND note_tag.user_id = ?', [noteId,userId])
})
.then((rows, fields) => {
//IF there are nots with a matching raw text id, we want to under their share status
db.promise().query('SELECT id FROM note WHERE note_raw_text_id = ?',[rawTextId])
.then((rows, fields) => {
if(rows[0].length == 1){
db.promise().query('UPDATE note SET shared = 0 WHERE id = ?', [rows[0][0]['id']])
}
})
resolve(true)
})
})
}
//text is the current text for the note that will be compared to the text in the database
Note.getDiffText = (userId, noteId, usersCurrentText, lastUpdated) => {
return new Promise((resolve, reject) => {
Note.get(userId, noteId)
.then(noteObject => {
let oldText = noteObject.text.replace(/(\r\n|\n|\r)/gm,"")
let newText = usersCurrentText.replace(/(\r\n|\n|\r)/gm,"")
if(noteObject.updated == lastUpdated){
// console.log('No note diff')
resolve(null)
}
if(noteObject.updated > lastUpdated){
newText = noteObject.text.replace(/(\r\n|\n|\r)/gm,"")
oldText = usersCurrentText.replace(/(\r\n|\n|\r)/gm,"")
}
const dmp = new DiffMatchPatch.diff_match_patch()
const diff = dmp.diff_main(oldText, newText)
dmp.diff_cleanupSemantic(diff)
const patch_list = dmp.patch_make(oldText, newText, diff);
const patch_text = dmp.patch_toText(patch_list);
//Patch text - shows a list of changes
var patches = dmp.patch_fromText(patch_text);
// console.log(patch_text)
//results[1] - contains diagnostic data for patch apply, its possible it can fail
var results = dmp.patch_apply(patches, oldText);
//Compile return data for front end
const returnData = {
updatedText: results[0],
diffs: results[1].length, //Only use length for now
updated: Math.max(noteObject.updated,lastUpdated) //Return most recently updated date
}
//Final change in notes
// console.log(returnData)
resolve(returnData)
})
})
}
Note.get = (userId, noteId) => {
return new Promise((resolve, reject) => {
db.promise()
.query('SELECT text, updated, raw_input, pinned, archived, color FROM note WHERE user_id = ? AND id = ? LIMIT 1', [userId,noteId])
.query(`
SELECT
note_raw_text.text,
note.updated,
note.pinned,
note.archived,
note.color,
count(distinct attachment.id) as attachment_count,
note.note_raw_text_id as rawTextId
FROM note
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
LEFT JOIN attachment ON (note.id = attachment.note_id)
LEFT JOIN user ON (note.share_user_id = user.id)
WHERE note.user_id = ? AND note.id = ? LIMIT 1`, [userId,noteId])
.then((rows, fields) => {
const created = Math.round((+new Date)/1000)
db.promise().query(`UPDATE note SET opened = ? WHERE (id = ?)`, [created, noteId])
//Return note data
resolve(rows[0][0])
})
.catch(console.log)
})
}
//Public note share action -> may not be used
Note.getShared = (noteId) => {
return new Promise((resolve, reject) => {
db.promise()
.query('SELECT text, updated, color FROM note WHERE id = ? AND shared = 1 LIMIT 1', [noteId])
.then((rows, fields) => {
//Return note data
@@ -103,94 +370,136 @@ Note.get = (userId, noteId) => {
})
}
// Searches text index, returns nothing if there is no search query
Note.solrQuery = (userId, searchQuery, searchTags) => {
return new Promise((resolve, reject) => {
if(searchQuery != '' && searchQuery != null){
let urlQuery = `/solr/note/select?hl.fl=note_text&hl=on&q=user_id:${userId} AND note_text:${searchQuery}&wt=json`
urlQuery = `/solr/note/select?
hl.fl=note_text,attachment_text,note_tag&
hl=on&
q=user_id:${userId} AND (note_text:${searchQuery} OR attachment_text:${searchQuery} OR note_tag:${searchQuery})&
wt=json&
fl=id&
hl.fl=note_text,attachment_text,note_tag&
hl.snippets=20&
hl.maxAnalyzedChars=100000`
rp('http://127.0.0.1:8983'+urlQuery)
.then(function (htmlString) {
let solrResult = JSON.parse(htmlString)
resolve(solrResult)
})
if(searchQuery.length == 0){
resolve(null)
} else {
resolve([])
//Number of characters before and after search word
const front = 5
const tail = 150
db.promise()
.query(`
SELECT
note_id,
substring(
text,
IF(LOCATE(?, text) > ${tail}, LOCATE(?, text) - ${front}, 1),
${tail} + LENGTH(?) + ${front}
) as snippet
FROM note_text_index
WHERE user_id = ?
AND MATCH(text)
AGAINST(? IN NATURAL LANGUAGE MODE)
LIMIT 1000
;
`, [searchQuery, searchQuery, searchQuery, userId, searchQuery])
.then((rows, fields) => {
let results = []
let snippets = {}
rows[0].forEach(item => {
let noteId = parseInt(item['note_id'])
//Setup array of ids to use for query
results.push( noteId )
//Get text snippet and highlight the key word
snippets[noteId] = item['snippet'].replace(new RegExp(searchQuery,"ig"), '<em>'+searchQuery+'</em>');
//.replace(searchQuery,'<em>'+searchQuery+'</em>')
})
resolve({ 'ids':results, 'snippets':snippets })
})
}
})
}
Note.search = (userId, searchQuery, searchTags, fastFilters) => {
return new Promise((resolve, reject) => {
//Define return data objects
let returnData = {
'notes':[],
'tags':[]
}
Note.solrQuery(userId, searchQuery, searchTags).then( solrResult => {
Note.solrQuery(userId, searchQuery, searchTags).then( (textSearchResults) => {
let highlights = solrResult.highlighting
//Pull out search results from previous query
let textSearchIds = []
let highlights = {}
let returnTagResults = false
//Parse Note ID's from solr search
let solrNoteIds = []
if(solrResult.response){
solrResult.response.docs.forEach(item => {
solrNoteIds.push(parseInt(item.id))
})
}
//No results, return empty data
if(solrNoteIds.length == 0 && searchQuery.length > 0){
resolve(returnData)
if(textSearchResults != null){
textSearchIds = textSearchResults['ids']
highlights = textSearchResults['snippets']
}
//Default note lookup gets all notes
//No results, return empty data
if(textSearchIds.length == 0 && searchQuery.length > 0){
return resolve(returnData)
}
// Base of the query, modified with fastFilters
// Add to query for character counts -> CHAR_LENGTH(note.text) as chars
let searchParams = [userId]
let noteSearchQuery = `
SELECT note.id,
SUBSTRING(note.text, 1, 400) as text,
updated, color,
SUBSTRING(note_raw_text.text, 1, 1500) as text,
updated,
color,
count(distinct note_tag.id) as tag_count,
count(distinct attachment.id) as attachment_count,
note.pinned,
note.archived
note.archived,
GROUP_CONCAT(DISTINCT tag.text) as tags,
GROUP_CONCAT(DISTINCT attachment.file_location) as thumbs,
shareUser.username as shareUsername,
note.shared
FROM note
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
LEFT JOIN note_tag ON (note.id = note_tag.note_id)
LEFT JOIN attachment ON (note.id = attachment.note_id AND attachment.attachment_type = 1)
WHERE note.user_id = ?`
let searchParams = [userId]
LEFT JOIN tag ON (tag.id = note_tag.tag_id)
LEFT JOIN attachment ON (note.id = attachment.note_id AND attachment.visible = 1)
LEFT JOIN user as shareUser ON (note.share_user_id = shareUser.id)
WHERE note.user_id = ?
`
//Show shared notes
if(fastFilters.onlyShowSharedNotes == 1){
//share_user_id means your shared them, a note with a shared user id filled in means it was shared
noteSearchQuery += ` AND share_user_id IS NOT NULL OR (note.shared = 2 AND note.user_id = ?)`
searchParams.push(userId)
//Show notes shared with you
} else {
noteSearchQuery += ' AND note.share_user_id IS NULL'
}
if(solrNoteIds.length > 0){
searchParams.push(solrNoteIds)
//If text search returned results, limit search to those ids
if(textSearchIds.length > 0){
searchParams.push(textSearchIds)
noteSearchQuery += ' AND note.id IN (?)'
}
// if(searchQuery != ''){
// //If a search query is defined, search notes for that word
// searchParams.push('%'+searchQuery+'%')
// noteSearchQuery += ' AND text LIKE ?'
// }
if(fastFilters.noteIdSet && fastFilters.noteIdSet.length > 0){
searchParams.push(fastFilters.noteIdSet)
noteSearchQuery += ' AND note.id IN (?)'
}
//If tags are passed, use those tags in search
if(searchTags.length > 0){
//If tags are passed, use those tags in search
searchParams.push(searchTags)
noteSearchQuery += ' AND note_tag.tag_id IN (?)'
}
//Toggle archived, show archived if tags are searched
// - archived will show archived in search results
// - onlyArchive will exclude notes that are not archived
if(fastFilters.archived == 1 || searchTags.length > 0 || fastFilters.onlyArchived == 1){
//Do nothing
//Show archived notes, only if fast filter is set, default to not archived
if(fastFilters.onlyArchived == 1){
noteSearchQuery += ' AND note.archived = 1' //Show Archived
} else {
noteSearchQuery += ' AND note.archived = 0' //Exclude archived
}
@@ -200,32 +509,54 @@ Note.search = (userId, searchQuery, searchTags, fastFilters) => {
//Only show notes with Tags
if(fastFilters.withTags == 1){
returnTagResults = true
noteSearchQuery += ' HAVING tag_count > 0'
}
//Only show notes with links
if(fastFilters.withLinks == 1){
returnTagResults = true
noteSearchQuery += ' HAVING attachment_count > 0'
}
//Only show archived notes
if(fastFilters.onlyArchived == 1){
returnTagResults = true
noteSearchQuery += ' HAVING note.archived = 1'
}
//
// Always prioritize pinned notes in searches.
//Default Sort, order by last updated
let defaultOrderBy = ' ORDER BY note.pinned DESC, updated DESC, created DESC, opened DESC, id DESC'
let defaultOrderBy = ' ORDER BY note.pinned DESC, note.updated DESC, note.created DESC, note.opened DESC, id DESC'
//Order by Last Created Date
if(fastFilters.lastCreated == 1){
defaultOrderBy = ' ORDER BY note.pinned DESC, created DESC, updated DESC, opened DESC, id DESC'
defaultOrderBy = ' ORDER BY note.pinned DESC, note.created DESC, note.updated DESC, note.opened DESC, id DESC'
}
//Order by last Opened Date
if(fastFilters.lastOpened == 1){
defaultOrderBy = ' ORDER BY note.pinned DESC, opened DESC, updated DESC, created DESC, id DESC'
defaultOrderBy = ' ORDER BY note.pinned DESC, opened DESC, note.updated DESC, note.created DESC, id DESC'
}
//Append Order by to query
noteSearchQuery += defaultOrderBy
//Manage limit params if set
if(fastFilters.limitSize > 0 || fastFilters.limitOffset > 0){
const limitSize = parseInt(fastFilters.limitSize, 10) || 10 //Use int or default to 10
const limitOffset = parseInt(fastFilters.limitOffset, 10) || 0 //Either parse int, or use zero
// console.log(` LIMIT ${limitOffset}, ${limitSize}`)
noteSearchQuery += ` LIMIT ${limitOffset}, ${limitSize}`
}
// console.log('------------- Final Query --------------')
// console.log(noteSearchQuery)
// console.log('------------- ----------- --------------')
db.promise()
.query(noteSearchQuery, searchParams)
.then((noteRows, noteFields) => {
@@ -239,55 +570,43 @@ Note.search = (userId, searchQuery, searchTags, fastFilters) => {
//Grab note ID for finding tags
noteIds.push(note.id)
if(note.text == null){ note.text = '' }
//Deduce note title
const textData = ProcessText.deduceNoteTitle(note.text)
// console.log(textData)
// console.log(textData)
//Attempt to pull string out of first tag in note
let reg = note.text.match(/<([\w]+)[^>]*>(.*?)<\/\1>/g)
//Pull out first html tag contents, that is the title
if(reg != null && reg[0]){
note.title = reg[0] //First line from HTML
} else {
note.title = note.text //Entire note
}
//Clean up html title
note.title = note.title
.replace(/&[#A-Za-z0-9]+;/g,'') //Rip out all HTML entities
.replace(/<[^>]+>/g, '') //Rip out all HTML tags
//Generate Subtext
note.subtext = ''
if(note.text != '' && note.title != ''){
note.subtext = note.text
.replace(/&[#A-Za-z0-9]+;/g,' ') //Rip out all HTML entities
.replace(/<[^>]+>/g, ' ') //Rip out all HTML tags
.replace(/\s+/g, ' ') //Remove all whitespace
.substring(note.title.length + 2)
}
note.title = textData.title
note.subtext = textData.sub
note.titleLength = textData.titleLength
note.subtextLength = textData.subtextLength
note.note_highlights = []
note.attachment_highlights = []
note.tag_highlights = []
//Push in solr highlights
if(highlights && highlights[note.id] && highlights[note.id].note_text){
note['note_highlights'] = highlights[note.id].note_text
}
if(highlights && highlights[note.id] && highlights[note.id].attachment_text){
note['attachment_highlights'] = highlights[note.id].attachment_text
}
if(highlights && highlights[note.id] && highlights[note.id].note_tag){
note['tag_highlights'] = highlights[note.id].note_tag
//Push in search highlights
if(highlights && highlights[note.id]){
note['note_highlights'] = [highlights[note.id]]
}
//Clear out note.text before sending it to front end
//Clear out note.text before sending it to front end, its being used in title and subtext
delete note.text
})
//If no notes are returned, there are no tags, return empty
if(noteIds.length == 0){
resolve(returnData)
return resolve(returnData)
}
//Return all notes, tags are not being searched
// if tags are being searched, continue
// if notes are being filtered, return tags
if(searchTags.length == 0 && returnTagResults == false){
return resolve(returnData)
}
//Only show tags of selected notes

101
server/models/QuickNote.js Normal file
View File

@@ -0,0 +1,101 @@
let db = require('@config/database')
let Note = require('@models/Note')
let QuickNote = module.exports = {}
QuickNote.get = (userId) => {
return new Promise((resolve, reject) => {
db.promise()
.query(`
SELECT note.id, text FROM note
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
WHERE quick_note = 1 AND user_id = ? LIMIT 1
`, [userId])
.then((rows, fields) => {
//Quick Note is set, return note text
if(rows[0].length == 1){
resolve({
id: rows[0][0].id,
text: rows[0][0].text
})
}
resolve({
id: null,
text: 'Enter something to create a quick note.'
})
})
.catch(console.log)
})
}
QuickNote.update = (userId, pushText) => {
return new Promise((resolve, reject) => {
//Process pushText, split at \n (new lines), put <p> tags around each new line
let broken = '<blockquote>' +
pushText.split(/\r?\n/).map( (line, index) => {
let clean = line
.replace(/&[#A-Za-z0-9]+;/g,'') //Rip out all HTML entities
.replace(/<[^>]+>/g, '') //Rip out all HTML tags
if(clean == ''){ clean = '&nbsp;' }
let newLine = ''
if(index > 0){ newLine = '<br>' }
//Return line wrapped in p tags
return `${newLine}<span>${clean}</span>`
}).join('') + '</blockquote>'
db.promise()
.query(`
SELECT note.id, text, color, pinned, archived
FROM note
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
WHERE quick_note = 1 AND user_id = ? LIMIT 1
`, [userId])
.then((rows, fields) => {
//Quick Note is set, push it the front of existing note
if(rows[0].length == 1){
let d = rows[0][0] //Get row data
//Push old text behind fresh new text
let newText = broken +''+ d.text
//Save that, then return the new text
Note.update(null, userId, d.id, newText, d.color, d.pinned, d.archived)
.then( saveResults => {
resolve({
id:d.id,
text:newText
})
})
} else {
//Create a new note with the quick text submitted.
Note.create(userId, broken, 1)
.then( insertId => {
resolve({
id:insertId,
text:broken
})
})
}
})
.catch(console.log)
})
//Lookup quick note,
//Note.create(userId, 'Quick Note', 1)
}

147
server/models/ShareNote.js Normal file
View File

@@ -0,0 +1,147 @@
//
// All actions in noteController.js
//
const db = require('@config/database')
const Note = require('@models/Note')
let ShareNote = module.exports = {}
// Share a note with a user, given the correct username
ShareNote.addUser = (userId, noteId, rawTextId, username) => {
return new Promise((resolve, reject) => {
let shareUserId = null
let newNoteShare = null
const cleanUser = username.toLowerCase().trim()
//Check that user actually exists
db.promise().query(`SELECT id FROM user WHERE LOWER(username) = ?`, [cleanUser])
.then((rows, fields) => {
if(rows[0].length == 0){
throw new Error('User Does Not Exist')
}
shareUserId = rows[0][0]['id']
//Check if note has already been added for user
return db.promise()
.query('SELECT id FROM note WHERE user_id = ? AND note_raw_text_id = ?', [shareUserId, rawTextId])
})
.then((rows, fields) => {
if(rows[0].length != 0){
throw new Error('User Already Has Note')
}
//Lookup note to share with user, clone this data to create users new note
return db.promise()
.query(`SELECT * FROM note WHERE id = ? LIMIT 1`, [noteId])
})
.then((rows, fields) => {
newNoteShare = rows[0][0]
//Modify note with the share attributes we want
delete newNoteShare['id']
delete newNoteShare['opened']
newNoteShare['share_user_id'] = userId //User who shared the note
newNoteShare['user_id'] = shareUserId //User who gets note
//Setup db colums, db values and number of '?' to put into prepared statement
let dbColumns = []
let dbValues = []
let escapeChars = []
//Pull out all the data we need from object to create prepared statemnt
Object.keys(newNoteShare).forEach( key => {
escapeChars.push('?')
dbColumns.push(key)
dbValues.push(newNoteShare[key])
})
//Stick all the note value back into query, insert updated note
return db.promise()
.query(`INSERT INTO note (${dbColumns.join()}) VALUES (${escapeChars.join()})`, dbValues)
})
.then((rows, fields) => {
//Update note share status to 2
return db.promise()
.query('UPDATE note SET shared = 2 WHERE id = ?', [noteId])
})
.then((rows, fields) => {
//Success!
return resolve({'success':true, shareUserId})
})
.catch(error => {
console.log(error)
resolve(false)
})
})
}
// Get users who see a shared note
ShareNote.getUsers = (userId, rawTextId) => {
return new Promise((resolve, reject) => {
db.promise()
.query(`
SELECT username, note.id as noteId
FROM note
JOIN user ON (user.id = note.user_id)
WHERE note_raw_text_id = ?
AND share_user_id = ?
AND user_id != ?
`, [rawTextId, userId, userId])
.then((rows, fields) => {
//Return a list of user names
return resolve (rows[0])
})
})
}
// Remove a user from a shared note
ShareNote.removeUser = (userId, noteId) => {
return new Promise((resolve, reject) => {
const Note = require('@models/Note')
let rawTextId = null
let removeUserId = null
//note.id = noteId, share_user_id = userId
db.promise()
.query('SELECT note_raw_text_id, user_id FROM note WHERE id = ? AND share_user_id = ?', [noteId, userId])
.then( (rows, fields) => {
rawTextId = rows[0][0]['note_raw_text_id']
removeUserId = rows[0][0]['user_id']
//Delete note entry for other user - remove users access
if(removeUserId && Number.isInteger(removeUserId)){
//Delete this users access to the note
return Note.delete(removeUserId, noteId)
} else {
return new Promise((resolve, reject) => { resolve(true) })
}
})
.then(stuff => {
resolve(true)
})
.catch(error => {
console.log(error)
resolve(false)
})
})
}

View File

@@ -2,6 +2,45 @@ let db = require('@config/database')
let Tag = module.exports = {}
Tag.userTags = (userId, searchQuery, searchTags, fastFilters) => {
return new Promise((resolve, reject) => {
let query = `
SELECT
tag.id,
text,
COUNT(note_tag.note_id) as usages
FROM tag
JOIN note_tag ON tag.id = note_tag.tag_id
JOIN note On note.id = note_tag.note_id
WHERE note_tag.user_id = ?
`
//Show shared notes
if(fastFilters && fastFilters.onlyShowSharedNotes == 1){
query += ' AND note.share_user_id IS NOT NULL' //Show Archived
} else {
query += ' AND note.share_user_id IS NULL'
}
//Show archived notes, only if fast filter is set, default to not archived
if(fastFilters && fastFilters.onlyArchived == 1){
query += ' AND note.archived = 1' //Show Archived
} else {
query += ' AND note.archived = 0' //Exclude archived
}
query += ` GROUP BY tag.id
ORDER BY usages DESC, text ASC`
db.promise()
.query(query, [userId])
.then( (rows, fields) => {
resolve(rows[0])
})
})
}
Tag.removeTagFromNote = (userId, tagId) => {
return new Promise((resolve, reject) => {
@@ -83,39 +122,50 @@ Tag.add = (tagText) => {
})
}
//
// Get all tags AND tags associated to note
//
Tag.get = (userId, noteId) => {
return new Promise((resolve, reject) => {
//Update last opened date of note
const now = Math.round((+new Date)/1000)
db.promise()
.query('UPDATE note SET opened = ? WHERE id = ? AND user_id = ? LIMIT 1', [now, noteId, userId])
.then((rows, fields) => {})
Tag.userTags(userId).then(userTags => {
db.promise()
.query(`SELECT tag_id as tagId, id as entryId
FROM note_tag
WHERE user_id = ? AND note_id = ?;`, [userId, noteId])
.then((rows, fields) => {
db.promise()
.query(`SELECT note_tag.id, tag.text FROM note_tag
JOIN tag ON (tag.id = note_tag.tag_id)
WHERE user_id = ? AND note_id = ?;`, [userId, noteId])
.then((rows, fields) => {
resolve(rows[0]) //Return all tags found by query
//pull IDs out of returned results
// let ids = rows[0].map( item => {})
resolve({'noteTagIds':rows[0], 'allTags':userTags }) //Return all tags found by query
})
.catch(console.log)
})
.catch(console.log)
})
}
//
// Get all tags for a note and concatinate into a string 'all, tags, like, this'
//
Tag.string = (userId, noteId) => {
return new Promise((resolve, reject) => {
Tag.get(userId, noteId).then(tagArray => {
db.promise()
.query(`SELECT GROUP_CONCAT(DISTINCT tag.text SEPARATOR ', ') as text
FROM note_tag
JOIN tag ON note_tag.tag_id = tag.id
WHERE user_id = ? AND note_id = ?;`, [userId, noteId])
.then((rows, fields) => {
let tagString = ''
tagArray.forEach( (tag, i) => {
if(i > 0){ tagString += ',' }
tagString += tag.text
})
//Output comma delimited list of tag strings
resolve(tagString)
let finalText = rows[0][0]['text']
if(finalText == null){
finalText = ''
}
return resolve(finalText) //Return all tags found by query
})
.catch(console.log)
})
}

View File

@@ -111,5 +111,58 @@ User.create = (username, password) => {
.catch(console.log)
})
}
//Counts notes, pinned notes, archived notes, shared notes, unread notes, total files and types
User.getCounts = (userId) => {
return new Promise((resolve, reject) => {
let countTotals = {}
db.promise().query(
`SELECT
SUM(pinned = 1 && archived = 0 && share_user_id IS NULL) AS pinnedNotes,
SUM(archived = 1 && share_user_id IS NULL) AS archivedNotes,
SUM(share_user_id IS NULL) AS totalNotes,
SUM(share_user_id != ?) AS sharedToNotes,
SUM( (share_user_id != ? && opened IS null) || (share_user_id != ? && updated > opened) ) AS unreadNotes
FROM note
WHERE user_id = ?`, [userId, userId, userId, userId])
.then( (rows, fields) => {
Object.assign(countTotals, rows[0][0]) //combine results
return db.promise().query(
`SELECT count(id) AS sharedFromNotes
FROM note WHERE share_user_id = ?`, [userId]
)
})
.then( (rows, fields) => {
Object.assign(countTotals, rows[0][0]) //combine results
return db.promise().query(
`SELECT
SUM(attachment_type = 1) as linkFiles,
SUM(attachment_type != 1) as otherFiles,
COUNT(id) as totalFiles
FROM attachment WHERE visible = 1
AND user_id = ?
`, [userId]
)
}).then( (rows, fields) => {
Object.assign(countTotals, rows[0][0]) //combine results
//Convert everything to an int or 0
Object.keys(countTotals).forEach( key => {
const count = parseInt(countTotals[key])
countTotals[key] = count ? count : 0
})
resolve(countTotals)
})
})
}

View File

@@ -0,0 +1,63 @@
let express = require('express')
var multer = require('multer')
var upload = multer({ dest: '../staticFiles/' }) //@TODO make this a global value
let router = express.Router()
let Attachment = require('@models/Attachment');
let Note = require('@models/Note')
let userId = null
// middleware that is specific to this router
router.use(function setUserId (req, res, next) {
if(userId = req.headers.userId){
userId = req.headers.userId
}
next()
})
router.post('/search', function (req, res) {
Attachment.search(userId, req.body.noteId, req.body.attachmentType)
.then( data => res.send(data) )
})
router.post('/textsearch', function (req, res) {
Attachment.textSearch(userId, req.body.searchTerm)
.then( data => res.send(data) )
})
router.post('/get', function (req, res) {
Attachment.forNote(userId, req.body.noteId)
.then( data => res.send(data) )
})
router.post('/update', function (req, res) {
Attachment.update(userId, req.body.attachmentId, req.body.updatedText, req.body.noteId)
.then( result => {
Note.reindex(userId, req.body.noteId)
.then( data => res.send(data) )
})
})
router.post('/delete', function (req, res) {
Attachment.delete(userId, req.body.attachmentId)
.then( data => res.send(data) )
})
router.post('/upload', upload.single('file'), function (req, res, next) {
//Create attachment with file information and node id
const noteId = parseInt(req.body.noteId)
Attachment.processUploadedFile(userId, noteId, req.file)
.then( uploadResults => {
//Reindex note, attachment may have had text
Note.reindex(userId, noteId)
.then( data => {res.send(uploadResults)})
})
})
module.exports = router

View File

@@ -2,20 +2,34 @@ var express = require('express')
var router = express.Router()
let Notes = require('@models/Note');
let ShareNote = require('@models/ShareNote');
let userId = null
let socket = null
// middleware that is specific to this router
router.use(function setUserId (req, res, next) {
if(userId = req.headers.userId){
if(req.headers.userId){
userId = req.headers.userId
}
if(req.headers.socket){
// socket = req.
}
next()
})
//
// Note actions
//
router.post('/get', function (req, res) {
// req.io.emit('welcome_homie', 'Welcome, dont poop from excitement')
Notes.get(userId, req.body.noteId)
.then( data => res.send(data) )
.then( data => {
//Join room when user opens note
// req.io.join('note_room')
res.send(data)
})
})
router.post('/delete', function (req, res) {
@@ -29,16 +43,62 @@ router.post('/create', function (req, res) {
})
router.post('/update', function (req, res) {
Notes.update(userId, req.body.noteId, req.body.text, req.body.fancyInput, req.body.color, req.body.pinned, req.body.archived)
Notes.update(req.io, userId, req.body.noteId, req.body.text, req.body.color, req.body.pinned, req.body.archived)
.then( id => res.send({id}) )
})
router.post('/search', function (req, res) {
Notes.search(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters)
.then( notesAndTags => res.send(notesAndTags))
.then( notesAndTags => {
res.send(notesAndTags)
})
})
router.post('/difftext', function (req, res) {
Notes.getDiffText(userId, req.body.noteId, req.body.text, req.body.updated)
.then( fullDiffText => {
//Response should be full diff text
res.send(fullDiffText)
})
})
//
// Share Note Actions
//
router.post('/getshareusers', function (req, res) {
ShareNote.getUsers(userId, req.body.rawTextId)
.then(results => res.send(results))
})
router.post('/shareadduser', function (req, res) {
ShareNote.addUser(userId, req.body.noteId, req.body.rawTextId, req.body.username)
.then( ({success, shareUserId}) => {
//Emit update count event to user shared with - so they see the note in real time
req.io.to(shareUserId).emit('update_counts')
res.send(success)
})
})
router.post('/shareremoveuser', function (req, res) {
ShareNote.removeUser(userId, req.body.noteId)
.then(results => res.send(results))
})
//
// Testing Action
//
//Reindex all notes. Not a very good function, not public
router.get('/reindex5yu43prchuj903mrc', function (req, res) {
Notes.migrateNoteTextToNewTable().then(status => {
return res.send(status)
})
})
module.exports = router

View File

@@ -0,0 +1,15 @@
var express = require('express')
var router = express.Router()
let Notes = require('@models/Note')
router.post('/note', function (req, res) {
Notes.getShared(req.body.noteId)
.then( data => res.send(data) )
})
module.exports = router

View File

@@ -0,0 +1,35 @@
var express = require('express')
var router = express.Router()
let QuickNote = require('@models/QuickNote');
let userId = null
// middleware that is specific to this router
router.use(function setUserId (req, res, next) {
if(userId = req.headers.userId){
userId = req.headers.userId
}
next()
})
//Get quick note text
router.post('/get', function (req, res) {
QuickNote.get(userId)
.then( data => res.send(data) )
})
//Push text to quick note
router.post('/update', function (req, res) {
QuickNote.update(userId, req.body.pushText)
.then( data => res.send(data) )
})
//Change quick note to a new note
router.post('/change', function (req, res) {
QuickNote.change(userId, req.body.noteId)
.then( data => res.send(data) )
})
module.exports = router

View File

@@ -42,4 +42,10 @@ router.post('/get', function (req, res) {
.then( data => res.send(data) )
})
//Get all the tags for this user in order of usage
router.post('/usertags', function (req, res) {
Tags.userTags(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters)
.then( data => res.send(data) )
})
module.exports = router

View File

@@ -46,4 +46,11 @@ router.post('/login', function (req, res) {
})
})
// fetch counts of users notes
router.post('/totals', function (req, res) {
User.getCounts(req.headers.userId)
.then( countsObject => res.send( countsObject ))
})
module.exports = router

View File

@@ -2,9 +2,9 @@
echo 'Make sure this is being run from root folder of project'
echo 'Starting API server (/api), watching for file changes...'
pm2 start server/index.js --watch
echo 'Starting Client webpack dev server (/app), in a screen, watching for file changes...'
screen -dm bash -c "cd client/; npm run watch"
echo 'Starting API server (/api), watching for file changes...'
cd server
pm2 start ecosystem.config.js

4
staticFiles/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*
*/
!.gitignore
!assets

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1 @@
<svg id="bac3cfc7-b61b-48ce-8441-8100e40ddaa6" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="797.5" height="834.5" viewBox="0 0 797.5 834.5"><title>void</title><ellipse cx="308.5" cy="780" rx="308.5" ry="54.5" fill="#3f3d56"/><circle cx="496" cy="301.5" r="301.5" fill="#3f3d56"/><circle cx="496" cy="301.5" r="248.89787" opacity="0.05"/><circle cx="496" cy="301.5" r="203.99362" opacity="0.05"/><circle cx="496" cy="301.5" r="146.25957" opacity="0.05"/><path d="M398.42029,361.23224s-23.70394,66.72221-13.16886,90.42615,27.21564,46.52995,27.21564,46.52995S406.3216,365.62186,398.42029,361.23224Z" transform="translate(-201.25 -32.75)" fill="#d0cde1"/><path d="M398.42029,361.23224s-23.70394,66.72221-13.16886,90.42615,27.21564,46.52995,27.21564,46.52995S406.3216,365.62186,398.42029,361.23224Z" transform="translate(-201.25 -32.75)" opacity="0.1"/><path d="M415.10084,515.74682s-1.75585,16.68055-2.63377,17.55847.87792,2.63377,0,5.26754-1.75585,6.14547,0,7.02339-9.65716,78.13521-9.65716,78.13521-28.09356,36.8728-16.68055,94.81576l3.51169,58.82089s27.21564,1.75585,27.21564-7.90132c0,0-1.75585-11.413-1.75585-16.68055s4.38962-5.26754,1.75585-7.90131-2.63377-4.38962-2.63377-4.38962,4.38961-3.51169,3.51169-4.38962,7.90131-63.2105,7.90131-63.2105,9.65716-9.65716,9.65716-14.92471v-5.26754s4.38962-11.413,4.38962-12.29093,23.70394-54.43127,23.70394-54.43127l9.65716,38.62864,10.53509,55.3092s5.26754,50.04165,15.80262,69.356c0,0,18.4364,63.21051,18.4364,61.45466s30.72733-6.14547,29.84941-14.04678-18.4364-118.5197-18.4364-118.5197L533.62054,513.991Z" transform="translate(-201.25 -32.75)" fill="#2f2e41"/><path d="M391.3969,772.97846s-23.70394,46.53-7.90131,48.2858,21.94809,1.75585,28.97148-5.26754c3.83968-3.83968,11.61528-8.99134,17.87566-12.87285a23.117,23.117,0,0,0,10.96893-21.98175c-.463-4.29531-2.06792-7.83444-6.01858-8.16366-10.53508-.87792-22.826-10.53508-22.826-10.53508Z" transform="translate(-201.25 -32.75)" fill="#2f2e41"/><path d="M522.20753,807.21748s-23.70394,46.53-7.90131,48.28581,21.94809,1.75584,28.97148-5.26754c3.83968-3.83969,11.61528-8.99134,17.87566-12.87285a23.117,23.117,0,0,0,10.96893-21.98175c-.463-4.29531-2.06792-7.83444-6.01857-8.16367-10.53509-.87792-22.826-10.53508-22.826-10.53508Z" transform="translate(-201.25 -32.75)" fill="#2f2e41"/><circle cx="295.90488" cy="215.43252" r="36.90462" fill="#ffb8b8"/><path d="M473.43048,260.30832S447.07,308.81154,444.9612,308.81154,492.41,324.62781,492.41,324.62781s13.70743-46.39439,15.81626-50.61206Z" transform="translate(-201.25 -32.75)" fill="#ffb8b8"/><path d="M513.86726,313.3854s-52.67543-28.97148-57.943-28.09356-61.45466,50.04166-60.57673,70.2339,7.90131,53.55335,7.90131,53.55335,2.63377,93.05991,7.90131,93.93783-.87792,16.68055.87793,16.68055,122.90931,0,123.78724-2.63377S513.86726,313.3854,513.86726,313.3854Z" transform="translate(-201.25 -32.75)" fill="#d0cde1"/><path d="M543.2777,521.89228s16.68055,50.91958,2.63377,49.16373-20.19224-43.89619-20.19224-43.89619Z" transform="translate(-201.25 -32.75)" fill="#ffb8b8"/><path d="M498.50359,310.31267s-32.48318,7.02339-27.21563,50.91957,14.9247,87.79237,14.9247,87.79237l32.48318,71.11182,3.51169,13.16886,23.70394-6.14547L528.353,425.32067s-6.14547-108.86253-14.04678-112.37423A33.99966,33.99966,0,0,0,498.50359,310.31267Z" transform="translate(-201.25 -32.75)" fill="#d0cde1"/><polygon points="277.5 414.958 317.885 486.947 283.86 411.09 277.5 414.958" opacity="0.1"/><path d="M533.896,237.31585l.122-2.82012,5.6101,1.39632a6.26971,6.26971,0,0,0-2.5138-4.61513l5.97581-.33413a64.47667,64.47667,0,0,0-43.1245-26.65136c-12.92583-1.87346-27.31837.83756-36.182,10.43045-4.29926,4.653-7.00067,10.57018-8.92232,16.60685-3.53926,11.11821-4.26038,24.3719,3.11964,33.40938,7.5006,9.18513,20.602,10.98439,32.40592,12.12114,4.15328.4,8.50581.77216,12.35457-.83928a29.721,29.721,0,0,0-1.6539-13.03688,8.68665,8.68665,0,0,1-.87879-4.15246c.5247-3.51164,5.20884-4.39635,8.72762-3.9219s7.74984,1.20031,10.062-1.49432c1.59261-1.85609,1.49867-4.559,1.70967-6.99575C521.28248,239.785,533.83587,238.70653,533.896,237.31585Z" transform="translate(-201.25 -32.75)" fill="#2f2e41"/><circle cx="559" cy="744.5" r="43" fill="#21ba45"/><circle cx="54" cy="729.5" r="43" fill="#21ba45"/><circle cx="54" cy="672.5" r="31" fill="#21ba45"/><circle cx="54" cy="624.5" r="22" fill="#21ba45"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB