Initial changes testing and running vue3

This commit is contained in:
Max committed 2026-10-27 12:50:34 -07:00
1 parent 68f71c9d6c
commit 9f37d3faf1
68 files changed
+23452 -12

No files matched your search

+4 -1
View File
@@ -8,7 +8,10 @@ const ports = {
}
//Allow user of @ in in require calls. Config in package.json
require('module-alias/register')
// Point module-alias at the repo root explicitly. The bare /register entry
// locates package.json by walking up from its own directory, which lands
// inside .pnpm/ under pnpm's symlinked store and fails to find it.
require('module-alias')(require('path').join(__dirname, '..'))
//Auth helper, used for decoding users web token
let Auth = require('@helpers/Auth')
+103 -9
View File
@@ -1,22 +1,116 @@
{ backupFile ? "" }:
with import <nixpkgs> { };
let
pythonPackages = python311Packages;
in pkgs.mkShell rec {
pkgs.mkShell {
name = "Solid Scribe Env";
buildInputs = [
# node for server
buildInputs = [
nodejs
#nodePackages.npm
nodemon
pnpm
pm2
mariadb
openssl
];
postShellHook = ''
echo("Welcome to solid scribe")
shellHook = ''
echo "Welcome to Solid Scribe"
alias npm=pnpm
'';
MYSQL_DATADIR="$PWD/.mysql/data"
MYSQL_SOCKET="$PWD/.mysql/mysql.sock"
MYSQL_PIDFILE="$PWD/.mysql/mysql.pid"
mkdir -p "$MYSQL_DATADIR"
# Initialize data directory on first run
if [ ! -d "$MYSQL_DATADIR/mysql" ]; then
echo "Initializing MySQL data directory (first run)..."
mysql_install_db \
--datadir="$MYSQL_DATADIR" \
--skip-test-db \
--auth-root-authentication-method=normal \
2>/dev/null
echo "Initialization complete."
fi
# Start mysqld if not already running
STARTED_MYSQL=0
if ! mysqladmin --socket="$MYSQL_SOCKET" ping &>/dev/null 2>&1; then
STARTED_MYSQL=1
echo "Starting MySQL..."
mysqld --no-defaults \
--datadir="$MYSQL_DATADIR" \
--socket="$MYSQL_SOCKET" \
--pid-file="$MYSQL_PIDFILE" \
--port=3306 \
--bind-address=127.0.0.1 \
--log-error="$PWD/.mysql/error.log" &
until mysqladmin --socket="$MYSQL_SOCKET" ping &>/dev/null 2>&1; do
sleep 0.5
done
echo "MySQL ready."
mysql -u root --socket="$MYSQL_SOCKET" \
-e "CREATE DATABASE IF NOT EXISTS application;" 2>/dev/null
fi
# Set app DB env vars — dotenv won't override these since they're already in process.env
export DB_HOST=127.0.0.1
export DB_USER=root
export DB_PASS=
# Stable dev JWT key, persisted across shell sessions
if [ -z "''${JSON_KEY:-}" ]; then
if [ -s "$PWD/.mysql/jwt_key" ]; then
export JSON_KEY=$(cat "$PWD/.mysql/jwt_key")
else
export JSON_KEY=$(openssl rand -hex 32)
echo "$JSON_KEY" > "$PWD/.mysql/jwt_key"
echo "Generated dev JSON_KEY (saved to .mysql/jwt_key)"
fi
fi
# Restore a SQL dump — handles both plain .sql and .gz files
restore-db() {
local file="''${1:-}"
if [ -z "$file" ]; then
echo "Usage: restore-db <backup.sql[.gz]>"
return 1
fi
echo "Restoring database from $file ..."
if [[ "$file" == *.gz ]]; then
gunzip -c "$file" | mysql -u root --socket="$MYSQL_SOCKET"
else
mysql -u root --socket="$MYSQL_SOCKET" < "$file"
fi
# The dump is --all-databases, so it includes the `mysql` schema and
# overwrites local root with production's credentials. In-memory grants
# are still the old ones until FLUSH, so fix root now while we can
# still authenticate.
mysql -u root --socket="$MYSQL_SOCKET" -e "
ALTER USER 'root'@'localhost' IDENTIFIED VIA mysql_native_password USING PASSWORD(''');
GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;"
echo "Restore complete."
}
export -f restore-db
# Auto-restore if --argstr backupFile was passed to nix-shell
if [ -n "${backupFile}" ]; then
restore-db "${backupFile}"
fi
if [ "$STARTED_MYSQL" = "1" ]; then
trap "echo 'Stopping MySQL...'; mysqladmin -u root --socket=$MYSQL_SOCKET shutdown 2>/dev/null" EXIT
else
echo "MySQL already running (started by another shell) -- leaving it up on exit."
fi
# Do not start the app here.
# This same shell.nix is used for hosting the app on dev
# use the startDevServerAndClient.sh script
'';
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Local dev: API server + v2client (Vue 3 / Vite).
# Run from inside nix-shell, which provides node/pnpm/pm2/mariadb,
# starts the local MySQL, and exports DB_* and JSON_KEY.
#
# Unlike startDevServerAndClient.sh (remote dev box, /home/mab/ss, Vue 2
# client) this is path-relative and points at v2client-vite-vue3.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$REPO"
if [ -z "${JSON_KEY:-}" ]; then
echo "JSON_KEY is not set -- run this from inside nix-shell." >&2
exit 1
fi
if ! mysqladmin --socket="$REPO/.mysql/mysql.sock" ping &>/dev/null; then
echo "Local MySQL is not running -- run this from inside nix-shell." >&2
exit 1
fi
echo '::--:: Starting API server (:3000) + socket.io (:3001) under pm2...'
cd "$REPO/server"
# `env -u name`: nix's mkShell exports its `name` attr ("Solid Scribe Env")
# as $name, and pm2 uses $name to override the app name from ecosystem.config.js.
pm2 delete NoteServer &>/dev/null || true
pm2 flush &>/dev/null || true
env -u name pm2 start ecosystem.config.js
cd "$REPO"
echo '::--:: Starting v2client dev server -> http://localhost:5173'
cd "$REPO/v2client-vite-vue3"
[ -d node_modules ] || pnpm install
screen -S "V2ClientScreen" -X quit &>/dev/null || true
screen -dmS "V2ClientScreen" bash -c "cd '$REPO/v2client-vite-vue3'; pnpm dev"
cat <<MSG
::--:: Up.
client http://localhost:5173 (proxies /api -> :3000, /socket -> :3001)
api http://localhost:3000
logs pm2 logs NoteServer
screen -r V2ClientScreen (detach: ctrl-a d)
stop pm2 delete NoteServer; screen -S V2ClientScreen -X quit
MSG
+3 -2
View File
@@ -1,4 +1,4 @@
#!/bin/bash
# # #!/bin/bash
#Syncs local data with sandbox
# v - Verbose
@@ -11,5 +11,6 @@
# e - Send Files via SSH
# z - Compress for speed
# h - Human Readable file sizes
# p - port
rsync -e 'ssh' --exclude-from=dontSync.txt -havzC --update mab@marvin.local:/home/mab/ss/ .
rsync -e 'ssh -p 13326' --exclude-from=dontSync.txt -havz --update mab@blarvin.local:/home/mab/ss/ .
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solid Scribe</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "v2client-vite-vue3",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.13",
"axios": "^1.1.3",
"diff-match-patch": "^1.0.5",
"fomantic-ui-css": "^2.9.0",
"mitt": "^3.0.1",
"socket.io-client": "^2.5.0",
"tailwindcss": "^4.1.13",
"vue": "^3.5.18",
"vue-router": "^4.0.0",
"vuedraggable": "^4.1.0",
"vuex": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.2"
}
}
+1666
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+306
View File
@@ -0,0 +1,306 @@
<template>
<div id="app" :class="{ 'night-mode':($store.getters.getIsNightMode == 2) }">
<!-- <div class="ui container" v-if="showFakeSite">
<div class="ui basic very padded segment">
<div class="ui inverted red segment">
<h1>WARNING - False site detected</h1>
<h2>The Domain for this website is not correct.</h2>
<h2>Only trust <a class="ui button" href="https://www.solidscribe.com">https://www.solidscribe.com</a></h2>
<h2>Do not any enter any personal information into this website.</h2>
<h2>You will be redirected to the correct domain in {{redirectSeconds}}</h2>
</div>
</div>
</div> -->
<div class="auth-block" v-if="requireAuth">
<div class="ui raised inverted segment">
<div class="ui centered header">
Authentication Required
</div>
<div class="ui small inverted centered header" v-if="$store.getters.getUsername">
<i class="green user outline icon"></i>
{{ $store.getters.getUsername }}
</div>
<div class="ui large form">
<div class="field">
<div class="ui small inverted header">Password</div>
<div class="ui input">
<input type="password" v-model="password" placeholder="Password">
</div>
</div>
<div class="field">
<div class="ui small inverted header">One Time Password</div>
<div class="ui input">
<input type="password" v-model="otp" placeholder="One Time Password">
</div>
</div>
<div class="ui fluid inverted black button">
<i class="unlock icon"></i>
Submit</div>
</div>
</div>
</div>
<global-site-menu v-if="!showFakeSite" />
<router-view v-if="!showFakeSite" />
<global-notification />
</div>
</template>
<script>
import axios from 'axios'
import GlobalSiteMenu from '@/components/GlobalSiteMenu.vue'
import GlobalNotification from '@/components/GlobalNotificationComponent.vue'
export default {
name: 'App',
components: {
'global-site-menu': GlobalSiteMenu,
'global-notification': GlobalNotification,
},
data: function(){
return {
showFakeSite:false, //Incorrect domain detection
redirectSeconds: 15,
fetchingInProgress: false, //Prevent start getting token while fetch is in progress
blockUntilNextRequest: false, //If token was just renewed, don't fetch more until next request
requireAuth: false,
password: '',
otp: '',
}
},
//Axios response interceptor
// - Gets new session tokens from server and uses them in app
beforeCreate: function(){
const root = 'https://www.solidscribe.com'
// const root = 'https://192.168.1.164'
const manifest = {
"theme_color":"#000",
"background_color": "#000",
"description": "Take Notes",
"display_override": ["fullscreen", "minimal-ui"],
"display": "standalone",
"icons": [
{
"src": root+"/api/static/assets/logo.png",
"sizes": "495x495",
"type": "image/png",
"purpose": "any"
},
{
"src": root+"/api/static/assets/maskable_icon.png",
"sizes": "826x826",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots" : [
{
"src": root+"/api/static/assets/lightmode.png",
"sizes": "1000x1080",
"type": "image/png"
},
{
"src": root+"/api/static/assets/darkmode.png",
"sizes": "1000x1081",
"type": "image/png"
}
],
"shortcuts" : [
{
"name": "Files & Links",
"url": " /attachments",
"description": "All Note files and links",
"icons": [{
"src": root+"/api/static/assets/icon1.png",
"sizes": "96x96",
"type": "image/png"
}]
},
{
"name": "Metric Track",
"url": "/metrictrack",
"description": "Metric Tracking",
"icons": [{
"src": root+"/api/static/assets/icon2.png",
"sizes": "96x96",
"type": "image/png"
}]
}
],
"name": "Solid Scribe",
"short_name": "Solid Scribe",
"author": "Max",
"scope": root,
"start_url": window.location.href
}
const link = document.createElement('link')
link.rel = 'manifest'
link.href = `data:application/json;base64,${btoa(JSON.stringify(manifest))}`
document.head.appendChild(link)
//Before all requests going out
axios.interceptors.request.use(
(config) => {
//Enable token fetching after another request is made
if(this.blockUntilNextRequest){
this.fetchingInProgress = false
this.blockUntilNextRequest = false
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Add a response interceptor, token can be renewed on every response
axios.interceptors.response.use(
(response) => {
if(typeof response.headers.remaininguses !== 'undefined'){
// console.log(response.headers.remaininguses)
//Look at remaining uses of token, if its less than five, request a new one
if(response.headers.remaininguses < 15 && !this.fetchingInProgress && !this.blockUntilNextRequest){
this.fetchingInProgress = true
const currentToken = localStorage.getItem('loginToken')
this.$io.emit('renew_session_token', currentToken)
}
}
return response
},
(error) => {
//Catch all authorization errors, log user out if we encounter one
if(error.response && error.response.status == 401){
this.$router.push('/')
this.$store.commit('destroyLoginToken')
this.$bus.$emit('notification', 'Error: You have been logged out.')
}
return Promise.reject(error)
}
)
//Puts token into state on page load
let token = localStorage.getItem('loginToken')
let username = localStorage.getItem('username')
//
if(token && token.length > 0){
//setup username display
this.$store.commit('setUsername', username)
//Set session token on every request if set
axios.defaults.headers.common['authorizationtoken'] = token
//Setup websockets into vue instance
const socket = this.$io
socket.on('connect', () => {
//Put user into personal event room for live note updates, etc
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 Main theme color
const accentColor = localStorage.getItem('main-accent')
if(accentColor){
document.documentElement.style.setProperty('--main-accent', accentColor)
}
//Set color theme based on local storage
const themeNumber = localStorage.getItem('nightMode')
if(themeNumber != null){
this.$store.commit('toggleNightMode', themeNumber)
}
},
mounted: function(){
// Vite does not shim node's `process` into the browser bundle the way
// webpack/vue-cli did; import.meta.env.DEV is the equivalent.
const isDev = import.meta.env.DEV
// if(window.location.hostname.toLowerCase().replace('www.','') != "solidscribe.com" && !isDev){
// this.showFakeSite = true
// setInterval(() => {
// this.redirectSeconds--
// if(this.redirectSeconds == 0){
// window.location.href = 'https://www.solidscribe.com'
// }
// }, 1000)
// }
//Update totals for entire app on event
this.$io.on('update_counts', () => {
console.log('Got event, update totals')
this.$store.dispatch('fetchAndUpdateUserTotals')
})
this.$io.on('recievend_new_token', newToken => {
// console.log('Got a new token')
axios.defaults.headers.common['authorizationtoken'] = newToken
localStorage.setItem('loginToken', newToken)
//Disable getting new tokens until next request
this.blockUntilNextRequest = true
})
//Track users active sessions
this.$io.on('update_active_user_count', countData => {
this.$store.commit('setActiveSessions', countData)
})
},
computed: {
loggedIn () {
//Map logged in from state
return this.$store.getters.getLoggedIn
}
},
methods: {
loginGateway() {
if(!this.loggedIn){
console.log('This user is not logged in')
this.$router.push({'path':'/login'})
return
}
},
logout() {
this.$router.push('/')
axios.post('/api/user/logout')
setTimeout(() => {
this.$store.commit('destroyLoginToken')
this.$bus.$emit('notification', 'Logged Out')
}, 200)
},
}
}
</script>
+12
View File
@@ -0,0 +1,12 @@
import mitt from 'mitt'
const emitter = mitt()
// Preserves the Vue 2 $on/$emit/$off API used throughout the codebase
const EventBus = {
$on: (...args) => emitter.on(...args),
$off: (...args) => emitter.off(...args),
$emit: (...args) => emitter.emit(...args),
}
export default EventBus
+68
View File
@@ -0,0 +1,68 @@
const helpers = {}
helpers.timeAgo = (time) => {
if(time == null){
time = Math.round(time/1000)
}
if(time.toString().length >= 13){
time = Math.round(time/1000)
}
const time_formats = [
[ 60, 'seconds', 1 ],
[ 120, '1 minute ago', '1 minute from now' ],
[ 3600, 'minutes', 60 ],
[ 7200, '1 hour ago', '1 hour from now' ],
[ 86400, 'hours', 3600 ],
[ 172800, 'yesterday', 'tomorrow' ],
[ 604800, 'days', 86400 ],
[ 1209600, 'last week', 'next week' ],
[ 2419200, 'weeks', 604800 ],
[ 4838400, 'last month', 'next month' ],
[ 29030400, 'months', 2419200 ],
[ 58060800, 'last year', 'next year' ],
[ 2903040000, 'years', 29030400 ],
[ 5806080000, 'last century', 'next century' ],
[ 58060800000, 'centuries', 2903040000 ]
]
//How many seconds ago was input event time?
let seconds = Math.round((+new Date)/1000) - time
let token = 'ago'
let list_choice = 1
if (seconds == 0) {
return 'Just now'
}
if (seconds < 0) {
seconds = Math.abs(seconds)
token = 'from now'
list_choice = 2
}
let i = 0
let format = null
while (format = time_formats[i++]){
if (seconds < format[0]) {
if (typeof format[2] == 'string') {
return format[list_choice]
} else {
return Math.floor(seconds / format[2]) + ' ' + format[1] + ' ' + token
}
}
}
return time
}
const plugin = {
install(app) {
app.config.globalProperties.$helpers = helpers
}
}
export default plugin
Binary file not shown.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,112 @@
<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>
@@ -0,0 +1,245 @@
<style type="text/css" scoped>
.attachment-display-card {
width: 100%;
padding: 10px;
display: inline-block;
border: 1px solid;
border-color: var(--border_color);
background-color: var(--small_element_bg_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: 75px;
}
.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"
onerror="
this.onerror=null;
this.src='/api/static/assets/marketing/void.svg';
this.classList.add('image-placeholder');
this.insertAdjacentText('afterend', 'Image not found');
"
: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"
v-on:focus="showSave = true"
></textarea>
<div v-if="showSave" class="ui green button">Save</div>
<!-- link -->
<a class="link" :href="linkUrl" target="_blank">{{linkText}}</a>
<!-- Buttons -->
<div v-if="item.note_id" class="ui small compact basic button" v-on:click="openNote">
<i class="file outline icon"></i>
Open Note
</div>
<div v-if="!item.note_id" class="ui small compact basic disabled button">
<i class="angle double up icon"></i>
Pushed from Web
</div>
<div v-if="item.note_id" 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,
showSave: false,
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()
})
},
updated: function(){
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)
}
})
.catch(error => { this.$bus.$emit('notification', 'Failed to delete attachment') })
},
saveIt(){
this.showSave = false
//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)
.catch(error => { this.$bus.$emit('notification', 'Failed to Save') })
},
}
}
</script>
@@ -0,0 +1,175 @@
<template>
<div>
<div class="ui grid">
<div class="ui sixteen wide column">
<div class="ui dividing header">
Reset Background Color and Icon
</div>
<div class="ui labeled basic icon button" v-on:click="clearStyles">
<i class="refresh icon"></i>
Reset
</div>
</div>
<div class="sixteen wide column rounded" :style="{ 'background-color':allStyles['noteBackground'], 'color':allStyles['noteText']}">
<div class="ui dividing header" :style="{ 'color':allStyles['noteText']}">
<i class="fill drip icon"></i>
Background Color
</div>
<div v-for="color in colors"
class="color-button"
:style="{ backgroundColor:color }"
v-on:click="chosenColor(color)"
></div>
</div>
<div class="sixteen wide column">
<div class="ui dividing header">
<span v-if="allStyles.noteIcon" >
<i :class="`large ${allStyles.noteIcon} icon`" :style="{ 'color':allStyles.iconColor }"></i>
</span>
Note Icon
</div>
<div v-for="icon in icons" class="icon-button" v-on:click="chosenIcon(icon)" >
<i :class="`large ${icon} icon`"></i>
</div>
</div>
<div class="sixteen wide column">
<div class="ui dividing header">
<span v-if="allStyles.noteIcon" >
<i :class="`large ${allStyles.noteIcon} icon`" :style="{ 'color':allStyles.iconColor }"></i>
</span>
Icon Color
</div>
<div v-for="color in getReducedColors()"
class="color-button"
:style="{ backgroundColor:color }"
v-on:click="chooseIconColor(color)"
>
</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: [null,
'rgb(67,67,67)','rgb(102,102,102)','rgb(153,153,153)','rgb(183,183,183)','rgb(204,204,204)','rgb(217,217,217)','rgb(239,239,239)','rgb(243,243,243)','rgb(255,255,255)','rgb(152,0,0)','rgb(255,0,0)','rgb(255,153,0)','rgb(255,255,0)','rgb(0,255,0)','rgb(0,255,255)','rgb(74,134,232)','rgb(0,0,255)','rgb(153,0,255)','rgb(255,0,255)','rgb(230,184,175)','rgb(244,204,204)','rgb(252,229,205)','rgb(255,242,204)','rgb(217,234,211)','rgb(208,224,227)','rgb(201,218,248)','rgb(207,226,243)','rgb(217,210,233)','rgb(234,209,220)','rgb(221,126,107)','rgb(234,153,153)','rgb(249,203,156)','rgb(255,229,153)','rgb(182,215,168)','rgb(162,196,201)','rgb(164,194,244)','rgb(159,197,232)','rgb(180,167,214)','rgb(213,166,189)','rgb(204,65,37)','rgb(224,102,102)','rgb(246,178,107)','rgb(255,217,102)','rgb(147,196,125)','rgb(118,165,175)','rgb(109,158,235)','rgb(111,168,220)','rgb(142,124,195)','rgb(194,123,160)','rgb(166,28,0)','rgb(204,0,0)','rgb(230,145,56)','rgb(241,194,50)','rgb(106,168,79)','rgb(69,129,142)','rgb(60,120,216)','rgb(61,133,198)','rgb(103,78,167)','rgb(166,77,121)','rgb(133,32,12)','rgb(153,0,0)','rgb(180,95,6)','rgb(191,144,0)','rgb(56,118,29)','rgb(19,79,92)','rgb(17,85,204)','rgb(11,83,148)','rgb(53,28,117)','rgb(116,27,71)','rgb(91,15,0)','rgb(102,0,0)','rgb(120,63,4)','rgb(127,96,0)','rgb(39,78,19)','rgb(12,52,61)','rgb(28,69,135)','rgb(7,55,99)','rgb(32,18,77)','rgb(76,17,48)'],
icons: ['cat','crow','dog','dove','dragon','feather','feather alternate','fish','frog','hippo','horse','horse head','kiwi bird','otter','paw','spider','video','headphones','motorcycle','truck','monster truck','campground','cloud sun','drumstick bite','football ball','fruit-apple','hiking','mountain','tractor','tree','wind','wine bottle','coffee','flask','glass cheers','glass martini','beer','toilet paper','gift','globe','hand holding heart','comment','graduation cap','hat cowboy','hat wizard','mitten','user tie','laptop code','microchip','shield alternate','mouse','plug','power off','satellite','hammer','wrench','bell','eye','marker','paperclip','atom','award','theater masks','music','grin alternate','grin tongue squint outline','laugh wink','fire','fire alternate','poop','sun','money bill alternate','piggy bank','heart outline','heartbeat','running','walking','bacon','bone','bread slice','candy cane','carrot','cheese','cloud meatball','cookie','egg','hamburger','hotdog','ice cream','lemon','lemon outline','pepper hot','pizza slice','seedling','stroopwafel','leaf','book dead','broom','cloud moon','ghost','mask','skull crossbones','certificate','check','check circle','joint','cannabis','bong','gem','futbol','brain','dna','hand spock','hand spock outline','meteor','moon','moon outline','robot','rocket','satellite dish','space shuttle','user astronaut','fingerprint','thumbs up','thumbs down']
}
},
watch:{
styleObject: function(updatedStyles){
this.allStyles = updatedStyles
}
},
mounted(){
this.allStyles = this.styleObject
},
methods: {
getReducedColors(){
let reduced = []
this.colors.forEach((color,i) => {
if(i < 20 || i > 69){
reduced.push(color)
}
})
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
if(inColor == null){
this.$emit('changeColor', this.allStyles)
return
}
//Automatically select note text color
//If you are using hex colors, use this
// 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;
const set = inColor.match(/\d+/g)
const r = parseInt(set[0])
const g = parseInt(set[1])
const b = parseInt(set[2])
//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, .color-button {
height: 40px;
width: calc(15% - 1px);
display: inline-block;
cursor: pointer;
font-size: 1.3em;
border: 1px solid grey;
text-align: center;
padding: 5px 0px 0 0;
border-radius: 4px;
box-shadow: 0px 1px 3px 0px #3e3e3e;
margin: 2px 2px 0 0;
box-sizing: border-box;
}
.color-button {
width: calc(10% - 4px);
}
.rounded {
border-radius: 5px;
}
</style>
@@ -0,0 +1,93 @@
<template>
<span v-on:mouseover="open = true" v-on:mouseleave="open = false" class="relative clickable filter-header" :class="{'filter-active':open}">
{{displayString()}} <i class="grey caret down icon"></i>
<span class="filter-menu" v-if="open">
<span v-for="(filter,label) in filterOptions" class="filter-option" v-on:click="updateFilter(filter, label)">{{label}}</span>
</span>
</span>
</template>
<script>
export default {
name: 'FastFilters',
data () {
return {
click: 0,
open: false,
filter: {},
orderString: 'Order by Last Edited',
filterOptions:{
'Order by Last Edited' :'lastEdited',
'Order by Last Opened' :'lastOpened',
'Order by Last Created' :'lastCreated',
}
}
},
beforeMount(){
// this.$bus.$on('reset_fast_filters', () => {
// this.orderString = 'Order by Last Edited'
// })
},
methods:{
displayString(){
return this.orderString.replace('Order by','').replace('Only Show','')
},
updateFilter(option, label){
this.orderString = label
this.open = false
let filter = {}
filter[option] = 1
// this.$bus.$emit('update_fast_filters', filter)
}
}
}
</script>
<style type="text/css" scoped>
.filter-header {
width: 200px;
padding: 0 0 0 10px;
border: 1px solid rgba(0,0,0,0);
border-bottom: none;
display: inline-block;
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(--small_element_bg_color);
border: 1px solid;
border-top: none;
position: absolute;
top: 100%;
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;
border-bottom: none;
border-color: var(--border_color);
}
.filter-option {
font-size: 17px;
width: 100%;
display: inline-block;
cursor: pointer;
padding: 0 0 5px 10px;
}
</style>
@@ -0,0 +1,94 @@
<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"/> -->
<i class="file upload icon"></i>
</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 { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
name: 'FileUploadButton',
props: [ 'noteId' ],
components: {
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
},
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
this.$bus.$emit('notification', 'Failed to Upload')
})
},
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>
@@ -0,0 +1,171 @@
<style type="text/css" scoped>
.popup-body {
position: fixed;
top: 15px;
left: 15px;
min-height: 50px;
min-width: 200px;
max-width: calc(100% - 30px);
z-index: 1020;
box-shadow: 0px 0px 5px 2px rgba(140,140,140,1);
border-radius: 4px;
color: white;
background-color: var(--main-accent);
}
.popup-row {
padding: 1em 5px;
cursor: pointer;
}
.popup-row > p {
/*width: calc(100% - 50px);*/
display: inline-block;
text-align: left;
box-sizing: border-box;
padding: 0 10px 0;
font-size: 1.25em;
border-radius: 4px;
}
.popup-row + .popup-row {
border-top: 1px solid #FFF;
}
.slide-in-bottom {
animation: slide-in-bottom 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
@keyframes slide-in-bottom {
0% {
transform: translateY(-1000px);
}
100% {
transform: translateY(0);
}
}
.color-fade {
animation: color-fade-animation 0.7s ease-out both;
}
@keyframes color-fade-animation {
0% {
color: black;
background-color: black;
}
50% {
color: black;
background-color: black;
}
100% {
color: white;
background-color: #21ba45;
}
}
.meter {
height: 2px;
display: inline-block;
width: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
overflow: hidden;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
}
.meter span {
display: block;
height: 100%;
}
.progress {
background-color: white;
animation: progressBar 3s linear;
animation-fill-mode: both;
}
.time-display {
display: inline-block;
width: calc(100% - 25px);
/*text-align: right;*/
color: white;
font-size: 0.7em;
margin: 0 0 0 25px;
}
.text-display {
display: inline-block;
width: 100%;
}
@keyframes progressBar {
0% { width: 0; }
100% { width: 100%; }
}
</style>
<template>
<div class="popup-body slide-in-bottom" v-on:click="dismiss" v-if="notifications.length > 0">
<div class="popup-row" v-for="item in notifications">
<div class="meter">
<span><span class="progress"></span></span>
</div>
<p class="text-display">
<i class="small info circle icon"></i>
{{ item.text }}
<span class="time-display">{{ item.time }}</span>
</p>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'GlobalNotificationComponent',
data () {
return {
notifications: [],
totalTimeout: null,
}
},
beforeMount(){
this.$bus.$on('notification', notificationText => {
this.displayNotification(notificationText)
})
},
mounted(){
// this.$bus.$emit('notification', 'Password Protection Removed Login did not succeed')
// this.$bus.$emit('notification', 'Password Protection Removed your life is exposed to the internet')
// this.$bus.$emit('notification', 'Password Protection Removed everyone can see everything')
},
methods: {
displayNotification(notificationText){
const date = new Date()
const time = date.toLocaleTimeString()
const notification = {
text: notificationText,
time: time
}
this.notifications.unshift(notification)
clearTimeout(this.totalTimeout)
this.totalTimeout = setTimeout(() => {
this.dismiss()
}, 3000)
},
dismiss(){
this.notifications = []
}
}
}
</script>
@@ -0,0 +1,541 @@
<style scoped>
.slotholder {
height: 100vh;
width: 180px;
display: block;
float: left;
overflow: hidden;
}
.global-menu {
width: 180px;
/* background: #221f2b; */
background: #221f2b;
margin: 0;
padding: 0;
box-sizing: border-box;
display: block;
position: fixed;
z-index: 900;
top: 0;
left: 0;
bottom: 0;
}
.menu-logo-display {
width: 27px;
margin: 5px 0 0 55px;
display: inline-block;
height: auto;
}
.menu-item {
color: #fff;
padding: 9px 10px;
display: inline-block;
width: 100%;
font-size: 1.1em;
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; */
border-top: 1px solid #534c68e3;
}
.menu-button {
cursor: pointer;
}
.menu-button:hover {
background-color: #534c68;
text-decoration: none;
}
.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: 899;
cursor: pointer;
}
.top-menu-bar {
/*color: var(--text_color);*/
/*width: 100%;*/
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
background-color: var(--small_element_bg_color);
/*padding: 5px 1rem 5px;*/
display: flex;
justify-content: space-around;
width: 100vw;
border-top: 1px solid var(--dark_border_color);
display: flex;
margin: 0;
padding: 0;
overflow: hidden;
}
.place-holder {
width: 100%;
/*height: 40px;*/
height: 0;
}
.logo-display {
width: 27px;
height: auto;
}
.version-display {
position: absolute;
bottom: 0;
left: -20px;
right: 0;
height: 30px;
padding: 5px 0;
text-align: center;
color: #8c80ae;
cursor: pointer;
background-color: var(--menu-background);
}
.mobile-button {
padding: 5px 0 0;
margin: 0;
cursor: pointer;
font-size: 0.6em;
color: var(--menu-text);
text-align: center;
flex-basis: 100%;
line-height: 1.8em;
}
.mobile-button + .mobile-button {
border-left: 1px solid var(--dark_border_color);
}
.mobile-button i {
font-size: 2em;
margin: 0 auto;
padding: 0;
width: 100%;
}
.mobile-button svg {
margin: 0 46% 0;
display: inline-block;
width: 15px;
}
.mobile-button:active, .mobile-button:focus, .mobile-button:hover {
text-decoration: none;
}
.mobile-button.active {
background-color: transparent;
}
.single-line-text {
width: calc(100%);
/*margin: 5px 10px;*/
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.faded {
color: var(--dark_border_color);
}
</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">
<!-- logo -->
<router-link v-if="loggedIn" class="mobile-button" exact-active-class="active" to="/notes" v-on:click.native="emitReloadEvent()">
<logo class="logo-display" color="var(--main-accent)" />
Notes
</router-link>
<!-- new note -->
<div v-if="loggedIn" class="mobile-button">
<span v-if="!disableNewNote" @click="createNote">
<i class="green plus icon"></i>
New Note
</span>
<span v-if="disableNewNote">
<i class="grey plus icon"></i>
Working
</span>
</div>
<!-- open straight to note -->
<router-link
v-if="loggedIn && $store.getters.totals && $store.getters.totals['quickNote']"
exact-active-class="active"
class="mobile-button"
:to="`/notes/open/${$store.getters.totals['quickNote']}`">
<i class="green sticky note outline icon"></i>
Scratch Pad
</router-link>
<!-- create new and redirect to new note id -->
<a
v-if="loggedIn && $store.getters.totals && !$store.getters.totals['quickNote']"
v-on:click="newQuickNote()"
exact-active-class="active"
class="mobile-button">
<i class="green sticky note outline icon"></i>
Scratch Pad
</a>
<router-link v-if="loggedIn" class="mobile-button" exact-active-class="active" to="/attachments">
<i class="green open folder outline icon"></i>
Files
</router-link>
<!-- menu -->
<div class="mobile-button" v-on:click="collapseMenu">
<i class="green link bars icon" ></i>
Menu
</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" v-on:click="collapseMenu">
<i class="white angle left icon"></i>
<logo class="menu-logo-display" color="var(--main-accent)" />
</div>
<div class="menu-section" v-if="loggedIn">
<div v-if="!disableNewNote" @click="createNote" class="menu-item menu-item menu-button">
<div class="ui green fluid compact button">
<i class="plus icon"></i>New Note
</div>
</div>
<div v-if="disableNewNote" class="menu-item menu-item menu-button">
<div class="ui basic fluid compact button">
<i class="plus loading icon"></i>New Note
</div>
</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 v-if="$store.getters.totals && $store.getters.totals['totalNotes']" class="float-right" number-id="totalNotes" />
</router-link>
<div>
<div class="menu-item menu-button sub" v-on:click="updateFastFilters(3)" v-if="$store.getters.totals && ($store.getters.totals['sharedToNotes'] > 0 || $store.getters.totals['sharedFromNotes'] > 0)">
<i class="grey paper plane outline icon"></i>Shared
<counter v-if="$store.getters.totals && $store.getters.totals['sharedToNotes']" class="float-right" number-id="sharedToNotes" />
</div>
<div class="menu-item menu-button sub" v-on:click="updateFastFilters(2)" v-if="$store.getters.totals && $store.getters.totals['archivedNotes'] > 0">
<i class="grey archive icon"></i>Archived
<counter v-if="$store.getters.totals && $store.getters.totals['archivedNotes']" class="float-right" number-id="archivedNotes" />
</div>
<div class="menu-item menu-button sub" v-on:click="updateFastFilters(4)" v-if="$store.getters.totals && $store.getters.totals['trashedNotes'] > 0">
<i class="grey trash alternate outline icon"></i>Trashed
<counter v-if="$store.getters.totals && $store.getters.totals['trashedNotes']" class="float-right" number-id="trashedNotes" />
</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="open folder outline icon"></i>Files
<counter class="float-right" number-id="totalFiles" />
</router-link>
</div>
<div class="menu-section" v-if="loggedIn">
<!-- open straight to note -->
<router-link
v-if="loggedIn && $store.getters.totals && $store.getters.totals['quickNote']"
exact-active-class="active"
class="menu-item menu-button"
:to="`/notes/open/${$store.getters.totals['quickNote']}`">
<i class="sticky note outline icon"></i>Scratch Pad
</router-link>
<!-- create new and redirect to new note id -->
<a
v-if="loggedIn && $store.getters.totals && !$store.getters.totals['quickNote']"
v-on:click="newQuickNote()"
exact-active-class="active"
class="menu-item menu-button">
<i class="sticky note outline icon"></i>Scratch Pad
</a>
</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 == 0">
<i class="moon outline icon"></i>Black Theme</span>
<span v-if="$store.getters.getIsNightMode == 1">
<i class="moon outline icon"></i>Light Theme</span>
</div>
</div>
<div class="menu-section" v-if="loggedIn">
<router-link class="menu-item menu-button" exact-active-class="active" to="/settings">
<i class="cog icon"></i>Settings
</router-link>
</div>
<div class="menu-section" v-if="loggedIn">
<router-link class="menu-item menu-button" exact-active-class="active" to="/metrictrack">
<i class="calendar check outlin icon"></i>Metric Track
</router-link>
</div>
<div class="menu-section">
<router-link class="menu-item menu-button" exact-active-class="active" to="/help">
<i class="question circle outline icon"></i>Help
</router-link>
</div>
<div class="menu-section" v-if="loggedIn">
<div class="menu-item menu-button" v-on:click="logout()">
<i class="log out icon"></i>Log Out
</div>
</div>
<!-- Tags -->
<div class="menu-section" v-if="gotTags()">
<div class="menu-item">
<i class="green tags icon"></i>
Tags
</div>
</div>
<div v-if="gotTags()">
<div class="menu-section"
v-for="(data, tag) in $store.getters.totals['tags']">
<router-link class="menu-item menu-button" :to="`/search/tags/${tag}`">
<span class="single-line-text">
<!-- <i class="small grey tag icon"></i> -->
<span class="float-right">{{ data.uses }}</span>
<span class="faded"> #</span> {{ tag }}</span>
</router-link>
</div>
</div>
<div v-on:click="reloadPage" class="version-display" v-if="version != 0" >
<i :class="`${getVersionIcon()} icon`"></i> {{ version }}
</div>
</div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
components: {
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
},
data: function(){
return {
version: '0',
username: '',
collapsed: false,
mobile: false,
disableNewNote: false,
menuOpen: true,
userIcon: true,
resizeDebounce: null,
}
},
beforeMount(){
window.addEventListener('resize', this.resizeEventHandler)
},
beforeUnmount(){
window.removeEventListener('resize', this.resizeEventHandler)
},
mounted: function(){
this.mobile = this.$store.getters.getIsUserOnMobile
this.collapsed = this.$store.getters.getIsUserOnMobile
if(this.mobile){
this.menuOpen = false
}
if(this.loggedIn){
this.$store.dispatch('fetchAndUpdateUserTotals')
this.version = localStorage.getItem('currentVersion')
}
this.resizeEventHandler() //Trigger resize event
},
computed: {
loggedIn () {
//Map logged in from state
return this.$store.getters.getLoggedIn
},
},
methods: {
gotTags(){
if(this.loggedIn && this.$store.getters.totals && this.$store.getters.totals.tags
&& Object.keys(this.$store.getters.totals.tags).length
){
return true
}
return false
},
logout() {
this.$router.push('/')
axios.post('/api/user/logout')
setTimeout(() => {
this.$store.commit('destroyLoginToken')
this.$bus.$emit('notification', 'Logged Out')
}, 200)
},
newQuickNote(){
axios.post('/api/quick-note/get')
.then( ({data}) => {
this.$router.push({'path':'/notes/open/'+data.noteId})
})
},
resizeEventHandler(e) {
clearTimeout(this.resizeDebounce)
this.resizeDebounce = setTimeout(() => {
this.mobile = false
this.menuOpen = false
this.collapsed = false
if(window.innerWidth < 700){
this.collapsed = true
this.mobile = true
}
}, 100)
},
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){
this.disableNewNote = true
axios.post('/api/note/create', {title:''})
.then(response => {
if(response.data && response.data.id){
//Push new note to url and it will open
this.$router.push('/notes/open/'+response.data.id)
this.disableNewNote = false
}
})
.catch(error => { this.$bus.$emit('notification', 'Failed to create note') })
},
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(filterIndex){
//A little hacky, brings user to notes page then filters on click
if(this.$route.name != 'Note Page'){
this.$router.push('/notes')
setTimeout( () => {
this.$bus.$emit('update_fast_filters', filterIndex)
}, 500 )
} else {
this.$bus.$emit('update_fast_filters', filterIndex)
}
},
reloadPage(){
location.reload(true)
},
getVersionIcon(){
if(!this.version){
return 'radiation alternate'
}
const icons = ['cat','crow','dog','dove','dragon','fish','frog','hippo','horse','kiwi bird','otter','spider', 'smile', 'robot', 'hat wizard', 'microchip', 'atom', 'grin tongue squint', 'radiation', 'ghost', 'dna', 'burn', 'brain', 'moon', 'torii gate']
const index = ( parseInt(String(this.version).replace(/\./g,'')) % (icons.length))
return icons[index]
}
}
}
</script>
@@ -0,0 +1,60 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa" target="_blank" rel="noopener">pwa</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
@@ -0,0 +1,55 @@
<template>
<div class="loading-container">
<svg version="1.1" id="L6" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve">
<rect fill="none" :stroke="$store.getters.getIsNightMode > 0 ? '#FFF':'var(--main-accent)'" stroke-width="4" x="25" y="25" width="50" height="50" rx="5">
<animateTransform
attributeName="transform"
dur="0.5s"
from="0 50 50"
to="180 50 50"
type="rotate"
id="strokeBox"
attributeType="XML"
begin="rectBox.end"/>
</rect>
<rect x="25" y="25" :fill="$store.getters.getIsNightMode > 0 ? '#FFF':'var(--main-accent)'" width="50" height="50">
<animate
attributeName="height"
dur="1.3s"
attributeType="XML"
from="50"
to="0"
id="rectBox"
fill="freeze"
begin="0s;strokeBox.end"/>
</rect>
</svg>
<div class="loading-message" v-if="message">{{ message }}</div>
</div>
</template>
<script>
export default {
name: 'LoadingIcon',
props:[ 'message' ],
}
</script>
<style type="text/css" scoped>
.loading-container {
text-align: center;
width: 100%;
/*min-height: 100px;*/
margin: 20px 0;
/*padding: 40px;*/
border-radius: 7px;
background-color: var(--small_element_bg_color);
}
.loading-container svg {
width: 60px;
height: 60px;
}
.loading-message {
font-size: 1.5em;
}
</style>
@@ -0,0 +1,241 @@
<template>
<div>
<!-- thicc form display -->
<div v-if="!thin" class="ui large form" v-on:keyup.enter="register">
<div class="field">
<div class="ui input">
<input ref="nameForm" v-model="username" type="text" name="email" placeholder="Username or E-mail">
</div>
</div>
<div class="field">
<div class="ui input">
<input v-model="password" type="password" name="password" placeholder="Password">
</div>
</div>
<div class="field">
<div class="ui input">
<input v-model="password2" type="password" name="password2" placeholder="Re-type Password">
</div>
</div>
<div class="field" v-if="require2FA">
<div class="ui input">
<input v-model="authToken" ref="authForm" type="text" name="authToken" placeholder="Authorization Token">
</div>
</div>
<div class="sixteen wide field">
<div class="ui fluid buttons">
<div v-on:click="register" class="ui green button" :class="{ 'disabled':(username.length == 0 || password.length == 0)}">
<i class="plug icon"></i>
Sign Up
</div>
</div>
</div>
<div class="sixteen wide column">
<span class="small-terms">
By signing up you agree to Solid Scribe's
<router-link to="/terms">
Terms of Use
</router-link>
</span>
</div>
</div>
<!-- Thin form display -->
<div v-if="thin" class="ui small form" v-on:keyup.enter="login">
<div v-if="!require2FA" class="field"><!-- hide this field if someone is logging in with 2FA -->
<div class="ui grid">
<div class="ui sixteen wide center aligned column">
<div v-on:click="register" class="ui green button">
<i class="plug icon"></i>
Sign Up Now!
</div>
</div>
</div>
</div>
<div class="field"><!-- hide this field if someone is logging in with 2FA -->
<div class="ui grid">
<div class="ui sixteen wide center aligned column">
Or Login
</div>
</div>
</div>
<div class="equal width fields">
<div class="field">
<div class="ui input">
<input ref="nameForm" v-model="username" type="text" name="email" placeholder="Username or E-mail">
</div>
</div>
<div class="field">
<div class="ui input">
<input v-model="password" type="password" name="password" placeholder="Password">
</div>
</div>
<div class="field" v-if="require2FA">
<div class="ui input">
<input v-model="authToken" ref="authForm" type="text" name="authToken" placeholder="Authorization Token">
</div>
</div>
<div class="field">
<div v-on:click="login" class="ui fluid button">
<i class="power icon"></i>
Login
</div>
</div>
</div>
<span class="small-terms">
By signing up you agree to Solid Scribe's
<router-link to="/terms">
Terms of Use
</router-link>
</span>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'Login',
props:[ 'thin' ],
mounted() {
//Focus on login form on desktop
if(!this.$store.getters.getIsUserOnMobile){
this.$refs.nameForm.focus()
}
},
data () {
return {
enabled: false,
username: '',
password: '',
password2: '',
authToken: '',
require2FA: false,
}
},
methods: {
finalizeLogin(data){
//Destroy local data if there is an error
if(data == false){
this.$store.commit('destroyLoginToken')
return
}
//Login user if we have a valid token
if(data && data.token && data.token.length > 0){
//Set username to local session
this.$store.commit('setUsername', this.username)
const token = data.token
//Setup socket io after user logs in
axios.defaults.headers.common['authorizationtoken'] = token
this.$io.emit('user_connect', token)
localStorage.setItem('loginToken', token)
//Redirect user to notes section after login
this.$router.push('/notes')
}
},
register(){
let error = false
if( this.username.length == 0 || this.password.length == 0 || this.password2.length == 0 ){
this.$bus.$emit('notification', 'All fields are required.')
error = true
}
if( this.password !== this.password2 ){
this.$bus.$emit('notification', 'Passwords must be identical.')
error = true
}
if(error){
//Login section
this.$router.push('/login')
return
}
axios.post('/api/public/register', {'username': this.username, 'password': this.password})
.then(({data}) => {
if(data == false){
this.$bus.$emit('notification', 'Unable to Sign Up - Username already in use')
}
this.finalizeLogin(data)
})
.catch(error => {
this.$bus.$emit('notification', 'Unable to Sign Up - Username already in use')
})
},
login(){
if( this.username.length == 0 || this.password.length == 0 ){
this.$bus.$emit('notification', 'Unable to Login - Username and Password Required')
return
}
axios.post('/api/public/login', {'username': this.username, 'password': this.password, 'authToken':this.authToken })
.then(({data}) => {
//Enable 2FA on form
if(data.success == false && data.verificationRequired == true && this.require2FA == false){
this.$bus.$emit('notification', data.message)
this.require2FA = true
this.$nextTick(() => {
this.$refs.authForm.focus()
})
return
}
if(data.success == false){
this.$bus.$emit('notification', data.message)
return
}
if(data.success){
this.finalizeLogin(data)
return
}
})
.catch(error => {
this.$bus.$emit('notification', error)
})
}
}
}
</script>
<style type="text/css" scoped="true">
.small-terms {
display: inline-block;
width: 100%;
font-size: 0.9em;
}
</style>
@@ -0,0 +1,88 @@
<template>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg8"
version="1.1"
viewBox="0 0 132.29166 132.29167"
height="500"
width="500">
<defs
id="defs2" />
<g
style="display:inline"
transform="translate(0,-164.70832)"
id="layer1">
<path
class="darken-accent"
id="path3813-4"
d="m 56.22733,165.36641 -55.56249926,15.875 8e-7,63.5 47.62499846,11.90625 v 27.78125 l -47.76066333,-13.9757 0.13566407,10.00695 55.56249926,15.875 v -47.625 l -47.6249985,-11.90625 -8e-7,-47.625 47.7606633,-13.94121 c 0.135664,-2.30629 -0.135664,-9.87129 -0.135664,-9.87129 z"
:style="`fill:${displayColor};fill-opacity:1;stroke:${strokeColor};stroke-width:${strokeWidth};stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1`" />
<path
class="brighten-accent"
id="path4563"
d="m 20.508581,220.92891 c 15.265814,-14.23899 27.809717,-7.68002 39.687499,3.96875 v -7.9375 C 51.75093,200.8366 37.512584,206.01499 20.508581,205.05391 Z"
:style="`fill:${displayColor};fill-opacity:1;stroke:${strokeColor};stroke-width:${strokeWidth}px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1`" />
<path
class="brighten-accent"
id="path4563-6"
d="m 111.78985,220.92891 c -15.265834,-14.23899 -27.809737,-7.68002 -39.68752,3.96875 v -7.9375 c 8.445151,-16.12356 22.683497,-10.94517 39.68752,-11.90625 z"
:style="`display:inline;fill:${displayColor};fill-opacity:1;stroke:${strokeColor};stroke-width:${strokeWidth}px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1`" />
<path
class="brighten-accent"
id="path3813-4-2"
d="m 76.07108,165.36641 55.5625,15.875 v 63.5 l -47.625,11.90625 v 27.78125 l 47.76067,-13.9757 -0.13567,10.00695 -55.5625,15.875 v -47.625 l 47.625,-11.90626 V 189.17891 L 75.93542,175.2377 c -0.13567,-2.30629 0.13566,-9.87129 0.13566,-9.87129 z"
:style="`display:inline;fill:${displayColor};fill-opacity:1;stroke:${strokeColor};stroke-width:${strokeWidth};stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1`" />
</g>
</svg>
</template>
<script>
export default {
name: 'LoadingIcon',
props:[
'color', // hex value for setting colorr
'stroke' // enable or disable stroke
],
data(){
return {
displayColor: '#21BA45', //Default green color
strokeWidth: '0.5',
strokeColor: 'none',
}
},
beforeCreate(){
},
created(){
if(this.stroke){
this.strokeWidth = 0.4
this.strokeColor = 'rgba(0,0,0,0.9)'
}
//Set color if passed
if(this.color){
this.displayColor = this.color
}
},
}
</script>
<style type="text/css" scoped>
.darken-accent {
filter: brightness(62%);
-webkit-filter: brightness(62%);
}
.brighten-accent {
filter: saturate(145%);
-webkit-filter: saturate(145%);
}
g > path {
filter: drop-shadow(1px 1px 1px black);
}
</style>
@@ -0,0 +1,433 @@
<style type="text/css" scoped>
.an-graph {
background: #fefefe;
}
.inactive.segment {
}
.active.segment {
outline: 4px solid cyan;
outline-offset: -5px;
outline-style: dashed;
max-height: 2000px;
}
.not-padded {
margin-left: -5px;
margin-right: -5px;
margin-bottom: -10px;
padding-right: 5px;
padding-left: 5px;
}
.sticky-boy {
position: fixed;
top: -1px;
right: 10px;
z-index: 100;
width: 70%;
background: orange;
}
.animate-height {
transition: max-height 0.8s linear;
max-height: 450px;
overflow: hidden;
}
</style>
<template>
<div>
<div class="ui very compact grid" :class="{'sticky-boy':editGraphs}">
<div class="sixteen wide column" v-if="!editGraphs">
<div class="ui basic padded segment">
<!-- Just a space to keep things clickable -->
</div>
</div>
<div class="sixteen wide column">
<dix class="ui basic segment" v-if="!editGraphs">
<div class="ui button" v-on:click="toggleEditGraphs">
<i class="edit icon"></i>
<span>Add/Edit Graphs</span>
</div>
</dix>
<div v-if="editGraphs">
<div class="ui green button" v-on:click="addGraph()">
<i class="plus icon"></i>
New Graph
</div>
<div class="ui basic button" v-on:click="toggleEditGraphs">
<i class="check circle icon"></i>
Done Editing Graphs
</div>
</div>
</div>
</div>
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment animate-height`">
<!-- Edit options -->
<div class="ui small header" v-if="editGraphs">
<div class="ui grid">
<div class="eight wide column">
<b>Graph #{{ index+1 }}</b>
</div>
<div class="eight wide right aligned column">
<span class="ui tiny compact inverted red button" v-on:click="removeGraph(index)">
Remove Graph
<i class="close icon"></i>
</span>
</div>
</div>
</div>
<h3 class="ui center aligned dividing header">
{{ getGraphTitle(graph) }}
</h3>
<div v-if="graph?.type == PILL_CALENDAR">
<PillCalendarGraph
:graph="graph"
:tempChartDays="tempChartDays"
:userFields="userFields"
:cycleData="cycleData"
:edit-graphs="editGraphs"
:showZeroValues="graph?.options?.showZeroValues"
:showTextValues="graph?.options?.showTextValues"
:connectDays="graph?.options?.connectDays"
:hideValues="graph?.options?.hideValues"
:hideIcons="graph?.options?.hideIcons"
/>
<div v-if="editGraphs" class="ui segment">
<p>Calendar Graph Toggles</p>
<div v-on:click="toggelValue(index, 'hideIcons')"class="ui button">
<span v-if="graph?.options?.hideIcons">Show</span><span v-else>Hide</span> Icons
</div>
<div v-on:click="toggelValue(index, 'hideValues')"class="ui button">
<span v-if="graph?.options?.hideValues">Show</span><span v-else>Hide</span> Values
</div>
<div v-on:click="toggelValue(index, 'showZeroValues')"class="ui button">
<span v-if="!graph?.options?.showZeroValues">Show</span><span v-else>Hide</span> Lowest Value
</div>
<div v-on:click="toggelValue(index, 'showTextValues')"class="ui button">
<span v-if="!graph?.options?.showTextValues">Show</span><span v-else>Hide</span> Text Value
</div>
<div v-on:click="toggelValue(index, 'connectDays')"class="ui button">
<span v-if="!graph?.options?.connectDays">Connect</span><span v-else>Disconnect</span> Days
</div>
</div>
</div>
<div v-if="graph?.type == LAST_DONE">
Last done not implemented
</div>
<div v-if="!graph.fieldIds || graph.fieldIds && graph.fieldIds.length == 0">
<h5>Blank Graph</h5>
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
Select Graph type and Metrics to display
</div>
<div v-if="graph?.type == undefined && graph.fieldIds && graph.fieldIds.length > 0">
<div :id="`graphdiv${index}`" style="width: 100%; min-height: 320px;"></div>
</div>
<div class="ui segment" v-if="editGraphs">
<!-- change graph type -->
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':(String(graphId) == String(graph?.type))}">
{{ graphType }}
</div>
</div>
<div v-for="fieldId in fields">
<span v-if="graph.fieldIds && graph.fieldIds.includes(fieldId)" v-on:click="toggleGraphField(fieldId, index)">
<i class="green check square icon"></i>
</span>
<span v-else v-on:click="toggleGraphField(fieldId, index)">
<i class="square outline icon"></i>
</span>
<i :class="`${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i>
<b>{{ userFields[fieldId]?.label }}</b>
</div>
</div>
</div>
<div class="ui very compact grid" :class="{'sticky-boy':editGraphs}">
<div class="sixteen wide column" v-if="!editGraphs">
<div class="ui basic padded segment">
<!-- Just a space to keep things clickable -->
</div>
</div>
<div class="sixteen wide column">
<dix class="ui basic segment" v-if="!editGraphs">
<div class="ui button" v-on:click="toggleEditGraphs">
<i class="edit icon"></i>
<span>Add/Edit Graphs</span>
</div>
</dix>
<div v-if="editGraphs">
<div class="ui green button" v-on:click="addGraph()">
<i class="plus icon"></i>
New Graph
</div>
<div class="ui basic button" v-on:click="toggleEditGraphs">
<i class="check circle icon"></i>
Done Editing Graphs
</div>
</div>
</div>
</div>
<!-- Anchor for scrolling to the bottom of graphs -->
<div ref="anchor"></div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
const PILL_CALENDAR = 'pillCalendar'
const LAST_DONE = 'lastDone'
export default {
inject: ['getFieldColor', 'getFieldIcon'],
name: 'MetricTrackingGraphs',
props: [
'tempChartDays', // Number of days to display
'fields', // field IDs for display/order
'userFields', // field values defined by user
'graphs', // Graph data defined by user
'cycleData', // ALL user data
'calendar', // Date data for currently open day
'editGraphs' // boolean for edit or not edit graphs
],
components: {
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
},
data: function(){
return {
graphTypesDef:{
// [LAST_DONE]: 'Last Done',
'undefined':'Line Graph (Default)',
[PILL_CALENDAR]:'Calendar Graph',
},
localGraphData:[],
}
},
beforeCreate() {
// Constants
this.PILL_CALENDAR = PILL_CALENDAR
this.LAST_DONE = LAST_DONE
// Include JS libraries
let graphsScript = document.createElement('script')
graphsScript.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.js')
document.head.appendChild(graphsScript)
},
mounted(){
this.localGraphData = this.graphs
this.graphCurrentData()
},
updated(){
// update graphs here? Or watch graphs prop
},
watch: {
// whenever question changes, this function will run
userFields(newFields, oldFields) {
// console.log([newFields, oldFields])
if( JSON.stringify(oldFields) == "{}" ){
this.graphCurrentData()
}
},
tempChartDays(newDays, oldDays){
if( newDays != oldDays ){
this.graphCurrentData()
}
},
},
methods: {
saveGraphs(){
this.$emit('saveGraphs', this.localGraphData)
},
toggleEditGraphs(){
setTimeout(() => {
// scroll last graph into view
this.$refs.anchor.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
})
}, 800)
this.$emit('toggleEditGraphs')
},
changeGraphType(index, newType){
console.log(index + ' change to ' + newType)
this.localGraphData[index]['type'] = newType
this.saveGraphs()
},
addGraph(){
this.localGraphData.push({})
this.saveGraphs()
},
removeGraph(index){
this.localGraphData.splice(index, 1)
this.saveGraphs()
},
toggelValue(graphIndex, optionName){
if(!this.localGraphData[graphIndex].options){
this.localGraphData[graphIndex].options = {}
}
if(this.localGraphData[graphIndex].options[optionName]){
this.localGraphData[graphIndex].options[optionName] = false
}
else {
this.localGraphData[graphIndex].options[optionName] = true
}
console.log(this.localGraphData[graphIndex].options[optionName])
this.saveGraphs()
},
toggleGraphField(fieldId, graphIndex){
if(!Array.isArray(this.localGraphData[graphIndex].fieldIds)){
this.localGraphData[graphIndex].fieldIds = []
}
const inSetCheck = this.localGraphData[graphIndex]?.fieldIds.indexOf(fieldId)
if(inSetCheck == -1){
this.localGraphData[graphIndex]?.fieldIds.push(fieldId)
}
if(inSetCheck > -1){
this.localGraphData[graphIndex]?.fieldIds.splice(inSetCheck,1)
}
this.saveGraphs()
},
getGraphTitle(graph){
const graphFields = graph?.fieldIds || []
let fieldTitles = []
graphFields.forEach(fieldId => {
fieldTitles.push(this.userFields[fieldId]?.label)
})
// console.log(fieldTitles)
const title = fieldTitles.join(', ')
return title
},
graphCurrentData(){
// try again if dygraphs isn't loaded
if( typeof(window.Dygraph) != 'function' ){
setTimeout(() => {
this.graphCurrentData()
}, 100)
return
}
const graphOptions = {
interactionModel: {},
// pointClickCallback: function(e, pt){
// console.log(e)
// console.log(pt)
// console.log(this.getValue(pt.idx, 0))
// }
}
// Excel date format YYYYMMDD
const convertToExcelDate = (dateCode) => {
return dateCode
.split('.')
.reverse()
.map(item => String(item).padStart(2,0))
.join('')
}
// Generate set of keys for graph length
let dataKeys = Object.keys(this.cycleData)
dataKeys = dataKeys.splice(0, this.tempChartDays)
console.log(dataKeys)
// build CSV data for each graph
this.graphs.forEach((graph,index) => {
// only chart line graphs with dygraphs
if( graph.type != undefined ){
return
}
if( !graph.fieldIds ){
return
}
// CSV or path to a CSV file.
let dataString = ""
// Lookup graph field titles
let graphLabels = ['Date']
graph.fieldIds.forEach(fieldId => {
const graphLabel = this.userFields[fieldId]?.label
const escapedLabel = graphLabel.replaceAll(',','')
graphLabels.push(escapedLabel)
})
dataString += graphLabels.join(',') + '\n'
// build each row, for each day
for (var i = 0; i < dataKeys.length; i++) {
let nextFragment = []
// push date code to first column
nextFragment.push(convertToExcelDate(dataKeys[i]))
graph.fieldIds.forEach(fieldId => {
const currentEntry = this.cycleData[dataKeys[i]]
let currentValue = currentEntry[fieldId]
// setup correct float graphing
if(fieldId == 'BT'){
// parse temp to fixed length float 00.00
currentValue = parseFloat(currentValue).toFixed(2)
}
if( currentValue == undefined ){
currentValue = -1
}
nextFragment.push(currentValue)
})
dataString += nextFragment.join(',') + "\n"
}
let graphDiv = document.getElementById("graphdiv"+index)
const g = new Dygraph(graphDiv, dataString ,graphOptions)
})
return
},
}
}
</script>
@@ -0,0 +1,552 @@
<style type="text/css" scoped>
div.calendar {
width: calc(100% - 4px);
min-height: 350px;
display: flex;
margin: 5px 8px 15px;
flex-wrap: wrap;
flex-direction: row;
justify-content: flex-start;
}
.day {
flex: 0 0 calc(14.28% - 2px);
min-height: 50px;
border: 1px solid var(--border_color);
font-size: 1.2em;
overflow: hidden;
box-sizing: border-box;
position: relative;
line-height: 1em;
display: flex;
align-items: flex-end;
}
.today {
font-weight: bold;
text-decoration: underline;
}
.active-entry {
outline: #07f4f4;
outline-style: none;
outline-width: medium;
outline-style: none;
outline-offset: -1px;
outline-style: solid;
outline-width: 3px;
}
.day ~ .has-data {
}
.day ~ .no-data {
background: #c7c7c787;
opacity: 0.6;
}
.day > .number {
position: absolute;
top: 0;
right: 5px;
z-index: 10;
opacity: 0.4;
}
.day > .sex {
font-size: 0.7em;
border-radius: 5px;
background: rgba(249, 0, 0, 0.15);
color: white;
padding: 0 0 0 4px;
z-index: 10;
position: absolute;
left: 0;
height: 26px;
}
.day > .period {
position: absolute;
bottom: 1px;
left: 1px;
right: 1px;
height: 5px;
background: red;
z-index: 10;
}
.day > .mucus {
position: absolute;
bottom: 0;
left: 0;
right: 0;
min-height: 10px;
background: #abecff7d;
z-index: 2;
}
.day > .notes {
}
.pill-container {
width: 100%;
}
.pill {
width: calc(100% - 8px);
min-height: 2px;
margin: 0 4px;
box-sizing: border-box;
display: inline-block;
background: rgb(50 218 255 / 44%);
border-radius: 40px;
text-align: center;
line-height: 1em;
position: relative;
color: white;
font-size: 0.7em;
padding: 2px;
overflow: hidden;
white-space: nowrap;
}
.pill.did-last {
margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
width: calc(100% - 5px);
}
.pill.did-next {
margin-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
width: calc(100% - 5px);
}
.pill.did-next.did-last {
width: 100%;
}
/* .last-high:after {
content: '';
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 3px solid transparent;
border-left: 10px solid rgb(50 218 255 / 44%);
position: absolute;
left: 0;
top: -13px;
}
.next-high:before {
content: '';
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 3px solid transparent;
border-right: 10px solid rgb(50 218 255 / 44%);
position: absolute;
right: 0;
top: -13px;
}*/
.big-day {
display: inline-block;
width: 100%;
min-height: 2px;
margin: 0 auto;
text-align: center;
}
.zero-day {
opacity: 0.5;
}
.icon-spacer {
display: inline-block;
background-color: greenyellow;
width: 20px;
height: 2px;
}
.past-entries {
width: 100%;
display: flex;
justify-content: space-around;
/* padding: 0 10px;*/
overflow-x: scroll;
overflow-y: hidden;
}
.past-entry {
position: relative;
text-align: center;
border: 1px solid;
border-color: var(--dark_border_color);
color: var(--text_color);
flex-grow: 1;
cursor: pointer;
font-weight: bold;
min-width: 40px;
min-height: 40px;
margin: 5px 0 10px;
line-height: 2.3em;
}
.day-list {
width: 100%;
height: 80px;
background-color: green;
display: flex;
justify-content: space-around;
overflow-x: scroll;
overflow-y: hidden;
}
.day-list-item {
flex-grow: 1;
border: 1px solid black;
width: 25px;
}
.pill.red { background-color: #db2828 }
.pill.orange { background-color: #f2711c }
.pill.yellow { background-color: #fbbd08 }
.pill.olive { background-color: #b5cc18 }
.pill.green { background-color: #21ba45 }
.pill.teal { background-color: #00b5ad }
.pill.blue { background-color: #2185d0 }
.pill.violet { background-color: #6435c9 }
.pill.purple { background-color: #a333c8 }
.pill.pink { background-color: #e03997 }
.pill.brown { background-color: #a5673f }
.pill.grey { background-color: #767676 }
.pill.black { background-color: #1b1c1d }
</style>
<template>
<div>
<div class="calendar">
<div v-for="day in calendar.weekdays" class="day">
{{ day }}
</div>
<div v-for="day in calendar.days" class="day"
:class="{
'today':day == calendar.today,
'active-entry':calendar.dateCode == `${day}.${calendar.month}.${calendar.year}`,
'has-data':cycleData[`${day}.${calendar.month}.${calendar.year}`],
'no-data':showDayDataColor(day),
}">
<!-- v-on:click="openDayData(`${day}.${calendar.month}.${calendar.year}`)" -->
<span class="number">{{ day }}</span>
<!-- {{ `${day}.${calendar.month}.${calendar.year}` }} -->
<template v-for="(entry, dateCode) in getChartData" :key="dateCode">
<span class="pill-container" v-if="dateCode == `${day}.${calendar.month}.${calendar.year}`">
<template v-for="(dayData, fieldId) in entry" :key="fieldId">
<span
v-if="showZeroValuesCheck(dayData.value, fieldId)"
class="pill"
:class="[getFieldColor(fieldId), {
'did-next':dayData.didNext,
'did-last':dayData.didLast,
'last-high':dayData.lastHigh,
'next-high':dayData.nextHigh,
}]">
<!-- 'zero-day':dayData.value == lowestGraphValue, -->
<!-- <i v-if="dayData.value != 0" :class="`tiny ${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i> -->
<!-- <span v-else class="icon-spacer"></span>
:style="{height:(Math.round(dayData.value*5)+'px')}"
-->
<span v-if="dayData.value > lowestGraphValue-1" class="big-day">
<i v-if="!hideIcons" :class="`tiny white ${getFieldIcon(fieldId)} icon`"></i>
<span v-if="!hideValues">
{{ getDayValue(fieldId, dayData.value) }}
</span>
</span>
</span>
</template>
</span>
</template>
<!-- <span v-for="fieldId in graph.fieldIds"></span> -->
</div>
</div>
</div>
</template>
<script>
// let chartData = {}
export default {
inject: ['getFieldColor', 'getFieldIcon'],
props: [
'graph', // options associated with this graph
'userFields', // all field attributes
'tempChartDays', // number of days to display
'cycleData', // all users metric data
'editGraphs', // display additional edit options
// Graph options
'showZeroValues', // Hide graph data with value of zero
'showTextValues', // Show button text or button value
'connectDays', // Calculates next and previous day connections.
'hideValues', // Hide all values on the graph
'hideIcons', // option to hide icons
],
data: function(){
return {
openModel:true,
calendar: {
dateObject: null,
dateCode: null,
monthName: '',
dayName:'',
daysAgo:0,
month: '',
year: '',
days: [],
weekdays: ['S','M','T','W','T','F','S'],
today: 0,
},
chartDateCodes: [], // array of date codes in chart
listDateCodes: [],
dayList: true,
lowestGraphValue: 0,
}
},
mounted(){
this.setupCalendar(new Date())
},
computed: {
getChartData(){
let chartData = {}
let chartValues = []
// iterate every day in month by day code
this.chartDateCodes.forEach((chartDayCode, codeIndex) => {
// lookup data for that day
const cycleDayData = this.cycleData[chartDayCode]
// if chart data is set for this day
if( cycleDayData && Object.keys(cycleDayData).length > 0){
chartData[chartDayCode] = {}
// go over each field to be displayed on graph
this.graph.fieldIds.forEach((graphFieldId) => {
if( cycleDayData[graphFieldId] == undefined ){
return
}
// track all chart values
chartValues.push(cycleDayData[graphFieldId])
chartData[chartDayCode][graphFieldId] = {
didLast: false,
lastHigh: false,
didNext: false,
nextHigh: false,
value: cycleDayData[graphFieldId]
}
})
}
})
this.lowestGraphValue = Math.min(...chartValues)
// determine next and previous states for display
this.chartDateCodes.forEach((chartDayCode, codeIndex) => {
if(chartData[chartDayCode] && this.connectDays){
const previousDateCode = this.chartDateCodes[codeIndex-1]
const nextDateCode = this.chartDateCodes[codeIndex+1]
Object.keys(chartData[chartDayCode]).forEach((graphFieldId) => {
const currentValue = chartData[chartDayCode][graphFieldId].value
// check for previous entry
if( chartData[previousDateCode] && chartData[previousDateCode][graphFieldId] ){
chartData[chartDayCode][graphFieldId].didLast = true
// set low value flag
const lastHigh = chartData[previousDateCode][graphFieldId].value > 0
chartData[chartDayCode][graphFieldId].lastHigh = lastHigh && currentValue == 0
}
// check for next entry
if( chartData[nextDateCode] && chartData[nextDateCode][graphFieldId] ){
chartData[chartDayCode][graphFieldId].didNext = true
// set low value flag
const nextHigh = chartData[nextDateCode][graphFieldId].value > 0
chartData[chartDayCode][graphFieldId].nextHigh = nextHigh && currentValue == 0
}
})
}
})
// console.log(chartData)
return chartData
},
},
methods: {
showZeroValuesCheck(dayValue, fieldId){
// if graph type is boolean or there are two options
let isBooleanField = this.userFields[fieldId].type == 'boolean'
if(this.userFields[fieldId].customOptions){
let options = this.userFields[fieldId].customOptions
isBooleanField = options.split(',').length == 2
}
if(isBooleanField && !this.showZeroValues){
const parsedValue = this.getDayValue(fieldId, dayValue)
if(parsedValue == 'Yes'){
return true
} else {
return false
}
}
return this.showZeroValues || dayValue > this.lowestGraphValue
},
getDayValue(fieldId, value){
if( !this.showTextValues ){
return value
}
let options = 'error, Yes, No'
if(this.userFields[fieldId].customOptions){
options = this.userFields[fieldId].customOptions
}
const values = options.split(',')
const selection = String(values[value]).trim()
return selection
},
displayDayFromCode(dateCode){
const parts = dateCode.split('.')
return `${parts[0]}`
},
showDayDataColor(day){
// Determine if day has any data set
if(day == ''){
return false
}
return !(this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`])
},
generateDateCode(date){
const dateSetup = [
date.getDate(), // 1-31 (Day)
date.getMonth()+1, // 0-11 (Month)
date.getFullYear(), // 1888-2022 (Year)
]
return dateSetup.join('.')
},
setupCalendar(date){
// visualize each day change
this.working = true
setTimeout(() => {
this.working = false
}, 500)
if(!date && this.dateObject){
date = this.dateObject
}
if(!date){
date = new Date()
}
this.calendar.dateObject = date
this.calendar.dateCode = this.generateDateCode(date)
// calculate days ago since current date
const now = new Date()
const diffSeconds = Math.floor((now - date) / 1000) // subtract unix timestamps, convert MS to S
const dayInterval = diffSeconds / 86400 // seconds in a day
this.calendar.daysAgo = Math.floor(dayInterval)
// ------------
// setup calendar display
var y = date.getFullYear()
var m = date.getMonth()
var firstDay = new Date(y, m, 1);
var lastDay = new Date(y, m + 1, 0);
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
const currentYear = date.getFullYear();
const currentMonth = date.getMonth() + 1;
this.calendar.monthName = date.toLocaleString("en-US", { month: "long" });
this.calendar.dayName = date.toLocaleString("en-US", { weekday: "long" });
this.calendar.year = currentYear
const daysInCurrentMonth = getDaysInMonth(currentYear, currentMonth);
const monthStartDay = firstDay.getDay()
let days = Array(monthStartDay).fill(""); // Pad days to start on correct weekday
for (let i = 0; i < daysInCurrentMonth; i++) {
days.push(i+1)
}
this.calendar.days = days
// set today
this.calendar.today = date.getDate()
this.calendar.month = date.getMonth()+1
// setup date codes for key matching on calendar
this.calendar.days.forEach((day) => {
if( day !== "" ){
let dateDay = new Date(y, m, day);
let dayCode = this.generateDateCode(dateDay)
this.chartDateCodes.push(dayCode)
}
})
// generate past date codes for list
for (let i = 0; i < this.tempChartDays; i++) {
const now = new Date()
const pastDate = now.setDate(now.getDate() - i)
const pastDateObj = new Date(pastDate)
const newCode = this.generateDateCode(pastDateObj)
this.listDateCodes.push(newCode)
}
// return codes.reverse()
/*
October 2022
S M T W T F S
1 2 3 4 5 6
7 8 9
*/
// -------
},
}
}
</script>
@@ -0,0 +1,164 @@
<style type="text/css" scoped>
.modal-content {
position: fixed;
top: 40%;
left: 50%;
/* bring your own prefixes */
transform: translate(-50%, -40%);
z-index: 300;
padding: 1em;
box-sizing: border-box;
width: 50%;
max-height: 100%;
/*overflow: hidden;*/
overflow-y: scroll;
font-weight: normal;
}
.modal-content.fullscreen {
width: 96%;
height: 100%;
max-height: 100%;
}
.close-container {
position: fixed;
top: 5px;
right: 5px;
z-index: 320;
}
/* Shrink button text for mobile */
@media only screen and (max-width: 740px) {
.modal-content {
width: 100%;
/* padding-bottom: 55px;*/
}
}
.modal-content.right-side {
width: 60%;
max-height: none;
height: 100vh;
padding: 0;
margin: 0;
top: 0;
bottom: 0;
left: 0;
left: auto;
transform: translate(0, 0);
}
.close-container-right-side {
position: fixed;
top: 5px;
left: calc(60% + 2px);
z-index: 320;
}
.shade {
position: fixed;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #0000007d;
z-index: 299;
backdrop-filter: blur(2px);
}
.fade-out-top {
animation: fade-out-top 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
.fade-out {
animation: fade-out 0.3s ease-out both;
}
@keyframes fade-out-top {
0% {
/*transform: translate(-50%, -50%);*/
opacity: 1;
}
100% {
/*transform: translate(-50%, -70%);*/
opacity: 0;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.fade-in {
/*animation: fade-in 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;*/
}
@keyframes fade-in {
0% {
transform: translate(-50%, -70%);
opacity: 0;
}
100% {
transform: translate(-50%, -50%);
opacity: 1;
}
}
</style>
<template>
<div v-if="openModel">
<div class="modal-content" :class="{ 'fade-out-top':(animateOut), 'fade-in':(!animateOut), 'fullscreen':(fullscreen)}">
<slot></slot>
</div>
<!-- full screen close button -->
<div class="close-container" v-if="fullscreen && clickOutClose !== false">
<div class="ui green icon button" v-on:click="closeModel">
<i class="close icon"></i>
</div>
</div>
<div class="shade" v-on:click="closeModel" v-on:mouseenter=" hoverOutClose?closeModel():null " :class="{ 'fade-out':(animateOut) }"></div>
</div>
</template>
<script>
export default {
props: [
'fullscreen', //Make the model really big
'clickOutClose', //Set to false to prevent closing of modal by clicking out
'hoverOutClose', //Close if cursor leaves modal
],
data: function(){
return {
openModel:true,
animateOut:false,
}
},
methods: {
closeModel(){
//Don't allow closing by clicking out
if(this.clickOutClose === false){
return
}
//Set stups to close model, animate out
this.animateOut = true
setTimeout( () => {
this.openModel = false
this.$emit('close')
//Once close event is sent, reset to default state
this.animateOut = false
this.openModel = true
}, 800)
},
}
}
</script>
@@ -0,0 +1,43 @@
<template>
<span>
<span class="clickable" @click="confirmDelete()" v-if="click == 0" data-tooltip="Delete Forever" data-inverted="" data-position="top right">
<i class="trash alternate icon"></i>
</span>
<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>
</template>
<script>
import axios from 'axios'
export default {
name: 'NoteTitleDisplayCard',
props: [ 'noteId', 'displayText' ],
data () {
return {
click: 0,
}
},
methods:{
confirmDelete(){
this.click++
},
actuallyDelete(){
axios.post('/api/note/delete', {'noteId':this.noteId}).then(response => {
if(response.data == true){
this.$bus.$emit('note_deleted', this.noteId)
this.$store.dispatch('fetchAndUpdateUserTotals')
}
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Delete Note') })
},
reset(){
this.click = 0
}
}
}
</script>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
<template>
<div class="nm-button" :class="moreClass" :data-tooltip="tip" data-inverted :data-position=" bottomTip?'bottom center':'top center'">
<!-- 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', 'bottomTip'],
data () {
return {
files: [],
mobile: false,
}
},
beforeMount(){
this.mobile = this.$store.getters.getIsUserOnMobile
},
mounted(){
// console.log('Im a button')
},
methods: {
onFileClick(file){
},
}
}
</script>
@@ -0,0 +1,339 @@
<template>
<div>
<!-- hover over view -->
<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">
<input
placeholder="Tag name. Press (Up) to select suggestion. Press (Enter) to submit."
v-model="newTagInput"
v-on:keydown="tagInput"
v-on:keyup="onKeyup"
v-on:blur="onBlur"
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="onSuggestionClick(index)">
{{ucWords(item.text)}} <span class="suggestion-tip" v-if="index == selection">Press Enter to add</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'NoteTagEdit',
props: [ 'noteId' ],
data () {
return {
tags: [],
newTagInput: '',
typeDebounce: null,
allTags: [],
noteTagIds: [],
suggestions: [],
selection: 0,
fullTagEdit: true,
loaded: false,
}
},
beforeMount(){
},
mounted() {
this.getTags()
},
methods: {
getTags(){
//Get Note Tags -> /api/tags/get
axios.post('/api/tag/get', {'noteId': this.noteId})
.then( ({data}) => {
this.loaded = true
this.allTags = data.allTags
this.noteTagIds = data.noteTagIds
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Get Tags') })
},
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
//Cancel any of the following key events
const code = event.keyCode
if([38, 40, 13, 9].includes(code)){
event.preventDefault()
}
//Up - 38 - Go Up suggestion
if(code == 40){
this.selection ++
if(this.selection >= this.suggestions.length){
this.selection = -1 //No selection made
}
return
}
//Down - 40 - Go Down Suggestion
if(code == 38){
this.selection --
if(this.selection < -1){
this.selection = this.suggestions.length -1 //No selection made
}
return;
}
//Enter - 13 - Execute addTag() function
if(code == 13){
//If an item from list is selected, make that the text
if(this.selection > -1 && this.suggestions[vm.selection]){
this.newTagInput = this.suggestions[vm.selection].text
}
this.addTag()
return
}
//Tab - 9 - Fill space with suggestion
//Anything else, perform search
clearTimeout(this.typeDebounce)
this.typeDebounce = setTimeout( () => {
if(event.target.value.length > 0){
const postData = {
'tagText':vm.newTagInput,
'noteId':vm.noteId
}
axios.post('/api/tag/suggest', postData)
.then(response => {
vm.suggestions = response.data
vm.selection = -1 //Nothing selected
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Get Suggested Tags') })
}
}, 300)
},
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(){
//Don't add blank or empty tags
if(this.newTagInput == ''){
return
}
let postData = {
'tagText':this.newTagInput.trim(),
'noteId':this.noteId
}
let vm = this
axios.post('/api/tag/addtonote', postData)
.then(response => {
vm.newTagInput = ''
vm.clearSuggestions()
vm.getTags()
//Trigger focus event to reload tag suggestions
vm.onFocus()
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Add Tag') })
},
onFocus(){
return
//Show suggested tags
let vm = this
let postData = {
'noteId':this.noteId
}
axios.post('/api/tag/latest', postData)
.then(response => {
vm.suggestions = response.data
vm.selection = -1 //Nothing selected
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Fetch Latest Tags') })
},
onKeyup(){
const code = event.keyCode
//Backspace - clear old suggestions, load latest suggestions
if(code == 8 && this.newTagInput.length == 0){
//Fetch suggestions
this.onFocus()
}
},
onBlur(){
let vm = this
setTimeout(i => {
vm.clearSuggestions()
}, 200)
},
removeTag(tagId){
let postData = {
'tagId':tagId,
'noteId':this.noteId
}
axios.post('/api/tag/removefromnote', postData)
.then(response => {
this.getTags()
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Remove Tag') })
},
clearSuggestions(){
this.suggestions = []
this.selection = -1 //No selections
},
ucWords(str){
return (str + '')
.replace(/^(.)|\s+(.)/g, function ($1) {
return $1.toUpperCase()
})
}
}
}
</script>
<style type="text/css" scoped>
/* note tag edit area */
.full-tag-area {
/*padding: 15px;*/
/*border: 1px solid;*/
border-color: var(--border_color);
}
.full-tag-area .delete-tag-display {
/*margin-top: 15px;*/
}
.full-tag-area .ui.label {
margin-bottom: 5px;
}
.simple-tag-display {
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 */
.suggestion-box {
position: absolute;
bottom: 38px;
width: 300px;
box-shadow: 0px 0px 5px 2px rgba(140,140,140,1);
}
.suggestion-item {
width: 100%;
height: 40px;
padding: 10px 15px;
cursor: pointer;
background-color: var(--small_element_bg_color);
color: var(--text_color);
}
.suggestion-item.active {
background: green;
color: white;
}
.suggestion-item + .suggestion-item {
border-top: 1px solid #DDD;
}
.suggestion-tip {
float: right;
}
</style>
@@ -0,0 +1,768 @@
<template>
<div class="note-title-display-card"
:style="{'background-color':color, 'color':fontColor, 'border-color':color }"
:class="{
'currently-open':(currentlyOpen || showWorking),
'ring':triggerClosedAnimation,
'title-view':titleView
}">
<!-- Show title and snippet below it -->
<div class="overflow-hidden note-card-text" @click.stop="cardClicked" v-if="!titleView">
<span v-if="note.title == '' && note.subtext == ''">
Empty Note
</span>
<span v-if="noteIcon" class="badge">
<i :class="`large ${noteIcon} icon`" :style="{ 'color':iconColor }"></i>
</span>
<!-- Title display -->
<span v-if="note.title.length > 0"
class="big-text"><p>{{ note.title }}</p></span>
<span class="tags" v-if="note.tags">
<span v-for="tag in (note.tags.split(','))" class="little-tag" v-on:click.stop="$emit('tagClick', tag.split(':')[1] )">#{{ tag.split(':')[0] }}</span>
<br>
</span>
<!-- Shared Details -->
<span class="subtext" v-if="note.shared == 2">
<i class="green paper plane outline icon"></i> Shared
<span v-if="note.updated/1000 > note.opened && !beenClicked" class="ui tiny green compact right floated basic button">
Updated
</span>
</span>
<span class="subtext" v-if="note.shareUsername">
<i class="green paper plane outline icon"></i> Shared by {{ note.shareUsername }}
<span v-if="note.opened == null && !beenClicked" class="ui tiny green compact right floated button">
New
</span>
<span v-else-if="note.updated/1000 > note.opened && !beenClicked" class="ui tiny green compact right floated basic button">
Updated
</span>
</span>
<!-- Sub text display -->
<span v-if="note.subtext.length > 0"
class="small-text"
v-html="note.subtext"></span>
<!-- Not indexed warning -->
<!-- <span v-if="note.indexed != 1">
<span class="green label">Not Indexed</span>
</span> -->
<!-- <div class="ui fluid basic button" v-if="note.encrypted == 1">
<i class="green lock icon"></i>
Locked
</div> -->
</div>
<!-- slim card view -->
<div v-if="titleView" class="thin-container" @click="cardClicked">
<!-- icon -->
<span v-if="noteIcon" class="thin-icon">
<i :class="`${noteIcon} icon`" :style="{ 'color':iconColor }"></i>
</span>
<!-- title -->
<span class="thin-title" v-if="note.title.length > 0">{{ note.title }}</span>
<!-- snippet -->
<span class="thick-sub" v-if="note.subtext.length > 0 && note.title.length == 0">
{{ removeHtml(note.subtext) }}
</span>
<span class="thin-sub" v-else-if="note.subtext.length > 0">
{{ removeHtml(note.subtext) }}
</span>
<span v-else-if="note.title.length == 0 && removeHtml(note.subtext).length == 0">
Empty Note
</span>
<!-- tags -->
<span v-if="note.tags" class="thin-tags" >
<span v-for="tag in (note.tags.split(','))" class="little-tag" v-on:click="$emit('tagClick', tag.split(':')[1] )">#{{ tag.split(':')[0] }}
</span>
</span>
<!-- edited -->
<span class="thin-right">
{{$helpers.timeAgo( note.updated )}}
<i class="green link ellipsis vertical icon"></i>
</span>
</div>
<!-- Toolbar on the bottom -->
<div class="tool-bar" @click.self="cardClicked" v-if="!titleView">
<div v-if="getThumbs.length > 0">
<div class="tiny-thumb-box" v-on:click="openEditAttachment">
<img v-for="thumb in getThumbs"
class="tiny-thumb"
:src="`/api/static/thumb_${thumb}`"
onerror="
this.onerror=null;
this.src='/api/static/assets/marketing/void.svg';
"
/>
</div>
</div>
<div class="icon-bar" :class="{ 'hover-hide':(!$store.getters.getIsUserOnMobile) }">
<span class="time-ago-display">
{{$helpers.timeAgo( note.updated )}}
</span>
<span class="teeny-buttons">
<span v-if="!note.trashed">
<i class="teeny-button" data-tooltip="Tags" data-inverted v-on:click="toggleTags(true)">
<i class="tags icon"></i>
</i>
<i class="teeny-button"
data-tooltip="Archive"
:data-tooltip="note.archived ? 'Un-Archive':'Archive' "
data-inverted v-on:click="archiveNote">
<i class="archive icon" :class="{'green':note.archived}"></i>
</i>
<i class="teeny-button"
:data-tooltip="note.pinned ? 'Un-Pin':'Pin' "
data-inverted v-on:click="pinNote">
<i class="pin icon" :class="{'green':note.pinned}"></i>
</i>
<i class="teeny-button"
data-tooltip="Move to Trash"
data-inverted v-on:click="trashNote()">
<i class="trash icon"></i>
</i>
</span>
<!-- Trash note options -->
<span v-if="note.trashed">
<i class="teeny-button"
data-tooltip="Un-Trash"
data-inverted v-on:click="trashNote()">
<i class="reply icon"></i>
</i>
<delete-button class="teeny-button" :note-id="note.id" />
</span>
</span>
</div>
</div>
<!-- tag edit menu -->
<side-slide-menu v-if="showTagSlideMenu" v-on:close="toggleTags(false)" :full-shadow="true" :skip-history="true">
<div class="ui basic segment">
<note-tag-edit :noteId="note.id" :key="'display-tags-for-note-'+note.id"/>
</div>
</side-slide-menu>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
name: 'NoteTitleDisplayCard',
props: [ 'onClick', 'data', 'currentlyOpen', 'textResults', 'attachmentResults', 'tagResults', 'titleView' ],
components: {
'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
'note-tag-edit': defineAsyncComponent(() => import('@/components/NoteTagEdit.vue')),
'side-slide-menu': defineAsyncComponent(() => import('@/components/SideSlideMenuComponent.vue')),
},
methods:{
cardClicked(){
// console.log(this.note)
this.beenClicked = true
this.onClick(this.note.id)
},
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()
},
cleanHighlight(text){
//Basically just remove whitespace
let updated = text.replace(/&nbsp;/g, '').replace(/<br>/g,'')
.replace(/<p><\/p>/g,'').replace(/<p>&nbsp;<\/p>/g,'')
return updated
},
splitTags(text){
return text.split(',')
},
openEditAttachment(){
this.$router.push('/attachments/note/'+this.note.id)
},
pinNote(){ //togglePinned() <- old name
this.showWorking = true
this.note.pinned = this.note.pinned == 1 ? 0:1
let postData = {'pinned': this.note.pinned, 'noteId':this.note.id}
axios.post('/api/note/setpinned', postData)
.then(data => {
this.showWorking = false
// this event is triggered by the server after note is saved
// this.$bus.$emit('update_single_note', this.note.id)
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Pin Note') })
},
archiveNote(){ //toggleArchived() <- old name
this.showWorking = true
let postData = {'archived': !this.note.archived, 'noteId':this.note.id}
axios.post('/api/note/setarchived', postData)
.then(data => {
//Show message so no one worries where note went
let message = 'Moved to Archive'
if(postData.archived != 1){
message = 'Moved out of Archive'
}
this.$bus.$emit('notification', message)
this.$bus.$emit('update_single_note', this.note.id)
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Archive Note') })
},
trashNote(){ //toggleArchived() <- old name
this.showWorking = true
let postData = {'trashed': !this.note.trashed, 'noteId':this.note.id}
axios.post('/api/note/settrashed', postData)
.then(data => {
//Show message so no one worries where note went
let message = 'Moved to Trash'
if(postData.trashed == 0){
message = 'Moved out of Trash'
}
this.$bus.$emit('notification', message)
this.$bus.$emit('update_single_note', this.note.id)
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Trash Note') })
},
toggleTags(state){
this.showTagSlideMenu = state
if(state == false){
this.$bus.$emit('update_single_note', this.note.id)
}
},
justClosed(){
// Dont do anything when not is closed.
// Its already saved, this will make interface feel snappy
// Scroll note into view
// this.$el.scrollIntoView({
// behavior: 'smooth',
// block: 'center',
// inline: 'center'
// })
// this.$bus.$emit('notification','Note Saved')
// //After scroll, trigger green outline animation
// setTimeout(() => {
// this.triggerClosedAnimation = true
// setTimeout(()=>{
// //After 3 seconds, hide it
// this.triggerClosedAnimation = false
// }, 1500)
// }, 500)
},
},
data () {
return {
note: null,
color: null,
fontColor: null,
noteIcon: null,
iconColor: null,
beenClicked: false,
showTagSlideMenu: false,
triggerClosedAnimation: false, //Show just closed animation
showWorking: false
}
},
computed: {
getThumbs(){
if(!this.note.thumbs){
return []
}
let notDisplaying = []
//Remove images displaying in text from the thumbnails
this.note.thumbs.forEach( path => {
const titleLocation = String(this.note.title).indexOf(path)
const subtextLocation = String(this.note.subtext).indexOf(path)
if(titleLocation != -1 || subtextLocation != -1){
return
}
notDisplaying.push(path)
})
return notDisplaying
}
},
beforeMount(){
this.note = this.data
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">
.teeny-buttons {
float: right;
text-align: right;
}
.time-ago-display {
font-size: 11px;
font-weight: bold;
}
.tags {
width: 100%;
display: inline-block;
}
.teeny-button {
border: 1px solid var(--border_color);
border-radius: 5px;
padding: 0px 0px 0px 4px;
text-align: center;
margin: 0 0 3px 5px;
display: inline-block;
min-width: 30px;
color: var(--text_color);
background-color: var(--small_element_bg_color);
}
.subtext {
display: inline-block;
width: 100%;
}
/*Strict font sizes for card display*/
.small-text {
width: 100%;
display: inline-block;
}
.small-text, .small-text > p, .small-text > h1, .small-text > h2 {
/*font-size: 1.0em !important;*/
font-size: 14px !important;
}
.small-text > p, , .small-text > h1, .small-text > h2 {
margin-bottom: 0.5em;
}
.big-text > p:first-child,
.big-text > h1, .big-text > h2 {
/*font-size: 1.3em !important;*/
font-size: 20px !important;
font-weight: bold;
margin-bottom: 0.5em;
}
.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-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;
border-top: 1px solid #DDD;
margin: 8px 0 0;
}
.note-title-display-card {
position: relative;
background-color: var(--small_element_bg_color);
/*The subtle shadow*/
box-shadow: 2px 2px 6px 0 rgba(0,0,0,.15);
transition: box-shadow, border-color ease 0.5s, transform linear 0.5s;
margin: 5px;
/*padding: 0.7em 1em;*/
border-radius: .28571429rem;
border: 1px solid transparent;
border-color: var(--border_color);
/*width: calc(33.333% - 10px);*/
width: calc(25% - 10px);
/*min-width: 190px;*/
/*min-height: 130px;*/
/*transition: box-shadow 0.3s;*/
box-sizing: border-box;
cursor: pointer;
line-height: 1.8rem;
letter-spacing: 0.05rem;
display: flex;
flex-direction: column;
align-items: stretch;
text-align: left;
min-height: 100px;
max-height: 450px;
}
.note-title-display-card:hover {
box-shadow: 0 8px 15px rgba(0,0,0,0.3);
border-color: var(--main-accent);
}
.note-title-display-card.title-view {
width: 100%;
min-height: 20px;
max-width: none;
padding: 10px;
margin: 0;
/*overflow: hidden;*/
border-radius: 0;
border: none;
/*box-shadow: 0px 0px 1px 1px rgba(210, 211, 211, 0.46);*/
}
.title-view + .title-view {
border-top: 1px solid var(--border_color);
}
.thin-container.single-line-text {
width: calc(100% - 25px);
/*margin: 5px 10px;*/
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.thin-container .thin-title {
font-weight: bold;
font-size: 1.2em;
}
.thin-container .thin-sub {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
opacity: 0.85;
}
.thin-container .thick-sub {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
opacity: 0.85;
}
.thin-container .thin-tags {
float: left;
margin-top: 3px;
}
.thin-container .thin-right {
float: right;
color: var(--dark_border_color);
}
.thin-container .thin-icon {
float: right;
}
.icon-bar {
display: inline-block;
padding: 5px 10px 0;
opacity: 1;
width: 100%;
background-color: rgba(200, 200, 200, 0.2);
}
.hover-hide {
opacity: 0.0;
transition: opacity ease 0.6s;
}
.little-tag {
font-size: 0.7em;
padding: 5px 5px;
margin: 0 3px 5px 0;
border-radius: 3px;
white-space: nowrap;
max-width: 175px;
overflow: hidden;
display: inline-block;
line-height: 0.8em;
text-overflow: ellipsis;
float: left;
color: var(--main-accent);
opacity: 0.8;
}
.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;
}
.note-card-text {
width: 100%;
display: inline-block;
align-self: flex-start;
flex-grow: 1;
padding: 10px 10px 0;
}
.tool-bar {
width: 100%;
display: inline-block;
align-self: flex-end;
flex-grow: 0;
}
.one-column .note-title-display-card {
width: 100%;
max-width: none;
/*margin: 0px -5px 10px -5px;*/
}
.overflow-hidden {
overflow: hidden;
}
.overflow-hidden p, .overflow-hidden h3 {
word-break: break-word;
}
.max-height {
height: calc(100% + 30px);
}
.currently-open:after {
content: '...';
position: absolute;
cursor: default;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: #000000b0;
vertical-align: middle;
display: flex;
justify-content: center;
align-items: center;
color: #cecece;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
font-size: 3rem;
}
.badge {
display: inline-block;
float: right;
}
/* Break points determine when display cards shrink */
@media only screen and (max-width: 700px) {
.note-title-display-card {
width: calc(100% + 10px);
/*margin: 0px -5px 10px -5px;*/
}
}
@media only screen and (min-width: 700px) and (max-width: 900px) {
.note-title-display-card {
width: calc(50% - 10px);
}
}
@media only screen and (min-width: 900px) and (max-width: 1100px) {
.note-title-display-card {
width: calc(33.33333% - 10px);
}
}
@media only screen and (min-width: 1100px) and (max-width: 1300px) {
.note-title-display-card {
width: calc(25% - 10px);
}
}
@media only screen and (min-width: 1300px) and (max-width: 1800px) {
.note-title-display-card {
width: calc(20% - 10px);
}
}
@media only screen and (min-width: 1800px) {
.note-title-display-card {
width: calc(16.66666% - 10px);
}
}
/*Animations for cool border effects*/
@keyframes bgin {
0% {
background-image:
linear-gradient(to right, var(--main-accent) 50%, var(--main-accent) 100%), /* TopLeft to Right */
linear-gradient(to bottom, var(--main-accent) 50%, var(--main-accent) 100%), /* TopRight to Bottom */
linear-gradient(to right, var(--main-accent) 50%, var(--main-accent) 100%), /* BottomLeft to Right*/
linear-gradient(to bottom, var(--main-accent) 50%, var(--main-accent) 100%); /* TopLeft to Bottom */
/*Initial state, no BG*/
background-size: 0 4px, 4px 0, 0 4px, 4px 0;
}
10% {
/*Middre state, some filled */
background-size: 100% 4px, 4px 0, 100% 4px, 4px 0;
}
20% {
/*final state, all filled */
background-size: 100% 4px, 4px 100%, 100% 4px, 4px 100%;
}
30% {
background-size: 100% 4px, 4px 100%, 100% 4px, 4px 100%;
background-image:
linear-gradient(to right, var(--main-accent) 50%, var(--main-accent) 100%), /* TopLeft to Right */
linear-gradient(to bottom, var(--main-accent) 50%, var(--main-accent) 100%), /* TopRight to Bottom */
linear-gradient(to right, var(--main-accent) 50%, var(--main-accent) 100%), /* BottomLeft to Right*/
linear-gradient(to bottom, var(--main-accent) 50%, var(--main-accent) 100%); /* TopLeft to Bottom */
}
100% {
background-image:
linear-gradient(to right, transparent 50%, transparent 100%), /* TopLeft to Right */
linear-gradient(to bottom, transparent 50%, transparent 100%), /* TopRight to Bottom */
linear-gradient(to right, transparent 50%, transparent 100%), /* BottomLeft to Right*/
linear-gradient(to bottom, transparent 50%, transparent 100%); /* TopLeft to Bottom */
}
}
.bgboy {
overflow: hidden;
background-repeat: no-repeat;
background-size: 100% 0, 0 100%, 100% 0, 0 100%;
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
animation: bgin 4s cubic-bezier(0.19, 1, 0.22, 1) 1;
}
/*switch between ring or BG boy to change save animation*/
.ring {
position: relative;
}
.ring::after {
content: '';
width: 10px;
height: 10px;
border-radius: 100%;
border: 6px solid #00FFCB;
position: absolute;
z-index: 800;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: ring 1.5s 1;
}
@keyframes ring {
0% {
width: 10px;
height: 10px;
opacity: 1;
}
100% {
width: 420px;
height: 420px;
opacity: 0;
}
}
</style>
@@ -0,0 +1,116 @@
<template>
<div class="button-fix">
<div class="ui right floated basic shrinking icon button" v-on:click="showPasteInputArea">
<i class="green paste icon"></i>
Paste
</div>
<div class="shade" v-if="showPasteArea" @click.prevent="close">
<div class="ui stackable grid full-height" @click.prevent="close">
<div class="four wide column"></div>
<div class="eight wide middle aligned center aligned column">
<div class="ui raised segment">
<div class="ui dividing header">
<i class="green paste icon"></i>
Paste & automatically Save
</div>
<div class="ui fluid action input">
<input
id="pastetextarea"
type="text"
ref="pastearea"
@paste.prevent="onPaste"
@keyup.enter.prevent="onEnter"
placeholder="Paste Here">
<button class="ui green labeled icon button" @click.prevent="onEnter">
<i class="save icon"></i>
Save
</button>
</div>
</div>
</div>
<div class="four wide column"></div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'PasteButton',
props: {},
data () {
return {
showPasteArea: false,
}
},
methods: {
close(){
this.showPasteArea = false
},
onEnter(e){
const text = this.$refs.pastearea.value
this.saveText(text)
},
onPaste(e){
// Get pasted data via clipboard API
const clipboardData = e.clipboardData || window.clipboardData
const pastedData = String(clipboardData.getData('Text')).trim()
this.saveText(pastedData)
},
saveText(text){
this.showPasteArea = false
if(!text){
this.$bus.$emit('notification', 'Nothing to save.')
return;
}
axios.post('/api/quick-note/update', { 'pushText':text } )
.then( response => {
this.$bus.$emit('notification', 'Saved To Scratch Pad')
})
.catch(error => {
this.$bus.$emit('notification', 'Failed to Save')
})
},
showPasteInputArea(){
// Show text area and focus its contents
this.showPasteArea = true
this.$nextTick(() => {
const aux = document.getElementById('pastetextarea')
aux.focus();
})
// auto hide after 1 Minute
setTimeout(() => {
this.showPasteArea = false
}, 60*1000)
},
}
}
</script>
<style scoped lang="css">
.paste-text-container {
background-color: green;
position: absolute;
width: 50vw;
height: 80vh;
display: inline-block;
}
.full-height {
height: 100vh;
}
</style>
@@ -0,0 +1,222 @@
<style type="text/css" scoped>
.fixed-search {
position: fixed;
top: 50%;
left: 0;
right: 0;
padding: 10px;
}
.floating-button {
position: absolute;
right: 7px;
top: 5px;
z-index: 2;
}
.floating-note-options {
position: absolute;
right: 0;
left: 0;
top: 35px;
z-index: 2;
}
.floating-note-options > .segment {
border-top: none;
border-top-right-radius: 0;
border-top-left-radius: 0;
}
</style>
<template>
<span>
<div class="ui form" v-on:mouseenter="extraMenuHover = true" v-on:mouseleave="extraMenuHover = false">
<!-- normal search menu -->
<div class="ui left icon fluid input">
<input ref="desktopSearch" v-on:blur="focused = false" v-on:focus="focused = true" v-model="searchTerm" @keydown="onKeyDown" @keyup="onKeyUp" placeholder="Search or Start Typing New Note" />
<i class="search icon"></i>
</div>
<div class="floating-button" v-if="searchTerm.length > 0">
<i class="big link grey close icon" v-on:click="clear()"></i>
</div>
<div class="floating-note-options"
v-if="(searchTerm.length > 0 || tagSuggestions.length > 0) && (extraMenuHover)">
<div class="ui segment">
<div class="ui very compact grid">
<div class="five wide column">
<div class="ui mini green fluid compact shrinking button" v-on:click="search()">
<i class="search icon"></i>Search
</div>
<span class="button-sub">
( Enter )
</span>
</div>
<div class="five wide middle aligned column">
<div class="ui mini green fluid compact shrinking button" v-on:click="pushToNewNote()">
<i class="plus icon"></i>A New Note
</div>
<span class="button-sub">
( Tab )
</span>
</div>
<div class="six wide right aligned column">
<div class="ui mini green fluid compact shrinking button" v-on:click="pushToQuickNote()">
<i class="sticky note outline icon"></i>The Scratch Pad
</div>
<span class="button-sub">
( CTRL + ENTER )
</span>
</div>
</div>
</div>
</div>
</div>
</span>
</template>
<script>
import axios from 'axios'
export default {
data: function(){
return {
searchTerm:'',
searched: false,
tagSuggestions: [],
tagSearchDebounce: null,
extraMenuHover: false,
}
},
beforeCreate: function(){
},
beforeMount(){
//search clear
this.$bus.$on('reset_fast_filters', () => {
this.searchTerm = ''
this.tagSuggestions = []
})
},
beforeUnmount(){
this.$bus.$off('reset_fast_filters')
},
mounted: function(){
},
methods: {
tagClick(tagId){
this.$emit('tagClick', tagId)
this.tagSuggestions = []
this.searchTerm = ''
},
clear(){
this.searched = false
this.searchTerm = ''
this.tagSuggestions = []
if(!this.$store.getters.getIsUserOnMobile){
this.$refs.desktopSearch.focus()
}
this.$bus.$emit('note_reload')
},
pushToQuickNote(){
const text = this.searchTerm
this.searchTerm = ''
this.tagSuggestions = []
axios.post('/api/quick-note/update', { 'pushText':text.trim() } )
.then( response => {
this.$bus.$emit('notification', 'Saved To Scratch Pad')
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Update The Scratch Pad') })
},
pushToNewNote(){
const text = this.searchTerm
this.searchTerm = ''
this.tagSuggestions = []
axios.post('/api/note/create', { text })
.then(response => {
if(response.data && response.data.id){
this.$router.push('/notes/open/'+response.data.id)
}
})
.catch(error => { this.$bus.$emit('notification', 'Failed to create note') })
},
onKeyUp(event){
//Search Tags
const postData = {
'tagText':this.searchTerm.trim()
}
clearTimeout(this.tagSearchDebounce)
// if(this.searchTerm.length == 0){
// this.tagSuggestions = []
// return
// }
// this.tagSearchDebounce = setTimeout(() => {
// this.tagSuggestions = []
// axios.post('/api/tag/suggest', postData)
// .then( response => {
// this.tagSuggestions = response.data
// })
// .catch(error => { this.$bus.$emit('notification', 'Failed to Get Suggested Tags') })
// }, 800)
},
onKeyDown(event){
//Tab
if(event.keyCode == 9){
this.pushToNewNote()
event.preventDefault()
return false
}
//Commant + Enter
if((event.metaKey || event.ctrlKey) && event.keyCode == 13 ){
this.pushToQuickNote()
event.preventDefault()
return false
}
//Enter
if(event.keyCode == 13){
this.search()
event.preventDefault()
return false
}
},
search(){
if(this.searchTerm.length == 0){
this.clear()
return
}
this.searched = true
this.$refs.desktopSearch.focus()
//Blur after search on mobile
if(this.$store.getters.getIsUserOnMobile){
this.$refs.desktopSearch.blur()
}
this.$bus.$emit('update_search_term', this.searchTerm)
},
}
}
</script>
@@ -0,0 +1,176 @@
<style type="text/css" scoped>
</style>
<template>
<div>
<div class="ui grid" v-if="shareUsername == null">
<div v-if="!isNoteShared" class="sixteen wide column">
<div class="ui button" v-on:click="makeShared()">Enable Sharing</div>
<ul>
<li>Shared notes can be read and edited by you and all shared users.</li>
<li>Shared notes can only be shared by the creator of the note.</li>
<li>If you turn off sharing, no one else can read the note.</li>
</ul>
</div>
<div v-if="isNoteShared" class="sixteen wide column">
<p>Generating a shared URL will expose the password of this note.</p>
<div class="ui button" v-on:click="removeShared()">Remove Shared</div>
<div class="ui button" v-on:click="getSharedUrl()">Get Shareable URL</div>
</div>
<div class="sixteen wide column" v-if="isNoteShared && sharedUrl.length > 0">
<p>Public Link - this link can be disabled by turning off sharing</p>
<a target="_blank" :href="sharedUrl">{{ sharedUrl }}</a>
</div>
</div>
<div class="ui grid" v-if="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="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 {
isNoteShared: false,
sharedWithUsers: [],
shareUserInput: '',
debounce: null,
enableSubmitShare: false,
sharedUrl: '',
}
},
beforeMount(){
},
mounted(){
// this.isNoteShared = this.noteShared
if(this.shareUsername == null){
this.loadShareList()
}
},
methods: {
loadShareList(){
axios.post('/api/note/getshareinfo', {'noteId':this.noteId, 'rawTextId':this.rawTextId })
.then( ({data}) => {
this.isNoteShared = (data.shareStatus == 2)
this.sharedWithUsers = data.shareUsers
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Load Shared') })
},
makeShared(){
axios.post('/api/note/enableshare', {'noteId':this.noteId })
.then( ({data}) => {
this.isNoteShared = true
})
.catch(error => { this.$bus.$emit('notification', 'Failed to fetch Shared URL') })
},
removeShared(){
axios.post('/api/note/disableshare', {'noteId':this.noteId })
.then( ({data}) => {
this.isNoteShared = false
})
.catch(error => { this.$bus.$emit('notification', 'Failed to remove share status') })
},
getSharedUrl(){
axios.post('/api/note/getsharekey', {'noteId':this.noteId })
.then( ({data}) => {
const encodedKey = encodeURIComponent(data)
this.sharedUrl = `${window.location.protocol}//${window.location.hostname}/#/public/note/${this.noteId}/${encodedKey}`
})
.catch(error => { this.$bus.$emit('notification', 'Failed to fetch Shared URL') })
},
onRevokeAccess(sharedNoteId){
const postData = {
'noteId': this.noteId,
'shareUserNoteId': sharedNoteId
}
axios.post('/api/note/shareremoveuser', postData)
.then( ({data}) => {
console.log(data)
if(data == true){
this.loadShareList()
}
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Remove Share User') })
},
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')
}
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Add User') })
},
}
}
</script>
@@ -0,0 +1,162 @@
<style type="text/css" scoped>
.slide-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1020;
overflow: hidden;
height: 100%;
color: var(--text_color);
background-color: var(--small_element_bg_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(--small_element_bg_color);*/
overflow-x: scroll;
}
.slide-shadow {
position: fixed;
top: 0;
left: 0;
right: 0;
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: 1019;
overflow: hidden;
/*cursor: pointer;*/
}
.slide-shadow.full-shadow {
right: 0;
background-color: rgba(0,0,0,0.3);
}
.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;
}
}
.fade-enter-active {
animation: fade-in .3s;
}
.fade-leave-active {
animation: fade-in .1s reverse;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
<template>
<!-- <transition name="fade"> -->
<div>
<div class="slide-container" :style="{ 'background-color':bgColor, 'color':textColor}">
<!-- 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>
<!-- content of the editor -->
<div class="slide-content">
<slot></slot>
</div>
</div>
<!-- <div class="slide-shadow" :class="{'full-shadow':fullShadow}" v-on:click="close"></div> -->
</div>
<!-- </transition> -->
</template>
<script>
import { defineAsyncComponent } from 'vue'
export default {
name: 'SideSlideMenu',
props: [ 'name', 'styleObject', 'fullShadow', 'skipHistory' ],
components: {
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
},
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()
}
})
},
beforeUnmount(){
},
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: {
close() {
if(this.skipHistory != true){
this.$router.go(-1)
}
this.$emit('close'); //Close menu via event
},
}
}
</script>
@@ -0,0 +1,123 @@
<style type="text/css" scoped>
.img-container {
display: flex;
flex-wrap: wrap;
}
.img-row {
height: 20vh;
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(){
this.loadImages()
},
methods: {
loadImages(){
axios.post('/api/attachment/search', {'attachmentType':'files', 'setSize':1000})
.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)
}
})
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Load Attachments') })
},
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){
window.history.back();
}
},
close() {
this.$emit('close');
},
}
}
</script>
@@ -0,0 +1,44 @@
<template>
<div class="svg-holder" v-if="svg[file]" v-html="svg[file]" :alt="alt"></div>
</template>
<script>
export default {
props:[ 'file','alt' ],
data() {
return {
//Remove title, remove id from each SVG
svg: {
'idea':'<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="1013.02965" height="844.99456" viewBox="0 0 1013.02965 844.99456"><path d="M1050.48389,297.81272c0,35.17-8.33,64.12-22.7799,88.92a177.67758,177.67758,0,0,1-16.19,23.25c-53.12,64.97-151.88,100.53-241.14,158.13-263.48,170.02-292.02-102.42-280.12-270.3,4.8-67.74,31.18-129.79,73.09-177.3a278.22062,278.22062,0,0,1,28.45-27.97c47.58-40.52,109.05-65.04,178.58-65.04,119.29,0,221.16,71.95,261.55,173.34a261.84957,261.84957,0,0,1,18.55993,96.97Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M830.054,88.4227l-266.71,32.09a278.22062,278.22062,0,0,1,28.45-27.97Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1027.704,386.7327a177.67758,177.67758,0,0,1-16.19,23.25l-228.46-27.48Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1041.2439,228.72269l-387.18994-50.22,377.87,22.34A260.439,260.439,0,0,1,1041.2439,228.72269Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M516.51853,99.50486c0,.69-.02,1.37-.08,2.04a27.99706,27.99706,0,1,1-52.68-15.11,25.73956,25.73956,0,0,1,2.06-3.31,27.9889,27.9889,0,0,1,50.6,14.1C516.4885,97.97483,516.51853,98.73484,516.51853,99.50486Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M516.51853,99.50486c0,.69-.02,1.37-.08,2.04l-41.92-5.04,41.9.72C516.4885,97.97483,516.51853,98.73484,516.51853,99.50486Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M506.51853,113.61484l-38.78,4.67a28.05694,28.05694,0,0,1-2.96-3.95Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M495.51853,82.61484l-31.76,3.82a25.73956,25.73956,0,0,1,2.06-3.31Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M900.25143,603.63055c-.32346.60949-.65991,1.20075-1.027,1.76443a27.99706,27.99706,0,1,1-39.45-38.042,25.73938,25.73938,0,0,1,3.37127-1.9581,27.98891,27.98891,0,0,1,38.08621,36.17477C900.94214,602.265,900.61239,602.95037,900.25143,603.63055Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M900.25143,603.63055c-.32346.60949-.65991,1.20075-1.027,1.76443L864.5584,581.29206l36.67354,20.27763C900.94214,602.265,900.61239,602.95037,900.25143,603.63055Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M884.80385,611.4064l-36.44426-14.054a28.05745,28.05745,0,0,1-.76292-4.87668Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M889.61936,578.867l-29.8449-11.514a25.73938,25.73938,0,0,1,3.37127-1.9581Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1098.56381,120.95526c.48176.494.9422.99477,1.367,1.5163a27.99707,27.99707,0,1,1-48.26355,25.96387,25.73851,25.73851,0,0,1-.83628-3.80793,27.9889,27.9889,0,0,1,46.06931-25.23471C1097.47405,119.88088,1098.02619,120.404,1098.56381,120.95526Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M1098.56381,120.95526c.48176.494.9422.99477,1.367,1.5163l-33.52955,25.66038,30.499-28.73915C1097.47405,119.88088,1098.02619,120.404,1098.56381,120.95526Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1101.25637,138.03864l-24.50216,30.41946a28.0572,28.0572,0,0,1-4.87694-.76118Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1071.73725,123.52584l-20.07,24.90959a25.73851,25.73851,0,0,1-.83628-3.80793Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M573.26553,636.95591s61.44557,2.56024,61.44557,17.92163-69.12627,0-69.12627,0Z" transform="translate(-93.48517 -27.50272)" fill="#a0616a"/><path d="M481.09717,872.49728l-115.21045-7.6807L360.76625,803.371l-25.60232,53.76488-112.65022-15.3614c2.56023-97.28882,43.524-192.01741,46.08418-204.81858s8.67923-65.69564,8.67923-65.69564c11.1882-23.73325,69.81727-25.96064,115.77362-23.81013,19.04829.87055,35.94576,2.509,46.36577,3.68674,6.88688.768,10.95787,1.33137,10.95787,1.33137,18.35667,18.92015,30.18524,44.70155,37.45621,73.50417C514.04734,729.73871,481.09717,872.49728,481.09717,872.49728Z" transform="translate(-93.48517 -27.50272)" fill="#2f2e41"/><circle cx="259.60038" cy="187.01487" r="64.00581" fill="#a0616a"/><path d="M309.56161,229.879S291.64,309.24619,263.47743,332.28828s110.09,0,110.09,0-30.72278-51.20465-7.68069-74.24674S309.56161,229.879,309.56161,229.879Z" transform="translate(-93.48517 -27.50272)" fill="#a0616a"/><path d="M440.13345,557.58871,397.659,562.29945,278.83882,575.51034s-.56318-1.51061-1.5617-4.25007c-8.628-23.52854-50.18049-138.022-54.76341-172.406-5.12046-38.40349,48.64441-84.48767,48.64441-84.48767s51.20465-10.24093,66.566-10.24093,76.807,23.04209,76.807,23.04209l22.914,206.12433,1.97143,17.84474Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M487.83059,625.97242,401.73,575.51034s-1.51076-4.91576-4.071-13.21089c-1.28011-4.14756-2.8415-9.16551-4.60823-14.84931-9.93371-31.90055-26.60085-84.53892-39.96518-122.9935-20.48186-58.88534,79.36719,104.96952,79.36719,104.96952lLine truncated
'onboarding':`<svg id="70bb2b30-be60-4de4-9e4b-86e23973444e" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1143.11" height="875.78" viewBox="0 0 1143.11 875.78"><defs><linearGradient id="e5da6213-051c-4da8-b1b3-926fff123e76" x1="539.33" y1="763.29" x2="539.33" y2="57.83" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="052f7d93-657d-41ac-9ec0-ae0276bba283" x1="1034.54" y1="812.84" x2="1034.54" y2="197.81" gradientTransform="matrix(-1, 0, 0, 1, 1347.17, 0)" xlink:href="#e5da6213-051c-4da8-b1b3-926fff123e76"/></defs><title>onboarding</title><ellipse cx="849.67" cy="829.19" rx="115.8" ry="28.95" fill="var(--main-accent)" opacity="0.1"/><path d="M63.7,616.77c-13.1,4.49-29,13.65-26.32,28.21,1.34,7.38,7.32,12.85,13,17.47a368.3,368.3,0,0,0,79.36,49.15c13.57,6.16,28,11.81,38.25,23.17s15,27.24,18.33,42.55a345.86,345.86,0,0,1,5.72,110.41c40.91.78,86.21-.14,115.16-29.61,17.33-17.65,26.62-43.83,47-57.49,13.59-9.11,30.34-11.37,46.52-13.27q114.87-13.43,230.93-16.26c32.42-.79,65.55-1.07,95.82-12.37s57.75-36,63.9-69.49c.95-5.16,1.57-10.85,5.1-14.45,3.15-3.23,7.87-4,12.28-4.57l265.89-36c14.56-2,29.63-4.13,41.89-12.36,11.86-8,19.86-20.78,27.51-33.24,17.53-28.52,35.72-63,23.58-95.61-9.71-26.06-35.81-41.07-60.75-50.39s-51.79-15.87-72.06-34.14c-30.84-27.8-37.68-73.95-46.37-115.36a675.43,675.43,0,0,0-33-109.89C943,151.89,927.85,121,905.27,96.61S851.62,54.93,820,56.17C778.7,57.81,742.37,90,701,86c-34.94-3.35-63.37-31.35-94.83-48.77C550.32,6.28,484.4,9,423.29,18.65c-64.8,10.21-130,28.08-182.5,68.27-12,9.2-23.48,19.82-30.53,33.66-10.8,21.23-9.87,47.1-15.28,70.69-21.8,95-138.68,134.63-162.71,228.94C23.73,453.69,30.05,490,45.72,520.94c16.85,33.31,50.7,47.51,68.05,76.1C102.17,607.63,79.06,611.5,63.7,616.77Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)" opacity="0.1"/><path d="M898.6,849s-10.48-72.2,21-119.72a110.5,110.5,0,0,0,18.68-67.38,185.42,185.42,0,0,0-5.1-33.61" transform="translate(-28.45 -12.11)" fill="none" stroke="#535461" stroke-miterlimit="10" stroke-width="2"/><path d="M952.92,598.2c-1.21,8.49-20.11,30.91-20.11,30.91S921,602.28,922.18,593.79a15.53,15.53,0,1,1,30.74,4.41Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M971.6,648.87c-5.64,6.46-33.69,15-33.69,15s4.64-29,10.27-35.42a15.53,15.53,0,1,1,23.42,20.41Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M959,729.07c-8.13,2.74-36.66-4-36.66-4s18.62-22.66,26.75-25.39A15.53,15.53,0,0,1,959,729.07Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M935.76,787.61c-7.55,4.07-36.81,2.2-36.81,2.2s14.52-25.47,22.07-29.54a15.53,15.53,0,0,1,14.74,27.34Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M905.4,660.9c5.24,6.79,32.74,17,32.74,17s-2.92-29.18-8.16-36a15.53,15.53,0,0,0-24.58,19Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M879.12,729.7c7,4.91,36.31,6.41,36.31,6.41s-11.5-27-18.54-31.88a15.53,15.53,0,1,0-17.77,25.47Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M862.75,800.08c6.1,6,34.71,12.45,34.71,12.45S890.68,784,884.57,778a15.53,15.53,0,1,0-21.82,22.1Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M952.92,598.2c-1.21,8.49-20.11,30.91-20.11,30.91S921,602.28,922.18,593.79a15.53,15.53,0,1,1,30.74,4.41Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M971.6,648.87c-5.64,6.46-33.69,15-33.69,15s4.64-29,10.27-35.42a15.53,15.53,0,1,1,23.42,20.41Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M959,729.07c-8.13,2.74-36.66-4-36.66-4s18.62-22.66,26.75-25.39A15.53,15.53,0,0,1,959,729.07Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M935.76,787.61c-7.55,4.07-36.81,2.2-36.81,2.2s14.52-25.47,22.07-29.54a15.53,15.53,0,0,1,14.74,27.34Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M905.4,660.9c5.24,6.79,32.74,17,32.74,17s-2.92-29.18-8.16-36a15.53,15.53,0,0,0-24.58,19Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M879.12,729.7c7,4.91,36.31,6.41,36.31,6.41s-11.5-27-18.54-31.88a15.53,15.53,0,1,0-17.77,25.47Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M862.75,800.08c6.1,6,34.71,12.45,34.71,12.45S890.68,784,884.57,778a15.53,15.53,0,1,0-21.82,22.1Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M899.46,846.71s-38.84-61.76-29.27-118A110.5,110.5,0,0,0,860,659.56a185.49,185.49,0,0,0-18.29-28.66" transform="translate(-28.45 -12.11)" fill="none" stroke="#535461" stroke-miterlimit="10" stroke-width="2"/><path d="M847.51,595.43c2.32,8.26-5.87,36.41-5.87,36.41s-21.7-19.72-24-28a15.53,15.53,0,1,1,29.9-8.44Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M885.11,634.19c-2.53,8.19-24.71,27.37-24.71,27.37sLine truncated
'cloud':`<svg id="b75ffde3-65ff-4761-a8e9-ebaeba35e9db" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="863.67584" height="787.45556" viewBox="0 0 863.67584 787.45556"><title>cloud_docs</title><rect x="674.64346" y="434.39412" width="2" height="140" fill="#f2f2f2"/><rect x="674.64346" y="281.39412" width="2" height="140" fill="#f2f2f2"/><rect x="674.64346" y="87.39412" width="2" height="140" fill="#f2f2f2"/><circle cx="675.64346" cy="434.39412" r="25" fill="#f2f2f2"/><circle cx="675.64346" cy="267.39412" r="47" fill="#f2f2f2"/><circle cx="675.64346" cy="87.39412" r="63" fill="#f2f2f2"/><circle cx="650.64346" cy="59.39412" r="10" fill="#d0cde1"/><circle cx="690.64346" cy="81.39412" r="6" fill="#d0cde1"/><circle cx="675.64346" cy="113.39412" r="6" fill="#d0cde1"/><path d="M558.5736,732.72005a443.16545,443.16545,0,0,1-168.1455-33.12793A356.65064,356.65064,0,0,1,194.67956,234.2889,285.75854,285.75854,0,0,1,567.183,77.58041,228.807,228.807,0,0,1,692.65905,375.84359,183.245,183.245,0,0,1,453.788,476.33431a146.79491,146.79491,0,0,1-80.502-191.35742,117.77062,117.77062,0,0,1,153.34668-64.51123,94.41622,94.41622,0,0,1,51.71875,122.938A75.64668,75.64668,0,0,1,479.7406,384.888a60.71786,60.71786,0,0,1-33.29786-79.14941A48.831,48.831,0,0,1,510.02331,278.991a39.264,39.264,0,0,1,21.50781,51.125,31.612,31.612,0,0,1-41.16064,17.31641,25.4898,25.4898,0,0,1-13.9624-33.19,20.59,20.59,0,0,1,26.812-11.27929,16.67345,16.67345,0,0,1,9.1333,21.71,13.5396,13.5396,0,0,1-17.62891,7.4165,11.03155,11.03155,0,0,1-6.043-14.36377,9.02471,9.02471,0,0,1,11.752-4.94384,7.42021,7.42021,0,0,1,4.06445,9.66211,6.13434,6.13434,0,0,1-7.99023,3.36132,5.10967,5.10967,0,0,1-2.79883-6.65283,4.283,4.283,0,0,1,5.583-2.34863,3.62962,3.62962,0,0,1,1.98877,4.72705,3.10075,3.10075,0,0,1-4.043,1.70019,2.67971,2.67971,0,0,1-1.46924-3.49462,2.34588,2.34588,0,0,1,.52344-.78467,2.2689,2.2689,0,0,0-.73145.95556,3.10864,3.10864,0,0,0,1.70215,4.04639,4.13478,4.13478,0,0,0,5.38379-2.26465,5.41865,5.41865,0,0,0-2.96826-7.05566,7.02632,7.02632,0,0,0-9.145,3.84717,9.0309,9.0309,0,0,0,4.94629,11.75732,11.54031,11.54031,0,0,0,15.02246-6.31982,14.67283,14.67283,0,0,0-8.03662-19.10352,18.58956,18.58956,0,0,0-24.20557,10.18262,23.4616,23.4616,0,0,0,12.86572,30.583,29.61209,29.61209,0,0,0,38.5542-16.21924,37.26339,37.26339,0,0,0-20.41113-48.51856,46.83052,46.83052,0,0,0-60.97412,25.65088A58.718,58.718,0,0,0,480.49548,383.036a73.64788,73.64788,0,0,0,96.00439-40.38721A92.41557,92.41557,0,0,0,525.8778,222.31722a115.77022,115.77022,0,0,0-150.74023,63.41455,144.79736,144.79736,0,0,0,79.40527,188.751,181.24485,181.24485,0,0,0,236.26465-99.394A226.80605,226.80605,0,0,0,566.4281,79.43246C421.5448,20.35384,255.60974,90.161,196.53112,235.04378,122.63122,416.27523,209.952,623.84017,391.183,697.74056c226.666,92.42676,486.269-16.78515,578.69678-243.45215l1.85156.75586A446.43912,446.43912,0,0,1,564.39,732.68294C562.45153,732.70736,560.50915,732.72005,558.5736,732.72005ZM497.93982,320.27718a.3519.3519,0,0,0-.13428.02735.34584.34584,0,0,0-.18555.1875.6803.6803,0,0,0,.37305.8872,1.10164,1.10164,0,0,0,1.436-.603,1.62634,1.62634,0,0,0-.7207-2.03858l-.63916,1.56543A.33875.33875,0,0,0,497.93982,320.27718Z" transform="translate(-168.16208 -56.27222)" fill="#3f3d56"/><circle cx="489.56553" cy="232.87527" r="49.53794" fill="#2f2e41"/><polygon points="544.143 380.894 550.143 399.894 472.143 411.894 473.143 391.894 544.143 380.894" fill="#a0616a"/><path d="M621.30554,585.16634l-3,61s-17,31-15,75,1,45,1,45l-1,16,16,2v-21s23-58,25-87l23-87Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M734.30554,585.16634l3,61s17,31,15,75-1,45-1,45l1,16-16,2v-21s-23-58-25-87l-23-87Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M718.30554,450.16634s-72,11-83,8l-36,125s33,13,73,9a34.86506,34.86506,0,0,1,24,4c12,7,48-12,48-12l25-5Z" transform="translate(-168.16208 -56.27222)" fill="#2f2e41"/><rect x="496.64953" y="407.40309" width="80" height="91" fill="var(--main-accent)"/><rect x="484.63738" y="417.38516" width="80" height="91" fill="#f2f2f2"/><path d="M605.30554,389.16634l-1,14,18,62s7,44,21,70c0,0,8,40,23,35s-7-44-7-44l-16-60-19-79Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><circle cx="493.64346" cy="250.39412" r="30" fill="#a0616a"/><path d="M644.30554,324.16634s9,27-9,32-36,16-32,34l2,4,125-30a27.43931,27.43931,0,0,0-27-13s-27.807-.73647-25.9035-18.86823l-3.0965-7.13177Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M726.30554,362.16634s5,0,18,17,22,26,22,26,12,6,5,20-32,28-32,28-25,30-28,15,21-23,21-23,18-17,17-27l-41-34Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M601.30554,387.16634s124-29,127-29l6.29076,9.74735s-1.29076,7.25265-16.29076,9.25265c0,0,3,15-3,30v37s-70,8-76,14l-10.77042-53.41383s-14.22958-8.58617-24.22958-1.58617C604.30554,403.16634,600.30554,390.16634,601.30554,387.16634Z" transform="translate(-168.16208 -56.27222)" fill="#d0cde1"/><path d="M621.30554,780.16634sLine truncated
'secure':'<svg id="4687a0c2-bd42-4093-8bbc-92d10a889ded" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="866" height="720.37" viewBox="0 0 866 720.37"><defs><linearGradient id="01871234-c547-4dad-9a9e-117fcc2bb7d8" x1="660.5" y1="458.72" x2="660.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="705039c9-bbaf-489f-8754-0d88ff1d230d" x1="802.94" y1="120.14" x2="802.94" y2="40.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="f1b18cc7-88bb-4220-8574-cb67e65e6847" x1="802.94" y1="269.14" x2="802.94" y2="189.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="729136a4-2a29-4999-b297-e6b2d53009e3" x1="802.94" y1="417.14" x2="802.94" y2="337.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="7a22862c-2f66-4029-a70d-d91858435973" x1="475.1" y1="785.18" x2="475.1" y2="418.21" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="668716b8-0ce2-4b29-be6e-2467fe221975" x1="475.5" y1="810.19" x2="475.5" y2="750.5" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="032f3a0d-ef41-4ad6-8a52-90e2f3059f59" x1="459.55" y1="723.31" x2="459.55" y2="473.18" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/></defs><title>secure server</title><g opacity="0.8"><rect x="455" width="411" height="458.72" fill="url(#01871234-c547-4dad-9a9e-117fcc2bb7d8)"/></g><rect x="461.79" y="306.27" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="340.06" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="376.09" r="37.16" fill="var(--main-accent)"/><rect x="461.79" y="158.26" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="192.05" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="228.09" r="37.16" fill="var(--main-accent)"/><rect x="461.79" y="10.25" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="44.05" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="80.08" r="40.06" fill="url(#705039c9-bbaf-489f-8754-0d88ff1d230d)"/><circle cx="802.94" cy="229.08" r="40.06" fill="url(#f1b18cc7-88bb-4220-8574-cb67e65e6847)"/><circle cx="802.94" cy="377.08" r="40.06" fill="url(#729136a4-2a29-4999-b297-e6b2d53009e3)"/><circle cx="802.94" cy="80.08" r="37.16" fill="var(--main-accent)"/><g opacity="0.8"><path d="M750.13,785.18H200.07V434a15.8,15.8,0,0,1,15.8-15.8H734.32a15.8,15.8,0,0,1,15.8,15.8Z" transform="translate(-167 -89.81)" fill="url(#7a22862c-2f66-4029-a70d-d91858435973)"/></g><path d="M56.45,334.85h503.3a15.8,15.8,0,0,1,15.8,15.8V695.37a0,0,0,0,1,0,0H40.65a0,0,0,0,1,0,0V350.65A15.8,15.8,0,0,1,56.45,334.85Z" fill="#fff"/><rect x="57.87" y="355.82" width="500.46" height="328.25" fill="#f5f5f5"/><rect x="59.79" y="371.76" width="498.34" height="295.32" fill="#e0e0e0" opacity="0.8"/><rect x="59.79" y="356.07" width="498.34" height="15.69" fill="#f5f5f5"/><circle cx="70.47" cy="363.67" r="3.8" fill="#ff5252"/><circle cx="80.93" cy="363.67" r="3.8" fill="#ff0"/><circle cx="91.4" cy="363.67" r="3.8" fill="#69f0ae"/><text x="-167" y="-89.81"></text><g opacity="0.8"><path d="M765.52,808.77c-30.62,2.32-556.46,1.39-574.84,0C166.59,Line truncated
'robot':`<svg id="fd25c83a-acc9-441d-8dce-2bbb37970907" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1076.4" height="733.47" viewBox="0 0 1076.4 733.47"><defs><linearGradient id="bc224463-365b-4973-a542-782a8bb75c72" x1="487.42" y1="814.54" x2="487.42" y2="138.95" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="f10c32a2-f99a-4cbc-971d-c4e860c06767" x1="875.73" y1="813.03" x2="875.73" y2="187.56" gradientTransform="matrix(-1, 0, 0, 1, 1751.04, 0)" xlink:href="#bc224463-365b-4973-a542-782a8bb75c72"/></defs><title>robotics</title><path d="M1130.38,364.35h-91.94V475.79h85.22c.22,1.15.44,2.3.68,3.45h-85.9v89.85l.43-.14c2.43-.85,4.84-1.75,7.23-2.72l1.55-.62q3.66-1.53,7.23-3.23c.84-.39,1.68-.8,2.51-1.22a150,150,0,0,0,36.17-25c.31-.26.6-.55.89-.82,1.46-1.41,2.9-2.85,4.3-4.32.52-.54,1-1.08,1.54-1.64,1.65-1.79,3.24-3.64,4.79-5.5.23-.28.45-.56.68-.82A115.92,115.92,0,0,0,1121.34,498c.13-.26.24-.55.36-.82,1.26-2.82,2.39-5.68,3.37-8.59.41-1.22.81-2.45,1.16-3.68.13-.37.23-.74.34-1.1,5.09-17.84,5.68-36.4,5.68-54.85q0-3.23,0-6.45c0-7.45-.11-14.95-.24-22.44,0-1.94-.1-3.88-.14-5.83-.13-4.94-.3-9.9-.54-14.84-.06-1.37-.13-2.75-.21-4.11,0-.79-.08-1.58-.14-2.37Q1130.71,368.61,1130.38,364.35Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,240.27h50.44a343.41,343.41,0,0,0-26.83-32.84c-7.49-8.18-15.37-16.16-23.61-23.84Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,243.72V360.9h91.65c0-.34,0-.69-.09-1-.08-.93-.16-1.85-.25-2.77-.09-1.08-.21-2.17-.32-3.25s-.25-2.35-.39-3.52-.26-2.35-.41-3.52-.29-2.34-.45-3.5-.31-2.33-.48-3.49c-.14-1-.28-1.93-.43-2.89-.21-1.37-.43-2.73-.66-4.09,0-.2-.07-.41-.1-.6a253.15,253.15,0,0,0-6.75-29.42A201.46,201.46,0,0,0,1108.39,273a215,215,0,0,0-17.1-29.29Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M820.1,364.35V475.79h196.38V364.35Zm0,114.88V554.7c22.86-7.31,49-4.35,72.7,1.65,18.35,4.65,36.23,11,54.55,15.55a198.26,198.26,0,0,0,24.87,4.7l2.06.23,3,.27,3,.22a1.67,1.67,0,0,1,.31,0c.44,0,.89,0,1.34.06l.46,0c.54,0,1.08.06,1.62.06h0c.77,0,1.55,0,2.33.07h1c.77,0,1.55,0,2.33,0l1.71,0,1.38,0c.59,0,1.17,0,1.75-.06.94,0,1.88-.08,2.83-.14h0l1.75-.12c.72,0,1.45-.1,2.16-.17,1.53-.12,3-.27,4.55-.46.6-.07,1.21-.14,1.81-.23l.89-.11c1.51-.2,3-.43,4.51-.69,1.11-.17,2.22-.37,3.34-.58V479.23Zm146.06-239v3.44H820.1V360.9h196.38V243.71h-9.85v-3.44h9.85V164.54c-38.2-30.78-82.85-54.88-132.58-66a282.59,282.59,0,0,0-63.8-6.66V240.27Zm-449-.66.69.66H798.13V92.91c-49.9,4.15-98.56,20.91-137.79,48.68-18.85,13.33-35.78,29.14-56.93,39.36a89.15,89.15,0,0,1-35.6,9.2Zm-1.93-60.44v33.35l27.08-26.43C532.24,182.57,524.3,180.2,515.19,179.17Zm-.9,419.56.9,0V671.8q15.33-1.15,30.54-3.14A547.36,547.36,0,0,0,678,634.4h0q6.84-2.76,13.58-5.67,4.49-2,8.93-4,6.66-3.06,13.22-6.29A463.75,463.75,0,0,0,769.62,586c1.11-.76,2.23-1.52,3.33-2.29q3.31-2.3,6.59-4.64c1.09-.79,2.18-1.57,3.26-2.37l2.25-1.66c2.24-1.67,4.47-3.34,6.73-5h0c2.09-1.5,4.2-3,6.34-4.4v-86.4H572.94c.16-.5.31-1,.48-1.53-.18.51-.34,1-.49,1.53H515.19V598.67Zm.9-355V360.9H798.13V243.71Zm0,120.64V475.79H798.13V364.35ZM458.67,182.56l34.56,33.73V179Q475.86,180.08,458.67,182.56ZM218,364.35V475.79H493.23V364.35Zm66.52,275.24a511.15,511.15,0,0,0,129.73,30.78l.19,0q12.58,1.33,25.25,2.06l.31,0h0c17.69,1,35.45,1.23,53.18.57V479.23H218l1,128.32M141.79,475.79h54.28V364.35H174.6c-2.23,3.91-4.39,7.86-6.44,11.85-1.1,2.13-2.17,4.29-3.21,6.45C150.82,412,141.84,444.22,141.79,475.79Zm0,3.44c.44,22.64,5.54,44.87,16.73,65.4,9.3,17,22.22,32,37.51,45V479.23ZM176.61,360.9h19.46v-29A310.66,310.66,0,0,0,176.61,360.9ZM298.5,240.27H486.74l.68-.66-1-1h0L433.49,187A427.49,427.49,0,0,0,387,199.43c-1.44.47-2.88,1-4.31,1.45A422,422,0,0,0,298.5,240.27ZM218,305.64V360.9H459.38l0,0,0,0h33.83v-114l-3.86-3.14H293A378.08,378.08,0,0,0,218,305.64Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,302.83V360.9h91.65c0-.34,0-.69-.09-1-.08-.93-.16-1.85-.25-2.77-.09-1.08-.21-2.17-.32-3.25s-.25-2.35-.39-3.52-.26-2.35-.41-3.52-.29-2.34-.45-3.5-.31-2.33-.48-3.49c-.14-1-.28-1.93-.43-2.89-.21-1.37-.43-2.73-.66-4.09,0-.2-.07-.41-.1-.6a253.15,253.15,0,0,0-6.75-29.42Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1130.38,364.35h-91.94V475.79h85.22c.22,1.15.44,2.3.68,3.45h-85.9v89.85l.43-.14c2.43-.85,4.84-1.75,7.23-2.72l1.55-.62q3.66-1.53,7.23-3.23c.84-.39,1.68-.8,2.51-1.22a150,150,0,0,0,36.17-25c.31-.26.6-.55.89-.82,1.46-1.41,2.9-2.85,4.3-4.32.52-.54,1-1.08,1.54-1.64,1.65-1.79,3.24-3.64,4.79-5.5.23-.28.45-.56.68-.82A115.92,115.92,0,0,0,1121.34,498c.13-.26.24-.55.36-.82,1.26-2.82,2.39-5.68,3.37-8.59.41-1.22.81-2.45,1.16-3.68.13-.37.23-.74.34-1.1,5.09-17.84,5.68-36.4,5Line truncated
'icecream':`<svg id="acfec7e7-ff55-4f51-a7da-3052482bb043" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="814.10215" height="785" viewBox="0 0 814.10215 785"><title>ice_cream</title><path d="M663.893,822.88894l-229.21475.7592c-60.81041-18.2721-110.6323-81.736-110.94745-176.88721a269.465,269.465,0,0,1,3.23812-42.81223L511.59074,562.471l142.42432-31.995a283.262,283.262,0,0,1,29.2563,30.4193L547.08072,632.80761,482.83355,666.7338,555.028,641.82691l149.84346-51.69428a363.37031,363.37031,0,0,1,24.91622,44.67247l-45.211,46.72484L735.721,648.04757q.32788.76935.653,1.54117C768.78735,726.53874,728.57068,822.67471,663.893,822.88894Z" transform="translate(-192.94892 -57.5)" fill="#f2f2f2"/><path d="M626.22411,508.93981c-34.66294,9.9552-77.27875,22.53336-121.09467,35.60691C441.778,563.45453,375.92052,583.40809,327.9742,597.99381l.0313-.171a65.89939,65.89939,0,0,1,12.15994-28.22451c35.51716-46.17886,80.74259-78.4385,131.07659-90.4187h.00442a188.90614,188.90614,0,0,1,43.20151-5.1543C554.49386,473.89268,592.42573,486.45281,626.22411,508.93981Z" transform="translate(-192.94892 -57.5)" fill="#f2f2f2"/><ellipse cx="284.72861" cy="762.5" rx="156" ry="22.5" fill="#3f3d56"/><polygon points="229.229 552.5 229.229 587.5 254.229 590.5 269.229 571.5 257.229 547.5 229.229 552.5" fill="#fbbebe"/><polygon points="324.229 696.5 337.229 731.5 361.229 723.5 343.229 694.5 324.229 696.5" fill="#fbbebe"/><path d="M487.17753,456s-130-13-126,23,61,139,61,139l38-7s-27-42-25-56-6-33-6-33l19,3,11,17,23,118,35,106,26-7s-14-44-10-58-19-68-19-68l16-94s31-9,22-40S487.17753,456,487.17753,456Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M433.17753,634s-15-12-14-7a35.88236,35.88236,0,0,1,0,11l-14,31s-34,31-3,32,47-31,47-31,43-32,38-37-31.24173-16.48346-31.24173-16.48346S447.17753,641,433.17753,634Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M529.17753,773s-14-13-15-7-2,14-2,14l-21,20s-30,29-4,30a50.27149,50.27149,0,0,0,40-17s38-8,36-14-12.02812-29.212-16.51406-30.106S541.17753,781,529.17753,773Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><circle cx="288.22861" cy="223.5" r="31" fill="#fbbebe"/><path d="M467.17753,302s13,35,12,38,41-25,41-25-18-4-18-20Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M581.17753,353s18,18,21,24,71,82,71,82,45,53,27,54-41-42-41-42-64-47-67-58l-37-38Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M487.17753,332h-11l-15,24s-15,13-4,40,20,61,20,61,32,4,38,14,40,42,40,42,6-21,4-52,0-78,0-78l-2-62s-38.62342-8.95238-41.31171-7.47619S487.17753,332,487.17753,332Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M542.17753,324l15-3s16,10,31,39c0,0-32,21-35,20S542.17753,324,542.17753,324Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M450.17753,392s-23,37-16,51,16,13,16,13,17-3,17-17,7-15,7-15,44-36,27-36-40,25-40,25l-1-23Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M465.17753,355s-15,4-18,20-2,24-2,24l20-2Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M437.17753,435s16-21,18-18Z" transform="translate(-192.94892 -57.5)" opacity="0.1"/><path d="M496.92557,243.47071s-16.4399-16.42593-32.26693-5.39712-23.19733,21.533-21.43077,28.66652,6.70064,16.9912,6.70064,16.9912-3.29287-14.78823,10.01317-16.23678,31.49853-10.75491,31.49853-10.75491l13.15752,26.28707s1.60057-6.21312,6.53114-4.57193S511.35516,245.06778,496.92557,243.47071Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M384.43046,793.39447c0,14.58087-8.6676,19.672-19.36056,19.672q-.37157,0-.742-.00826c-.49543-.01062-.98615-.0342-1.47095-.06724-9.6502-.683-17.14766-6.03594-17.14766-19.59647,0-14.03354,17.93326-31.7415,19.28034-33.052l.00236-.00236c.05191-.05072.07786-.07549.07786-.07549S384.43046,778.8136,384.43046,793.39447Z" transform="translate(-192.94892 -57.5)" fill="var(--main-accent)"/><path d="M364.36451,810.83584l7.08105-9.89437-7.09875,10.98077-.01886,1.13594c-.49543-.01062-.98615-.0342-1.47095-.06724l.7632-14.58794-.0059-.11325.013-.02123.072-1.37775-7.11645-11.00791,7.13886,9.97459.01653.29253.5768-11.02205-6.09255-11.37476,6.16687,9.44024.6004-22.851.00236-.07785v.07549l-.10026,18.01937,6.06544-7.14358-6.0902,8.69591-.16043,9.86842,5.6632-9.4709-5.68678,10.923-.08965,5.48625,8.22171-13.18188L364.56269,798.633Z" transform="translate(-192.94892 -57.5)" fill="#3f3d56"/><path d="M824.25561,277.1656s-6.15258,19.13-15.75329,46.61588c-3.10475,8.92836-6.58171,18.7294-10.33044,29.04561-5.22251,14.36534-10.97441,29.73228-16.99808,45.09922-3.70576,9.45775-7.51178,18.90109-11.36076,28.12986-20.68952,49.46327-42.72393,92.3161-57.161,92.91711-32.43645,1.345-101.91689-215.681-109.50028-239.66146-.44364-1.40225-.67244-2.14622-.67244-2.14622Z" transform="translate(-192.94892 -57.5)" fill="#3f3d56"/><path d="M808.50232,323.78148c-3.10475,8.92836-6.58171,18.7294-10.33044,29.04561L712.25139,277.1656h43.31079Z" transform="translate(-192.94892 -57.5)" opacity="0.1"/><path d="M78Line truncated
'watching':`<svg id="bea18b66-86ba-42fe-9190-76147bd64da7" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1057" height="784.44" viewBox="0 0 1057 784.44"><defs><linearGradient id="e9b7614d-2d40-4533-bad7-5d0811b316bd" x1="890.27" y1="819.43" x2="890.27" y2="453.22" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient></defs><title>meditating</title><g opacity="0.2"><path d="M719.61,602a23.27,23.27,0,0,1-2.11,3.25Q707,597.9,696.12,590.78l.47-.93a20.53,20.53,0,0,1,7.27-7.92c4-2.46,8.51-3,12.22-1s5.78,6,6,10.67A20.6,20.6,0,0,1,719.61,602Z" transform="translate(-71.5 -57.78)" fill="#3f3d56"/><ellipse cx="717.85" cy="577.53" rx="17.05" ry="13.02" transform="translate(-200 883.82) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="727.6" cy="559.12" rx="17.05" ry="13.02" transform="translate(-178.54 882.64) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="737.35" cy="540.71" rx="17.05" ry="13.02" transform="translate(-157.08 881.47) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="747.1" cy="522.3" rx="17.05" ry="13.02" transform="translate(-135.63 880.29) rotate(-62.09)" fill="#3f3d56"/><path d="M770.75,373.5a61.7,61.7,0,0,1-1-8.58L802.67,376l-32.83-17.08a62.45,62.45,0,0,1,22.08-44.23l31.81,46L799,309.49a62.37,62.37,0,1,1,58.36,109.89,62.4,62.4,0,0,1,1,13.39l-50-.4,49.28,8.12a62.37,62.37,0,0,1-36.29,47.05,62.37,62.37,0,1,1-86.65-45.89,62.39,62.39,0,0,1,36.1-68.15Z" transform="translate(-71.5 -57.78)" fill="var(--main-accent)"/><path d="M851.14,459.71a62.14,62.14,0,0,1-29.84,27.83,62.37,62.37,0,1,1-86.65-45.89C732.23,428.36,854.42,453.52,851.14,459.71Z" transform="translate(-71.5 -57.78)" opacity="0.1"/></g><g opacity="0.2"><path d="M288.48,716.67a43.27,43.27,0,0,0,2.39,7.05q23.11-8.85,46.75-17c-.14-.64-.28-1.3-.44-2a39.53,39.53,0,0,0-9.83-18.22c-6.29-6.48-14.45-9.64-22.3-7.72s-13.63,8.47-16.24,17.09A39.55,39.55,0,0,0,288.48,716.67Z" transform="translate(-71.5 -57.78)" fill="#3f3d56"/><ellipse cx="303.33" cy="671.75" rx="25.07" ry="32.81" transform="translate(-222.12 33.29) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="293.82" cy="632.78" rx="25.07" ry="32.81" transform="translate(-213.16 29.93) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="284.31" cy="593.82" rx="25.07" ry="32.81" transform="translate(-204.19 26.56) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="274.8" cy="554.86" rx="25.07" ry="32.81" transform="translate(-195.22 23.2) rotate(-13.72)" fill="#3f3d56"/><path d="M300.88,266a118.65,118.65,0,0,0,5.81-15.56l-66.55,5.11,69.33-16.38a120.32,120.32,0,0,0-20.33-93L208.05,217l70.26-83.83A120.07,120.07,0,1,0,117.52,310.72a119.33,119.33,0,0,0-8.11,24.52L203,358.12,107.18,350A120.14,120.14,0,0,0,152.72,455a120.07,120.07,0,1,0,183.37-44.75A120.08,120.08,0,0,0,300.88,266Z" transform="translate(-71.5 -57.78)" fill="var(--main-accent)"/><path d="M110.16,388.94a119.51,119.51,0,0,0,42.56,66,120.07,120.07,0,1,0,183.37-44.75C346.88,386.55,107,375.85,110.16,388.94Z" transform="translate(-71.5 -57.78)" opacity="0.1"/></g><ellipse cx="532" cy="711.44" rx="525" ry="73" fill="var(--main-accent)" opacity="0.1"/><rect x="138" width="568" height="684" rx="19.8" fill="#464353"/><rect x="187" y="69" width="470" height="546" fill="var(--main-accent)" opacity="0.1"/><circle cx="422" cy="651" r="20" fill="#fff"/><circle cx="422" cy="40" r="9.5" fill="#fff"/><path d="M493.5,88.78a9,9,0,1,1-9,9,9,9,0,0,1,9-9m0-1a10,10,0,1,0,10,10,10,10,0,0,0-10-10Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M393.5,517.67V499.6h2.06v8.67l9.16-8.48a.79.79,0,0,1,.52-.19.86.86,0,0,1,.87.87V516.8a.86.86,0,0,1-.87.87.8.8,0,0,1-.54-.21l-9.14-8.41v8.62Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M405.24,500.09a.36.36,0,0,1,.37.37V516.8a.37.37,0,0,1-.58.29l-8.3-7.63-.87-.81.88-.8,8.29-7.68a.37.37,0,0,1,.21-.08m-10.18,0v17.07H394V500.09h1.07m10.18-1a1.33,1.33,0,0,0-.86.32l-8.32,7.7V499.6a.51.51,0,0,0-.51-.51H393.5a.51.51,0,0,0-.51.51v18a.51.51,0,0,0,.51.51h2.05a.51.51,0,0,0,.51-.51v-7.46l8.32,7.66a1.32,1.32,0,0,0,.86.31,1.35,1.35,0,0,0,1.37-1.36V500.46a1.36,1.36,0,0,0-1.37-1.37Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M591.46,517.67V509l-9.16,8.48a.82.82,0,0,1-.53.19.86.86,0,0,1-.6-.25.82.82,0,0,1-.27-.62V500.46a.87.87,0,0,1,1.42-.67l9.14,8.42V499.6h2l0,18.06,0,0Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M581.76,500.09a.32.32,0,0,1,.21.08l8.3,7.63.87.8-.87.81-8.3,7.68a.31.31,0,0,1-.21.07.36.36,0,0,1-.37-.36V500.46a.37.37,0,0,1,.37-.37m11.26,0v17.07H592V500.09H593m-11.26-1a1.36,1.36,0,0,0-1.37,1.37V516.8a1.36,1.36,0,0,0,1.37,1.36,1.33,1.33,0,0,0,.87-.31l8.32-7.71v7.51a.51.51,0,0,0,.51.51h2a.51.51,0,0,0,.52-.51v-18a.51.51,0,0,0-.52-.51h-2a.51.51,0,0,0-.51.51v7.47l-8.32-7.66a1.34,1.34,0,0,0-.87-.32Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M496.9,518a1.54,1.54,0,0,1-1.Line truncated
'':``,
'':``,
'':``,
'':``,
'':``,
},
}
},
mounted(){
},
methods: {
onMouseUp(){
}
}
}
</script>
<style type="text/css" scoped>
.svg-holder :deep(svg) {
width: 100%;
max-height: 250px;
}
</style>
@@ -0,0 +1,148 @@
<template>
<div class="button-fix">
<!-- Dropdown Button -->
<span v-if="activeTags.length == 0" v-on:click="openMenu()" class="ui basic button shrinking">
<i class="green tags icon"></i>
Tags
<i class="caret down icon"></i>
</span>
<!-- Remove Tag button -->
<span v-if="activeTags.length > 0" v-on:click="openMenu()" class="ui basic button shrinking">
<i class="green tag icon"></i>
{{ getActiveTag() }}
<i class="caret right icon"></i>
</span>
<!-- hidden dropdown menu -->
<div class="dropdown-menu" v-if="menuOpen">
<div class="ui raised segment">
<div class="ui very tight grid">
<div class="fourteen wide column">
<h2 class="ui header"><i class="small green tags icon"></i>Tags</h2>
</div>
<div class="two wide middle aligned center aligned column" v-on:click="closeMenu()">
<i class="link grey close icon"></i>
</div>
<div class="sixteen wide middle aligned column" v-if="loadedTags.length == 0">
Tags added to Notes will appear here.
</div>
<div class="row hover-row" v-for="tag in loadedTags" v-on:click="onClick(tag.id)" :class="{'green':(activeTags[0] == tag.id)}">
<div class="two wide center aligned column">
<i class="grey tag icon"></i>
</div>
<div class="twelve wide column">
{{ ucWords(tag.text) }}
</div>
<div class="two wide center aligned column">
{{tag.usages}}
</div>
</div>
</div>
</div>
</div>
<div class="shade" v-if="menuOpen" v-on:click="closeMenu()"></div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'TagDisplay',
props: [ 'activeTags' ],
data () {
return {
loadedTags: [],
menuOpen: false,
}
},
components: {
},
methods:{
closeMenu(){
this.menuOpen = false
},
openMenu(){
this.menuOpen = true
axios.post('/api/tag/usertags')
.then( ({data}) => {
this.loadedTags = data
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Fetch Tags') })
},
toggleActive(){
this.menuOpen = false
const current = this.activeTags[0]
this.onClick( current )
},
onClick(tagId){
this.menuOpen = false
this.$emit('tagClick', tagId)
},
ucWords(str){
return (str + '')
.replace(/^(.)|\s+(.)/g, function ($1) {
return $1.toUpperCase()
})
},
getActiveTag(){
let text = 'Tags'
if(this.activeTags.length == 0){
return text
}
this.loadedTags.forEach(tag => {
if( this.activeTags.includes(tag.id) ){
text = this.ucWords(tag.text)
}
})
return text
},
},
beforeMount(){
}
}
</script>
<style type="text/css">
.button-fix {
display: inline-block;
float: left;
}
.hover-row:hover {
cursor: pointer;
background-color: var(--menu-accent);
}
.dropdown-menu {
position: absolute;
/*width: 70vw;*/
top: 50px;
z-index: 1005;
left: 10px;
right: 10px;
/*min-width: 200px;*/
/*max-width: 100%;*/
width: 340px;
text-align: left;
}
.dropdown-menu .button {
margin: 0 5px 5px 0;
}
.shade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1004;
background-color: #0000008a;
width: 100vw;
height: 100vh;
}
</style>
@@ -0,0 +1,121 @@
<style type="text/css" scoped>
.colors {
position: fixed;
z-index: 1023;
top: 35px;
/*height: 100px;*/
width: 400px;
left: 20%;
}
.colors-container {
/*max-width: 360px;*/
display: flex;
/*flex-direction: column;*/
flex-wrap: wrap;
justify-content: center;
align-items: stretch;
align-content: stretch;
height: 250px;
width: 100%;
}
.dot {
/*display: inline-block;*/
border-radius: 30px;
box-shadow: 0px 0px 0px 1px inset #3e3e3e;
margin: 0 0 2px 2px;
cursor: pointer;
flex-basis: 9%;
height: 30px;
text-align: center;
}
.dot > i {
margin: 9px 0 0 0;
color: white;
text-shadow:
1px 1px 2px #3e3e3e,
1px -1px 2px #3e3e3e,
-1px 1px 2px #3e3e3e,
-1px -1px 2px #3e3e3e
;
}
.shade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1022;
background-color: transparent;
width: 100vw;
height: 100vh;
}
.big-shadow {
box-shadow: 0px 4px 5px 1px #a8a8a8;
}
@media only screen and (max-width: 740px) {
.colors {
position: fixed;
left: 5px;
right: -5px;
top: 5px;
width: 95%;
}
}
</style>
<template>
<div>
<div class="colors">
<div class="ui segment big-shadow">
<h3>Select Text Color</h3>
<div class="colors-container">
<span
v-for="(color,index) in colors"
class="dot"
v-on:click="onColorClick(index)"
:style="`background-color: ${color};`">
<i v-if="lastUsedColor == color" class="check icon"></i>
</span>
</div>
</div>
</div>
<div class="shade" v-on:click="close"></div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
export default {
components:{
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
},
props: [ 'lastUsedColor' ],
data: function(){
return {
hover: false,
colors: [null,'rgb(67,67,67)','rgb(102,102,102)','rgb(153,153,153)','rgb(183,183,183)','rgb(204,204,204)','rgb(217,217,217)','rgb(239,239,239)','rgb(243,243,243)','rgb(255,255,255)','rgb(152,0,0)','rgb(255,0,0)','rgb(255,153,0)','rgb(255,255,0)','rgb(0,255,0)','rgb(0,255,255)','rgb(74,134,232)','rgb(0,0,255)','rgb(153,0,255)','rgb(255,0,255)','rgb(230,184,175)','rgb(244,204,204)','rgb(252,229,205)','rgb(255,242,204)','rgb(217,234,211)','rgb(208,224,227)','rgb(201,218,248)','rgb(207,226,243)','rgb(217,210,233)','rgb(234,209,220)','rgb(221,126,107)','rgb(234,153,153)','rgb(249,203,156)','rgb(255,229,153)','rgb(182,215,168)','rgb(162,196,201)','rgb(164,194,244)','rgb(159,197,232)','rgb(180,167,214)','rgb(213,166,189)','rgb(204,65,37)','rgb(224,102,102)','rgb(246,178,107)','rgb(255,217,102)','rgb(147,196,125)','rgb(118,165,175)','rgb(109,158,235)','rgb(111,168,220)','rgb(142,124,195)','rgb(194,123,160)','rgb(166,28,0)','rgb(204,0,0)','rgb(230,145,56)','rgb(241,194,50)','rgb(106,168,79)','rgb(69,129,142)','rgb(60,120,216)','rgb(61,133,198)','rgb(103,78,167)','rgb(166,77,121)','rgb(133,32,12)','rgb(153,0,0)','rgb(180,95,6)','rgb(191,144,0)','rgb(56,118,29)','rgb(19,79,92)','rgb(17,85,204)','rgb(11,83,148)','rgb(53,28,117)','rgb(116,27,71)','rgb(91,15,0)','rgb(102,0,0)','rgb(120,63,4)','rgb(127,96,0)','rgb(39,78,19)','rgb(12,52,61)','rgb(28,69,135)','rgb(7,55,99)','rgb(32,18,77)','rgb(76,17,48)'
],
}
},
beforeCreate: function(){
},
mounted: function(){
},
methods: {
close(){
this.$emit('close')
},
onColorClick(index){
this.$emit('color',this.colors[index])
this.$nextTick( () => {
this.close()
})
},
}
}
</script>
+40
View File
@@ -0,0 +1,40 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './stores/mainStore'
import 'fomantic-ui-css/components/reset.min.css'
import 'fomantic-ui-css/components/site.css'
import 'fomantic-ui-css/components/button.min.css'
import 'fomantic-ui-css/components/container.min.css'
import 'fomantic-ui-css/components/form.min.css'
import 'fomantic-ui-css/components/grid.min.css'
import 'fomantic-ui-css/components/header.min.css'
import 'fomantic-ui-css/components/icon.css'
import 'fomantic-ui-css/components/input.min.css'
import 'fomantic-ui-css/components/segment.min.css'
import 'fomantic-ui-css/components/label.min.css'
import './assets/semantic-helper.css'
import io from 'socket.io-client'
import EventBus from './EventBus'
import Helpers from './Helpers'
const socket = io({ path: '/socket' })
router.beforeEach((to, from, next) => {
document.title = to.meta.title + ' - Solid Scribe'
next()
})
const app = createApp(App)
app.config.globalProperties.$io = socket
app.config.globalProperties.$bus = EventBus
app.use(router)
app.use(store)
app.use(Helpers)
app.mount('#app')
@@ -0,0 +1,443 @@
const SquireButtonFunctions = {
data(){
return {
//active button states
activeBold: false,
activeItalics: false,
activeUnderline: false,
activeTitle: false,
activeList: false,
activeToDo: false,
activeColor: null,
activeCode: false,
activeSubTitle: false,
//
lastUsedColor: null,
}
},
methods: {
//
// Inside squire init function
//
pathChangeEvent(e){
//Reset all button states
this.activeBold = false
this.activeTitle = false
this.activeItalics = false
this.activeList = false
this.activeToDo = false
this.activeColor = null
this.activeUnderline = false
this.activeCode = false
this.activeSubTitle = false
if(e.path.indexOf('>U>') > -1 || e.path.search(/U$/) > -1){
this.activeUnderline = true
}
if(e.path.indexOf('>B>') > -1 || e.path.search(/B$/) > -1){
this.activeBold = true
}
if(e.path.indexOf('>I') > -1){
this.activeItalics = true
}
if(e.path.indexOf('fontSize=1.4em') > -1){
this.activeTitle = true
}
if(e.path.indexOf('fontSize=0.9em') > -1){
this.activeSubTitle = true
}
if(e.path.indexOf('OL>LI') > -1){
this.activeList = true
}
if(e.path.indexOf('UL>LI') > -1){
this.activeToDo = true
}
if(e.path.indexOf('CODE') > -1){
this.activeCode= true
}
const colorIndex = e.path.indexOf('color=')
if(colorIndex > -1){
//Get all digigs after color index, then limit to 3
let colors = e.path.substring(colorIndex).match(/\d+/g).slice(0,3)
const lastColor = `rgb(${colors.join(',')})`
this.activeColor = lastColor
this.lastUsedColor = lastColor
}
},
//
//Inside Squire Init
//
removeFormatting(){
this.selectLineIfNoSelect()
this.editor.removeAllFormatting()
},
//If nothing is selected, select the entire line
selectLineIfNoSelect(){
//Select entire line if range is not set
let selection = this.editor.getSelection()
if(selection.startOffset == selection.endOffset && selection.startContainer == selection.endContainer){
let squireRange = this.editor.createRange(
selection.startContainer, 0,
selection.endContainer, selection.commonAncestorContainer.textContent.length)
this.editor.setSelection(squireRange)
}
},
modifyFont(inSize){
this.selectLineIfNoSelect()
let fontInfo = this.editor.getFontInfo()
//Toggle font size between large and normal
if(fontInfo.size){
this.editor.setFontSize(null)
} else {
this.editor.setFontSize(inSize)
}
},
modifyColor(color){
this.selectLineIfNoSelect()
//Set color of font
this.editor.setTextColour(color)
this.lastUsedColor = color
},
applyLastUsedColor(){
this.modifyColor(this.lastUsedColor)
},
toggleList(type){
//Undo list if its already a lits
if(this.editor.hasFormat(type)){
this.editor.removeList()
return
}
if(type == 'ol'){
this.editor.makeOrderedList()
}
if(type == 'ul'){
this.editor.makeUnorderedList()
}
},
toggleUnderline(){
this.selectLineIfNoSelect()
if( this.editor.hasFormat('u') ){
this.editor.removeUnderline()
} else {
this.editor.underline()
}
},
toggleBold(){
this.selectLineIfNoSelect()
if( this.editor.hasFormat('b') ){
this.editor.removeBold()
} else {
this.editor.bold()
}
},
toggleItalic(){
this.selectLineIfNoSelect()
if( this.editor.hasFormat('i') ){
this.editor.removeItalic()
} else {
this.editor.italic()
}
},
modifyCode(){
this.selectLineIfNoSelect()
this.editor.toggleCode()
},
undoCustom(){
//The same as pressing CTRL + Z
// this.editor.focus()
// document.execCommand("undo", false, null)
this.editor.undo()
},
uncheckAllListItems(){
//
// Uncheck All List Items
//
//Fetch the container
let container = document.getElementById('squire-id')
this.$router.go(-1)
setTimeout(()=>{
Array.from( container.getElementsByClassName('active') ).forEach(item => {
item.classList.remove('active');
})
},600)
},
deleteCompletedListItems(){
//
// Delete Completed List Items
//
//Fetch the container
let container = document.getElementById('squire-id')
//Close menu if user is on mobile, then sort list
this.$router.go(-1)
setTimeout(()=>{
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
if(node.nodeName == 'UL'){
//Create two categories, done and not done list items
let undoneElements = document.createDocumentFragment()
//Go through each item in each list we found
node.childNodes.forEach( (checkListItem, index) => {
//Skip Embedded lists, they are handled with the list item above them. Keep lists with intented items together
if(checkListItem.nodeName == 'UL'){
return
}
//Check if list item has active class
const checkedItem = checkListItem.classList.contains('active')
//Check if the next item is a list, Keep lists with intented items together
let sublist = null
if(node.childNodes[index+1] && node.childNodes[index+1].nodeName == 'UL'){
sublist = node.childNodes[index+1]
}
//Push checked items and their sub lists to the done set
if(!checkedItem){
undoneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
undoneElements.appendChild( sublist.cloneNode(true) )
}
}
})
//Remove all HTML from node, push unfinished items, then finished below them
node.innerHTML = null
node.appendChild(undoneElements)
}
})
}, 600)
},
sortList(){
//
// Sort list, checked at the bottom, unchecked at the top
//
//Fetch the container
let container = document.getElementById('squire-id')
//Close menu if user is on mobile
this.$router.go(-1)
setTimeout(()=>{
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
if(node.nodeName == 'UL'){
//Create two categories, done and not done list items
let doneElements = document.createDocumentFragment()
let undoneElements = document.createDocumentFragment()
//Go through each item in each list we found
node.childNodes.forEach( (checkListItem, index) => {
//Skip Embedded lists, they are handled with the list item above them. Keep lists with intented items together
if(checkListItem.nodeName == 'UL'){
return
}
//Check if list item has active class
const checkedItem = checkListItem.classList.contains('active')
//Check if the next item is a list, Keep lists with intented items together
let sublist = null
if(node.childNodes[index+1] && node.childNodes[index+1].nodeName == 'UL'){
sublist = node.childNodes[index+1]
}
//Push checked items and their sub lists to the done set
if(checkedItem){
doneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
doneElements.appendChild( sublist.cloneNode(true) )
}
} else {
undoneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
undoneElements.appendChild( sublist.cloneNode(true) )
}
}
})
//Remove all HTML from node, push unfinished items, then finished below them
node.innerHTML = null
node.appendChild(undoneElements)
node.appendChild(doneElements)
}
})
},600)
},
calculateMath(){
//
// Find math in note and calculate the outcome
//
//Fetch the container
let container = document.getElementById('squire-id')
//Close menu if user is on mobile, then sort list
this.$router.go(-1)
// simple function that trys to evaluate javascript
const shittyMath = (string) => {
//Remove all chars but math chars
const cleanString = String(string).replace(/[a-zA-Z\s]*/g,'')
try {
return Function('"use strict"; return (' + cleanString + ')')();
} catch (error) {
console.log('Math Error: ', string)
return null
}
}
setTimeout(()=>{
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
const line = node.innerText.trim()
// = sign exists and its the last character in the string
if(line.indexOf('=') != -1 && (line.length-1) == line.indexOf('=')){
//Pull out everything before the formula and try to evaluate it
const formula = line.split('=').shift()
const output = shittyMath(formula)
//If its a number and didn't throw an error, update the line
if(!isNaN(output) && output != null){
//Since there is HTML in the line, splice in the number after the = sign
let equalLocation = node.innerHTML.indexOf('=')
let newLine = node.innerHTML.slice(0, equalLocation+1).trim()
newLine += ` ${output}`
newLine += node.innerHTML.slice(equalLocation+1).trim()
//Slam in that new HTML with the output
node.innerHTML = newLine
}
}
})
},600)
},
setText(inText){
this.editor.setHTML(inText)
// this.noteText = this.editor._getHTML()
// this.diffNoteText = this.editor._getHTML()
//Make sure all list items have draggable property
// let container = document.getElementById('squire-id')
// let listItems = container.getElementsByTagName('li')
// for(let itemIndex in listItems){
// // console.log(listItems[itemIndex])
// // listItems[itemIndex].setAttribute('draggable','true')
// }
// // console.log(listItems)
},
getText(){
return this.editor.getHTML()
},
insertDivide(){
this.editor.insertHTML(`<p><div class='divide'></div><br></p>`)
this.editor.focus()
this.editor.moveCursorToEnd()
},
insertTable(tall, wide){
console.log(`Table: ${wide} x ${tall}`)
//Insert a table
let tableSyntax = '<div>'
tableSyntax += '<table>'
for (let i = 0; i < tall; i++) {
tableSyntax += '<tr>'
for (let j = 0; j < wide; j++) {
tableSyntax += '<td><p><br></p></td>'
}
tableSyntax += '</tr>'
}
tableSyntax += '</table></div><p><br></p>'
this.editor.insertHTML(tableSyntax)
this.editor.focus()
this.editor.moveCursorToEnd()
this.$router.go(-1)
},
indentText(){
// Lists use increase list level, increase quote breaks numbering
if(this.activeList || this.activeToDo){
this.editor.increaseListLevel()
return
}
this.editor.increaseQuoteLevel()
},
outdentText(){
// Lists use increase list level, increase quote breaks numbering
if(this.activeList || this.activeToDo){
this.editor.decreaseListLevel()
return
}
this.editor.decreaseQuoteLevel()
},
},
}
export default SquireButtonFunctions
@@ -0,0 +1,256 @@
<template>
<div class="ui basic segment no-fluf-segment" ref="content">
<div class="ui stacking grid">
<div class="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 class="sub header">
<i class="green angle double up icon icon"></i>
<router-link
to="/bookmarklet">
Push any website to solid scribe
</router-link>
</div>
</div>
</h2>
<!-- subnav -->
<router-link
exact-active-class="green"
class="ui basic button shrinking"
to="/attachments">
<i class="open folder outline icon"></i>
All
</router-link>
<router-link
v-if="$store.getters.totals && $store.getters.totals['linkFiles']"
exact-active-class="green"
class="ui basic button shrinking"
to="/attachments/type/links">
<i class="linkify icon"></i>
Links
</router-link>
<router-link
v-if="$store.getters.totals && $store.getters.totals['otherFiles']"
exact-active-class="green"
class="ui basic button shrinking"
to="/attachments/type/files">
<i class="copy icon"></i>
Other Files
</router-link>
<router-link
v-if="$store.getters.totals && $store.getters.totals['archivedNotes']"
exact-active-class="green"
class="ui basic button shrinking"
to="/attachments/type/archived">
<i class="archive icon"></i>
Archived
</router-link>
<router-link
v-if="$store.getters.totals && $store.getters.totals['trashedNotes']"
exact-active-class="green"
class="ui basic button shrinking"
to="/attachments/type/trashed">
<i class="trash icon"></i>
Trashed
</router-link>
<router-link
v-if="$store.getters.totals && $store.getters.totals['sharedToNotes']"
exact-active-class="green"
class="ui basic button shrinking"
to="/attachments/type/shared">
<i class="send icon"></i>
Show Shared
</router-link>
</div>
<div class="sixteen wide column" v-if="searchParams.noteId">
<router-link class="ui green button" to="/attachments">
<i class="chevron circle left icon"></i>
Back to All
</router-link>
<div class="ui green button" v-on:click="openNote">
<i class="file outline icon"></i>
Open Note
</div>
<i v-if="loading" class="green sync alternate loading icon"></i>
</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 { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
components: {
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
},
data: function(){
return {
loading: false,
attachments: [],
searchParams: {},
loadedAttachmentsOffset: 0,
loadingBatchTimeout: null,
allLoaded: false,
}
},
beforeCreate: function(){
//
// Perform Login check
//
this.$root.loginGateway()
},
mounted: function(){
//Load more attachments on scroll
window.addEventListener('scroll', this.onScroll)
this.$io.on('update_note_attachments', () => {
this.reset()
this.searchAttachments()
})
//Mount notes on load if note ID is set
this.searchAttachments()
},
beforeUnmount(){
//Remove scroll event on destroy
window.removeEventListener('scroll', this.onScroll)
this.$io.removeListener('update_note_attachments')
},
watch:{
$route (to, from){
//Reset everything on route change
this.reset()
//Params are handled by search function
this.searchAttachments()
}
},
methods: {
openNote(){
const noteId = this.searchParams['noteId']
this.$router.push('/notes/open/'+noteId)
},
onScroll(){
clearTimeout(this.loadingBatchTimeout)
this.loadingBatchTimeout = setTimeout(() => {
//Distance to bottom of page
const bottomOfWindow =
Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop)
+ window.innerHeight
//height of page
const offsetHeight = this.$refs.content.clientHeight
//Determine percentage down the page
const percentageDown = Math.round( (bottomOfWindow/offsetHeight)*100 )
//If greater than 80 of the way down the page, load the next batch
if(percentageDown >= 80){
this.searchAttachments()
}
}, 50)
},
reset(){
this.attachments = []
this.loading = false
this.allLoaded = false
this.loadedAttachmentsOffset = 0
},
searchAttachments (){
if(this.loading || this.allLoaded){
return
}
delete this.searchParams.attachmentType
delete this.searchParams.noteId
//Set attchment type if in URL
if(this.$route.params.type){
this.searchParams.attachmentType = this.$route.params.type
}
// include files from shared notes or selected notes
this.searchParams.includeShared = false
if(this.$route.params.type == 'shared'){
this.searchParams.includeShared = true
}
//Set noteId in if in URL
if(this.$route.params.id){
this.searchParams.noteId = this.$route.params.id
}
//Offset of attchments to load
this.searchParams.offset = this.loadedAttachmentsOffset
if(this.allLoaded){
return
}
this.loading = true
axios.post('/api/attachment/search', this.searchParams)
.then( results => {
this.loading = false
if(results.data.length == 0){
this.allLoaded = true
return
}
//Load up the results
this.attachments.push(...results.data)
//Grab the next batch
this.loadedAttachmentsOffset += results.data.length
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Attachments') })
},
}
}
</script>
<style type="text/css" scoped>
.attachment-display-area {
width: 100%;
margin-top: 15px;
box-sizing: border-box;
padding: 0 5%;
}
</style>
@@ -0,0 +1,66 @@
<template>
<div class="text-container squire-box">
<h2 class="ui header">
<i class="green angle double up icon icon"></i>
<div class="content">
Push URL to Solid Scribe - Bookmarklet
<div class="sub header">Push any website to your file list.</div>
</div>
</h2>
<p>A bookmarklet is a small piece of code that can be run from a bookmark.</p>
<p>Use the bookmarklet below to push URLs of website to solid scribe for later</p>
<p>The bookmarklet works in a secure way and won't leak any data.</p>
<p>To install the bookmarklet, all you need to do is drag it to your bookmarks bar.</p>
<h2>
Drag the link below to your bookmarks.
</h2>
<h3>
<a :href="`${(bookmarkletscript)}`" class="ui huge text">Push to SolidScribe</a>
</h3>
</div>
</template>
<script>
import axios from 'axios'
export default {
components: {
},
data: function(){
return {
loading: true,
bookmarkletscript:'',
}
},
beforeCreate: function(){
// Perform Login check
this.$root.loginGateway()
},
mounted: function(){
this.getBookmarklet()
},
beforeUnmount(){
},
methods: {
getBookmarklet(){
this.loading = true
axios.post('/api/attachment/getbookmarklet')
.then( results => {
this.bookmarkletscript = results.data
})
.catch(error => { this.$bus.$emit('notification', 'Failed to get bookmarklet') })
},
}
}
</script>
@@ -0,0 +1,298 @@
<style>
.calendar {
width: 300px;
height: 400px;
display: inline-block;
}
.day {
width: 40px;
height: 40px;
border: 1px solid var(--border_color);
display: inline-block;
text-align: center;
font-size: 1.2em;
cursor: pointer;
}
.today {
font-weight: bold;
}
.day ~ .active {
background-color: var(--main-accent);
color: var(--body_bg_color);
}
.day ~ .has-data {
outline: #07f4f4;
outline-style: none;
outline-width: medium;
outline-style: none;
outline-offset: -4px;
outline-style: solid;
outline-width: 2px;
}
</style>
<template>
<div class="squire-box">
<h3>Cycle Tracking Beta
<span v-on:click="deleteData()">
<i class="clickable trash icon"></i>
</span>
</h3>
<div class="calendar">
<p>{{calendar.monthName}}, {{calendar.year}}</p>
<div v-for="day in calendar.weekdays" class="day">
{{ day }}
</div>
<div v-for="day in calendar.days" class="day"
:class="{
'today':day == calendar.today,
'active':dateCode == `${day}.${calendar.month}.${calendar.year}`,
'has-data':cycleData[`${day}.${calendar.month}.${calendar.year}`],
}"
v-on:click="openDayData(`${day}.${calendar.month}.${calendar.year}`)">
{{ day }}
</div>
</div>
<div class="ui form">
<h3><input type="text" v-model="dateCode" v-on:keypress.enter="openDayData(dateCode)"></h3>
<div class="field" v-for="field in fields">
<label>{{ field.label }}</label>
<div v-for="(value, key) in openDay">
<!-- float -->
<div v-if="field.type == 'float' && field.id == key" class="ui left icon input">
<input type="text" :placeholder="field.label" v-on:keyup="e => saveField(field.id, e.target.value)" :value="value">
<i class="thermometer half icon"></i>
</div>
<!-- range -->
<div v-if="field.type == 'range' && field.id == key">
<div :class="{green:(value == 1)}" v-on:click="saveField(field.id, 1)" class="ui button">1</div>
<div :class="{green:(value == 2)}" v-on:click="saveField(field.id, 2)" class="ui button">2</div>
<div :class="{green:(value == 3)}" v-on:click="saveField(field.id, 3)" class="ui button">3</div>
<div :class="{green:(value == 4)}" v-on:click="saveField(field.id, 4)" class="ui button">4</div>
<div :class="{green:(value == 5)}" v-on:click="saveField(field.id, 5)" class="ui button">5</div>
</div>
<!-- text area -->
<div v-if="field.type == 'text' && field.id == key">
<textarea rows="3" v-on:keyup="e => saveField(field.id, e.target.value)">
{{ value }}
</textarea>
</div>
</div>
</div>
<div class="ui green save button" v-on:click="saveDayData">
Save
</div>
</div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
name: 'CycleTracking',
components: {
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
},
data () {
return {
appWorkingDate: null,
fields:[],
defaultFields:[
{'type':'float','label':'Basil Temp', 'id':'BT'},
{'type':'range','label':'Cervical Mucus', 'id':'CM'},
{'type':'text','label':'Notes', 'id':'NO'},
],
cycleData: {},
dateCode: null,
openDay: [],
calendar: {
monthName: '',
month: '',
year: '',
days: [],
weekdays: ['S','M','T','W','T','F','S'],
today: 0,
},
}
},
beforeCreate() {
// Perform Login check
this.$root.loginGateway()
},
mounted(){
if(!this.$store.getters.getLoggedIn){
return
}
// setup date code
// day - month - year
const now = new Date()
this.appWorkingDate = now
const dateSetup = [
now.getDate(), // 1-31 (Day)
now.getMonth()+1, // 0-11 (Month)
now.getFullYear(), // 1888-2022 (Year)
]
this.dateCode = dateSetup.join('.')
this.setupCalendar(this.appWorkingDate)
this.fetchCycleData()
},
methods: {
saveField(fieldId, value){
this.openDay[fieldId] = value
},
openDayData(dateCode){
if(String(dateCode).split('.').length != 3){
console.log('Invalid Date Code')
return
}
this.dateCode = dateCode || this.dateCode
let currentDay = this.cycleData[this.dateCode] || {}
//Set up each entry empty or with current value
this.fields.forEach(field => {
currentDay[field.id] = currentDay[field.id] || ''
})
this.openDay = currentDay
},
saveDayData(){
// remove empty keys
let cleanDayData = {}
Object.keys(this.openDay).forEach(key => {
if(this.openDay[key] != ''){
cleanDayData[key] = this.openDay[key]
}
})
this.cycleData[this.dateCode] = cleanDayData
// Update calendar
this.setupCalendar(this.appWorkingDate)
this.saveCycleData()
},
fetchCycleData(){
axios.post('/api/cycle-tracking/get')
.then(({ data }) => {
if(data.hasOwnProperty("text")){
let appData = null
try {
appData = JSON.parse(data.text)
} catch(e) {
console.log('Didnt parse json')
}
console.log(appData)
this.cycleData = appData?.cycleData || {}
this.fields = appData?.fields || []
this.$nextTick(() => {
this.setupFields()
this.openDayData(this.dateCode)
})
}
})
.catch(error => { this.$bus.$emit('notification', error) })
},
saveCycleData(){
const appData = JSON.stringify({
fields: this.fields,
cycleData: this.cycleData,
})
axios.post('/api/cycle-tracking/save', { cycleData:appData })
.then(response => {
{ this.$bus.$emit('notification', 'Data Saved') }
})
.catch(error => { this.$bus.$emit('notification', error) })
},
deleteData(){
axios.post('/api/cycle-tracking/save', { cycleData:'' })
.then(response => {
{ this.$bus.$emit('notification', 'Data Deleted') }
})
},
setupFields(){
// push the first 3 default fields to users set
if(this.fields.length == 0){
for (let i = 0; i < 3; i++) {
this.fields.push(this.defaultFields[i])
}
}
},
setupCalendar(date){
// ------------
// setup calendar display
var y = date.getFullYear()
var m = date.getMonth()
var firstDay = new Date(y, m, 1);
var lastDay = new Date(y, m + 1, 0);
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
const currentYear = date.getFullYear();
const currentMonth = date.getMonth() + 1;
this.calendar.monthName = date.toLocaleString("en-US", { month: "long" });
this.calendar.year = currentYear
const daysInCurrentMonth = getDaysInMonth(currentYear, currentMonth);
const monthStartDay = firstDay.getDay()
let days = Array(monthStartDay).fill("."); // Pad days to start on correct weekday
for (let i = 0; i < daysInCurrentMonth; i++) {
days.push(i+1)
}
this.calendar.days = days
// set today
this.calendar.today = date.getDate()
this.calendar.month = date.getMonth()+1
/*
October 2022
S M T W T F S
1 2 3 4 5 6
7 8 9
*/
// -------
}
}
}
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="text-container squire-box">
<h3 class="ui dividing header">
<i class="green question circle icon"></i>
Help
</h3>
<p><u>Encrypted Data</u><br></p><blockquote><p>Note text, note title and the note search index is fully encrypted. <br></p><p><br></p><p>Encryption is the transformation of data into a form unreadable by anyone without the password. Its purpose is to ensure privacy by keeping the information hidden from anyone for whom it is not intended, even those who can see the encrypted data. If every note in the database was leaked, nothing would be readable. If the government asked for your notes, it would all be gibberish. <br></p></blockquote><p><br></p><p><u>Some Data is not encrypted</u><br></p><blockquote><p>To keep up ease of use, your Username, Files, Tags, Attachments and attachment search text is not encrypted.<br></p></blockquote><p><br></p><p><u>Searching</u><br></p><blockquote><p>Note text searching is limited to key words.<br></p><p>To allow for higher security, the complexity of the search index has been reduced. To make sure that every word of every note is fully encrypted, the search index is encrypted as well. <br></p></blockquote><p><br></p><p><u>Files</u><br></p><blockquote><p>The files section will show files that have been uploaded to notes or web pages that have been scraped from notes.<br></p><p><br></p><p>The text next to each file can be modified and found later with the search bar. <br></p><p>Files are limited to 5MB in size. <br></p><p><br></p><p>Files can be uploaded to notes. If its an image, the picture will be put into the note.<br></p><p>Images added to notes will have text scanned, so it can be searched (This isn't super accurate so don't rely to heavily on it.) The text can be updated at any time and searched later.<br></p></blockquote><p><br></p><p><u>Scratch Pad</u><br></p><blockquote><p>The Scratch Pad was designed to allow rapid input to a single note. Rather than junking up all your notes with random links, numbers or haikus, you can put them all in one place. <br></p></blockquote><p><br></p><p><u>Dark Theme</u><br></p><blockquote><p>Dark theme limits the amount of blue emitted by your screen. Less blue light at night is supposed to be helpful for falling asleep.<br></p><p>Here is some good research on the topic: <a href="https://justgetflux.com/research.html">https://justgetflux.com/research.html</a><br></p></blockquote><p><br></p><p><u>Text Editor Keyboard Shortcuts</u><br></p><blockquote><ul><li><b>Number List</b> - CTRL + SHIFT + 9<br></li><li><b>Todo List</b> - CTRL + SHIFT + 8<br></li><li><b>Underline</b> - CTRL + u<br></li><li><b>Bold</b> - CTRL + b<br></li><li><b>Quote</b> - CRTL + i<br></li><li><b>Undo</b> - CTRL + z<br></li><li><b>Redo</b> - CTRL + y<br></li><li><b>Indent</b> - TAB<br></li><li><b>Un-Indent</b> - SHIFT + TAB<br></li><li><b>Manual Save</b> - CTRL + S<br></li><li><b>Close Note</b> - ESC<br></li></ul></blockquote><p><br></p><p><u>Shared Notes</u><br></p><blockquote><p>Shared notes still respect privacy but use a different security scheme. Instead of encrypting the note with your password, a shared password is created. This note then uses <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">public-key cryptography</a> to share the note with other users, while still making it unreadable to anyone else. <br></p><ul><li>You can revoke any users access to a shared note at any time.<br></li><li>Notes you share can not be shared by other people.<br></li><li>If you turn off sharing, all users lose access and the note is reverted to the default security scheme.<br></li></ul><p><br></p><p>It is possible to generate a public URL for shared notes. This exposes the Encryption Key for the note. Anyone with the key to the note can read that note. The encryption key to a shared note can easily be changed by turning off sharing, then turning it back on. <br></p><p><u><br></u></p></blockquote><p><u>Links in notes</u><br></p><blockquote><p>Links put into notes are automatically scraped. This means the data from the link will be scanned to get an image and some text from the website to help make that link more accessible in the future. <br></p><p>You can edit the text of scarped links and any time and search for it later. <br></p></blockquote><p><br></p><p><u>Deleting Notes</u><br></p><blockquote><p>Deleting a note from the trash erases all note text and the note entry. It can not be recovered.<br></p><p>None of the files related to the note are deleted. They can be manually removed at any time.<br></p></blockquote><p><br></p><p><u>Daily Backups</u><br></p><blockquote><p>All notes are backed up, every night, at midnight. If there is data loss, it can be restored from a backup. If you experience some sort of cataclysmic data loss please contact the system administrator for a copy of your data or a restoration procedure. <br></p><p>All data in the backups remains encrypted and unreadable.<br></p></blockquote><p><br></p><p><u>Two Factor Authentication (2FA)</u><br></p><blockquote><p>Two factor authentication is an additional layer of security on top of your regLine truncated
<div class="ui grid">
<div class="center aligned sixteen wide column">
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'HelpPage',
}
</script>
+622
View File
@@ -0,0 +1,622 @@
<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;
height: 350px;
text-shadow:
1px 1px 1px rgba(69,69,69,0.1),
-1px -1px 1px rgba(69,69,69,0.1),
-1px 1px 1px rgba(69,69,69,0.1),
1px -1px 1px rgba(69,69,69,0.1)
;
}
.shine {
position: absolute;
width: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: none;
}
.spotlight {
background: rgba(255,255,255,0);
background: radial-gradient(circle at bottom, var(--main-accent) 0%, rgba(255,255,255,0) 60%);
z-index: 200;
}
.logo-display {
width: 140px;
height: auto;
}
.lightly-padded {
margin-top: 10px;
}
.massive-text {
color: white;
font-size: 4rem;
text-align: center;
}
div#app div.lightly-padded.home-main div.ui.centered.vertically.divided.stackable.grid div.row.hero.fadeBg div.sixteen.wide.middle.aligned.center.column h2.massive-text svg.logo-display path {
stroke: black !important;
stroke-width: 1px !important;
}
.blinking {
animation:blinkingText 1.5s linear infinite;
}
@keyframes blinkingText {
0%{ opacity: 0.9; }
50%{ opacity: 0; }
100%{ opacity: 0.9; }
}
.subtext {
text-align: center;
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 */
.green-text {
color: #3710a4;
}
.dont-pad-me {
margin-right: 0 !important;
margin-left: 0 !important;
}
.home-main img {
max-height: 250px;
}
.white-link {
text-decoration: underline;
color: white;
}
@media only screen and (max-width: 740px) {
.column > img {
max-height: 125px;
}
}
</style>
<template>
<div class="lightly-padded home-main">
<div class="ui centered vertically divided stackable grid">
<div class="row hero fadeBg" :style="{ 'height':(height+'px') }">
<!-- <div class="one wide large screen only column"></div> -->
<!-- desktop column - large screen only -->
<div class="sixteen wide middle aligned center aligned column" style="z-index: 500;">
<h2 class="massive-text">
<!-- <img class="logo-display" loading="lazy" src="/api/static/assets/logo.svg" alt="Solid Scribe Logo"> -->
<logo class="logo-display" color="var(--main-accent)" stroke="true" />
<br>
Solid Scribe
</h2>
<h3 class="subtext">
A free, secure, online note taking application<i class="i cursor icon blinking"></i>
</h3>
</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 v-for="i in jewelFacets" class="shine" :style="shineStyle(i)" v-bind:key="i"></div>
<div class="shine spotlight"></div>
</div>
<!-- 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">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/gardening.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/growth.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/icecream.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/investing.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/onboarding.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/robot.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/solution.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/watching.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/cloud.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/grandma.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/hamburger.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/idea.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/notebook.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/plan.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/secure.svg">
<img loading="lazy" width="10%" src="/api/static/assets/marketing/void.svg">
</div>
<!-- Go to notes button -->
<div class="row" v-if="$root.loggedIn">
<div class="sixteen wide middle algined center aligned column">
<h3>You are already logged in</h3>
<router-link class="ui huge green labeled icon button" to="/notes">
<i class="external alternate icon"></i>Go to Notes
</router-link>
</div>
</div>
<!-- Small login form -->
<div class="row" v-if="!$root.loggedIn">
<div class="sixteen wide middle algined column">
<div class="ui text container">
<h2>
<i class="plug icon"></i>
Sign Up Now - Only a Username and Password required
</h2>
<login-form :thin="true" />
</div>
</div>
</div>
<!-- Overview -->
<div class="middle aligned centered row">
<div class="six wide column">
<h2 class="ui dividing header">Powerful text editing and privacy</h2>
<h3>Easily edit, share and organize thousands of notes.</h3>
<h3>Feel safe knowing no one can read your notes but you.</h3>
<!-- <h3>Tools to organize and collaborate on thousands of notes while maintaining security and respecting your privacy.</h3> -->
</div>
<div class="four wide column">
<svg-displayer file="idea" alt="Explosion of New Ideas" />
</div>
</div>
<!-- theme selector -->
<div class="ui white row">
<div class="sixteen wide middle aligned column">
<div class="ui container">
<h2 style="color: var(--main-accent);">
Pick your theme
</h2>
<h3 v-if="$root.loggedIn">Go to settings to change theme</h3>
<div
v-for="color in themeColors"
v-bind:key="color"
class="ui small basic button"
:style="`background: linear-gradient(0deg, ${color} 4%, rgba(0,0,0,0) 5%);`"
v-on:click="setAccentColor(color)">
<logo style="width: 20px; height: auto;" :color="color" />
</div>
</div>
</div>
</div>
<!-- features list -->
<div class="top aligned centered row">
<!-- note features -->
<div class="six wide column">
<h1 class="ui center aligned dividing header"><i class="small green sliders horizontal icon"></i>App Features</h1>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey sticky note icon"></i>
<i class="bottom left corner teal plus icon"></i>
</i>
Create a million notes!
<div class="sub header">Create unlimited notes up to 5,000,000 characters long.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey tags icon"></i>
<i class="bottom left corner purple plus icon"></i>
</i>
Tag Notes
<div class="sub header">Add and edit tags on notes then search or sort by tag.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey search icon"></i>
<i class="bottom left corner orange font icon"></i>
</i>
Search Note Text
<div class="sub header">Search all notes, files, links and tags.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey search icon"></i>
<i class="bottom left corner pink paperclip icon"></i>
</i>
Search Attachments
<div class="sub header">Add text to Images and links than can be searched.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey cloud moon icon"></i>
<i class="bottom left corner red eye icon"></i>
</i>
Night Mode
<div class="sub header">Pure black night theme with an even darker flux theme.</div>
</div>
</h2>
</div>
<!-- editing features -->
<div class="six wide column">
<h1 class="ui center aligned dividing header"><i class="small green sliders horizontal icon"></i>Editing Features</h1>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey list icon"></i>
<i class="bottom left corner green check icon"></i>
</i>
Create To Do Lists
<div class="sub header">Create To Do lists that are always synced, work on mobile and can be sorted.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey file icon"></i>
<i class="bottom left corner blue pen icon"></i>
</i>
Formatting Tools
<div class="sub header">Bold, Underline, Title, Add Links, Add Tables, Color Text, Color Background and more.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey file icon"></i>
<i class="bottom left corner orange paint brush icon"></i>
</i>
Customized Colorful Notes
<div class="sub header">Color the background of notes and add colored icons to make them stand out.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey images icon"></i>
<i class="bottom left corner teal paperclip icon"></i>
</i>
Add Images
<div class="sub header">Upload images to notes, add search text to the images to find them later.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey users icon"></i>
<i class="bottom left corner olive exchange icon"></i>
</i>
Collaborative Note Editing
<div class="sub header">Notes instantly update in real time everywhere its open and anywhere its shared.</div>
</div>
</h2>
</div>
</div>
<div class="middle aligned centered row">
<!-- privacy features -->
<div class="six wide column">
<h1 class="ui center aligned dividing header"><i class="small green sliders horizontal icon"></i>Privacy Features</h1>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey lock icon"></i>
<i class="bottom left corner yellow key icon"></i>
</i>
Secure Notes
<div class="sub header">All note text is encrypted. No one can read your notes. None of your data is shared.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey search icon"></i>
<i class="bottom left corner orange font icon"></i>
</i>
Private Search
<div class="sub header">Search the contents of all your notes without compromising security.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey share alternate icon"></i>
<i class="bottom left corner share icon"></i>
</i>
Encrypted Sharing
<div class="sub header">Shared notes are still encrypted, only readable by you and the shared users.</div>
</div>
</h2>
<h2 class="ui header">
<div class="content">
<i class="icons">
<i class="grey tv icon"></i>
<i class="bottom left corner blue mobile icon"></i>
</i>
Two Factor Authentication
<div class="sub header">Enable two factor authentication for added peace of mind.</div>
</div>
</h2>
</div>
<div class="six wide column">
<svg-displayer file="onboarding" alt="Observe this chart" />
</div>
</div>
<div class="middle aligned centered row">
<div class="four wide right aligned column">
<svg-displayer file="secure" alt="So dang secure" />
</div>
<div class="six wide column">
<h2>Only you can read your notes. </h2>
<h3>Employees can not <a target="_blank" href="https://www.forbes.com/sites/zakdoffman/2019/01/30/facebook-has-just-been-caught-spying-on-users-private-messages-and-data-again/#1e27e00a31ce"> snoop your account</a>. No one can <a target="_blank" href="https://mashable.com/article/google-reading-your-emails-response/">read your data for advertising</a>. Notes are completely unreadable without your password.</h3>
</div>
</div>
<div class="middle aligned centered green row">
<div class="six wide column">
<h2>Extremely accessible - Nothing to install</h2>
<h3>Works on mobile or desktop browsers. <br>Behaves like an installed app on mobile phones.</h3>
</div>
<div class="four wide right aligned column">
<svg-displayer file="cloud" alt="Girl falling into the spiral of digital chaos" />
</div>
</div>
<div class="middle aligned centered row">
<div class="four wide right aligned column">
<svg-displayer file="robot" alt="Murder Robot in office environment" />
</div>
<div class="six wide column">
<h2>Secure Data Sharing</h2>
<h3>Share notes with friends without compromising privacy. The data remains encrypted with a shared password for you and people you invite to view it.</h3>
</div>
</div>
<!-- set -->
<div class="middle aligned centered row">
<div class="six wide column">
<h2>Leave your Ad Blockers turned on</h2>
<h3>SolidScribe doesn't load any trackers or ads. It was designed to run on
<a href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>, with
<a href="https://addons.mozilla.org/en-US/firefox/addon/ublock-origin/" target="_blank">an Ad Blocker</a> turned on. It even works with a
<a href="https://pi-hole.net/" target="_blank">Pi-hole</a> on the network.</h3>
</div>
<div class="four wide column">
<svg-displayer file="icecream" 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 right aligned column">
<img loading="lazy" width="100%" src="/api/static/assets/marketing/add.svg" alt="A shpere of newness">
</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 right aligned 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>
<br>
<br>
<br>
</div>
</div>
<div v-if="true" class="middle aligned centered row">
<div class="six wide column">
<h3>
<a target="_blank" href="https://www.maxg.cc">Solid Scribe was created by Max Gialanella</a>
</h3>
<p><a target="_blank" href="https://www.maxg.cc">Check out my Resume</a></p>
<p>OR</p>
<p><a target="_blank" href="http://blog.maxg.cc">Check out my Programming Blog</a></p>
<p>
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>Email me at <a href="mailto:[email protected]">[email protected]</a></p>
<p>If you want to help me out with hosting this application, I would love a small Bitcoin donation.</p>
<p>
<a href="https://btc3.trezor.io/address/3QYnnNKnYTcU82F8NJ1BrmzGU2zRndTyEG" target="_blank">
<img loading="lazy" width="160px" src="/api/static/assets/marketing/wallet.png" alt="3QYnnNKnYTcU82F8NJ1BrmzGU2zRndTyEG">
</a>
</p>
<p>Awesomely Generic Marketing Images - <a target="_blank" href="https://undraw.co/">https://unDraw.co/</a></p>
</div>
<div class="four wide column">
<svg-displayer file="watching" alt="Drinking the blood of the elderly" />
</div>
</div>
<div class="center aligned sixteen wide column">
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
</div>
</div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
export default {
name: 'WelcomePage',
components: {
'login-form':defineAsyncComponent(() => import('@/components/LoginFormComponent.vue')),
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
'svg-displayer':defineAsyncComponent(() => import('@/components/SvgDisplayer.vue')),
},
data(){
return {
height: null,
realInformation: false,
jewelFacets: 15,
themeColors: [
'#21BA45', //Green
'#b5cc18', //Lime
'#00b5ad', //Teal
'#2185d0', //Blue
'#7128b9', //Violet
'#a333c8', //Purple
'#e03997', //Pink
'#db2828', //Red
'#f2711c', //Orange
'#fbbd08', //Yellow
'#767676', //Grey
'#303030', //Black-almost
],
}
},
beforeCreate(){
//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.solidscribe.com')
// }
},
beforeMount(){
},
methods: {
shineStyle(i){
const farMax = 95 //85
const farMin = 83
const farOut = (Math.floor(Math.random() * (farMax - farMin + 1)) + farMin)
// const rotation = 360/this.jewelFacets
const rotMax = 360/this.jewelFacets
const rotMin = 320/this.jewelFacets
const rotation = (Math.floor(Math.random() * (rotMax - rotMin + 1)) + rotMin)
let style = `
background: linear-gradient(
${(i+1)*(rotation)}deg,
rgba(255,255,255,0) ${farOut}%,
rgba(255,255,255,0.1) ${farOut+1}%,
rgba(255,255,255,0.0) ${farOut+10}%
)
;`
// Remove whitespace - Make it 1 line
return style.replace(/\s+/g, '')
},
setAccentColor(color){
let root = document.documentElement
root.style.setProperty('--main-accent', color)
localStorage.setItem('main-accent', color)
if(!color || color == '#21BA45'){
localStorage.removeItem('main-accent')
}
},
showRealInformation(){
this.realInformation = !this.realInformation
if(this.realInformation){
this.$nextTick(() => {
this.$refs.real.scrollIntoView({'behavior':'smooth'})
})
}
}
}
}
</script>
@@ -0,0 +1,54 @@
<template>
<div class="ui basic segment no-fluf-segment">
<div class="ui stackable grid">
<div class="sixteen wide center aligned column">
<h2>Login / Register</h2>
</div>
<div class="ui sixteen wide column">
<div class="ui text container">
<div class="ui segment">
<h4 class="ui header">
<i class="plug icon"></i>
<div class="content">
To Register
<div class="sub header">Choose Any Username & password</div>
</div>
</h4>
<login-form />
</div>
<p>You will remain logged in on this browser, for 20 days or until you log out.</p>
</div>
</div>
</div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios';
export default {
name: 'Login',
components: {
'login-form':defineAsyncComponent(() => import('@/components/LoginFormComponent.vue')),
},
data () {
return {
enabled: false,
}
},
methods: {
}
}
</script>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
<template>
<div class="ui basic segment">
<div class="ui grid">
<div class="sixteen wide column">
<div class="ui text container">
<h2 class="ui dividing header">
Page Not Found
</h2>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'NotFoundPage',
props:[ 'message' ],
data () {
return {
items: []
}
},
methods: {
}
}
</script>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,764 @@
<template>
<div class="page-container">
<div class="ui grid" ref="content">
<div class="sixteen wide column">
<!-- :class="{ 'sixteen wide column':showOneColumn(), 'sixteen wide column':!showOneColumn() }" -->
<div class="ui stackable grid">
<div class="six wide column" v-if="$store.getters.totals && $store.getters.totals['totalNotes']">
<search-input />
</div>
<div class="ten wide column" :class="{ 'sixteen wide column':$store.getters.getIsUserOnMobile }">
<div class="ui basic button shrinking"
v-on:click="updateFastFilters(3)"
v-if="$store.getters.totals && ($store.getters.totals['youGotMailCount'] > 0)"
style="position: relative;">
<i class="green mail icon"></i>Inbox
<span class="tiny circular floating ui green label">+{{ $store.getters.totals['youGotMailCount'] }}</span>
</div>
<tag-display
:active-tags="searchTags"
v-on:tagClick="tagId => toggleTagFilter(tagId)"
/>
<div class="ui basic shrinking icon button" v-on:click="toggleTitleView()" v-if="$store.getters.totals && $store.getters.totals['totalNotes'] > 0">
<i v-if="titleView" class="th icon"></i>
<i v-if="!titleView" class="bars icon"></i>
</div>
</div>
<div class="eight wide column" v-if="showClear">
<!-- <fast-filters /> -->
<span class="ui fluid green button" @click="reset">
<i class="arrow circle left icon"></i>Show All Notes
</span>
</div>
</div>
</div>
<div class="sixteen wide column" v-if="searchTerm.length > 0 && !loadingInProgress">
<h2 class="ui header">
<div class="content">
{{ searchResultsCount.toLocaleString() }} notes with keyword "{{ searchTerm }}"
<div v-if="searchResultsCount == 0" class="sub header">
Search can only find key words. Try a single word search.
</div>
</div>
</h2>
</div>
<div v-if="fastFilters['onlyArchived'] == 1" class="sixteen wide column">
<h2>Archived Notes</h2>
</div>
<div class="sixteen wide column" v-if="fastFilters['onlyShowTrashed'] == 1">
<h2>Trash
<span>({{ $store.getters.totals['trashedNotes'] }})</span>
<div class="ui right floated basic button" data-tooltip="This doesn't work yet">
<i class="poo storm icon"></i>
Empty Trash
</div>
</h2>
</div>
<div class="sixteen wide column" v-if="fastFilters['onlyShowSharedNotes'] == 1">
<h2>Shared Notes</h2>
</div>
<div class="sixteen wide column" v-if="tagSuggestions.length > 0">
<h5 class="ui tiny dividing header"><i class="green tags icon"></i> Tags ({{ tagSuggestions.length }})</h5>
<div class="ui clickable green label" v-for="tag in tagSuggestions" v-on:click="tagId => toggleTagFilter(tag.id)">
<i class="tag icon"></i>
{{ tag.text }}
</div>
</div>
<!-- found attachments -->
<div class="sixteen wide column" v-if="foundAttachments.length > 0">
<h5 class="ui tiny dividing header"><i class="green folder open outline icon"></i> Files ({{ foundAttachments.length }})</h5>
<attachment-display
v-for="item in foundAttachments"
:item="item"
:key="item.id"
:search-params="{}"
/>
</div>
<!-- Note title card display -->
<div class="sixteen wide column">
<h3 v-if="$store.getters.totals && $store.getters.totals['totalNotes'] == 0 && fastFilters['notesHome'] == 1">
No Notes Yet. <br>Thats ok.<br><br> <br>
<img loading="lazy" width="25%" src="/api/static/assets/marketing/hamburger.svg" alt="Create a new note"><br>
Create one when you feel ready.
</h3>
<!-- Go to one wide column, do not do this on mobile interface -->
<div :class="{'one-column':( showOneColumn() )}">
<!-- render each section based on notes in set -->
<template v-for="section,index in noteSections" :key="index">
<div v-if="section.length > 0" class="note-card-section">
<h5 class="ui tiny dividing header"><i :class="`green ${sectionData[index][0]} icon`"></i>{{ sectionData[index][1] }}</h5>
<div class="note-card-display-area">
<note-title-display-card
v-on:tagClick="tagId => toggleTagFilter(tagId)"
v-for="note in section"
:ref="'note-'+note.id"
:onClick="openNote"
:data="note"
:title-view="titleView"
:currently-open="activeNoteId1 == note.id"
:key="note.id + note.color + '-' +note.title.length + '-' +note.subtext.length + '-' + note.tag_count + note.updated"
/>
</div>
</div>
</template>
<loading-icon v-if="loadingInProgress" message="Decrypting Notes" />
</div>
</div>
</div>
<note-input-panel
v-if="activeNoteId1 != null"
:key="activeNoteId1"
:noteid="activeNoteId1"
:url-data="$route.params"
/>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
name: 'SearchBar',
components: {
'note-input-panel': defineAsyncComponent(() => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue')),
'note-title-display-card': defineAsyncComponent(() => import('@/components/NoteTitleDisplayCard.vue')),
// 'fast-filters': defineAsyncComponent(() => import('@/components/FastFilters.vue')),
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
'tag-display':defineAsyncComponent(() => import('@/components/TagDisplayComponent.vue')),
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
},
data () {
return {
initComponent: true,
tagSuggestions:[],
searchTerm: '',
searchResultsCount: 0,
searchTags: [],
notes: [],
highlights: [],
searchDebounce: null,
fastFilters: {},
titleView: false,
//Load up notes in batches
firstLoadBatchSize: 10, //First set of rapidly loaded notes
batchSize: 25, //Size of batch loaded when user scrolls through current batch
batchOffset: 0, //Tracks the current batch that has been loaded
loadingBatchTimeout: null, //Limit how quickly batches can be loaded
loadingInProgress: false,
scrollLoadEnabled: true,
//Clear button is not visible
showClear: false,
initialPostData: null,
//Currently open notes in app
activeNoteId1: null,
activeNoteId2: null,
//Position determines how note is Positioned
activeNote1Position: 0,
activeNote2Position: 0,
lastVisibilityState: null,
foundAttachments: [],
sectionData: {
'pinned': ['thumbtack', 'Pinned'],
'archived': ['archive', 'Archived'],
'shared': ['envelope outline', 'Inbox'],
'sent': ['paper plane outline', 'Sent Notes'],
'notes': ['file','Notes'],
'highlights': ['paragraph', 'Found In Text'],
'trashed': ['poop', 'Trashed Notes'],
'tagged': ['tag', 'Tagged'],
},
noteSections: {
pinned: [],
archived: [],
shared:[],
sent:[],
notes: [],
highlights: [],
trashed: [],
tagged:[],
},
}
},
beforeMount(){
this.$root.loginGateway()
this.$io.on('new_note_created', noteId => {
//Do not update note if its open
if(this.activeNoteId1 != noteId){
this.$store.dispatch('fetchAndUpdateUserTotals')
this.updateSingleNote(noteId, false)
}
})
this.$io.on('note_attribute_modified', noteId => {
//Do not update note if its open
if(this.activeNoteId1 != noteId){
this.$store.dispatch('fetchAndUpdateUserTotals')
this.updateSingleNote(noteId, false)
}
})
//Update title cards when new note text is saved
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
//Do not update note if its open
if(this.activeNoteId1 != noteId){
this.updateSingleNote(noteId, true)
}
})
this.$bus.$on('update_single_note', (noteId) => {
//Do not update note if its open
if(this.activeNoteId1 != noteId){
this.updateSingleNote(noteId)
}
})
//Update totals for app
this.$store.dispatch('fetchAndUpdateUserTotals')
//Close note event
this.$bus.$on('close_active_note', ({noteId, modified}) => {
if(modified){
console.log('Just closed Note -> ' + noteId + ', modified -> ', modified)
}
//A note has been closed
if(this.$route.fullPath != '/notes'){
this.$router.push('/notes')
}
this.$store.dispatch('fetchAndUpdateUserTotals')
//Focus and animate if modified
this.updateSingleNote(noteId, modified)
})
this.$bus.$on('note_deleted', (noteId) => {
//Remove deleted note from set, its deleted
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key].forEach( (note, index) => {
if(note.id == noteId){
this.noteSections[key].splice(index,1)
this.$store.dispatch('fetchAndUpdateUserTotals')
return
}
})
})
})
this.$bus.$on('update_fast_filters', filterIndex => {
this.updateFastFilters(filterIndex)
})
//Event to update search from other areas
this.$bus.$on('update_search_term', sentInSearchTerm => {
this.searchTerm = sentInSearchTerm
this.search(true, this.batchSize)
.then( () => {
this.searchAttachments()
const postData = {
'tagText':this.searchTerm.trim()
}
this.tagSuggestions = []
axios.post('/api/tag/suggest', postData)
.then( response => {
this.tagSuggestions = response.data
})
// return
})
})
//Reload page content - don't trigger if load is in progress
this.$bus.$on('note_reload', () => {
if(!this.loadingInProgress){
this.reset()
}
})
window.addEventListener('scroll', this.onScroll)
//Close notes when back button is pressed
// window.addEventListener('hashchange', this.hashChangeAction)
//update note on visibility change
// document.addEventListener('visibilitychange', this.visibiltyChangeAction);
},
beforeUnmount(){
window.removeEventListener('scroll', this.onScroll)
// document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
this.$bus.$off('note_reload')
this.$bus.$off('close_active_note')
// this.$bus.$off('update_single_note')
this.$bus.$off('note_deleted')
this.$bus.$off('update_fast_filters')
this.$bus.$off('update_search_term')
//We want to remove event listeners, but something here is messing them up and preventing ALL event listeners from working
// this.$off() // Remove all event listeners
// this.$bus.$off()
},
mounted() {
//Open note on load if ID is set
if(this.$route.params.id > 1){
this.activeNoteId1 = this.$route.params.id
}
//Loads initial batch and tags
this.reset()
},
watch: {
'$route.params.id': function(id){
//Open note on ID, null id will close note
this.activeNoteId1 = id
}
},
methods: {
toggleTitleView(){
this.titleView = !this.titleView
},
showOneColumn(){
return this.$store.getters.getIsUserOnMobile
//If note 1 or 2 is open, show one column. Or if the user is on mobile
return (this.activeNoteId1 != null || this.activeNoteId2 != null) &&
!this.$store.getters.getIsUserOnMobile
},
openNote(id, event = null){
//Don't open note if a link is clicked in display card
if(event && event.target && event.target.nodeName){
const nodeClick = event.target.nodeName
if(nodeClick == 'A'){ return }
}
//Open note if a link was not clicked
this.$router.push('/notes/open/'+id)
return
},
toggleTagFilter(tagId){
this.searchTags = [tagId]
//Reset note set and load up notes and tags
if(this.searchTags.length > 0){
this.search(true, this.batchSize)
return
}
//If no tags are selected, reset entire page
this.reset()
},
onScroll(e){
clearTimeout(this.loadingBatchTimeout)
this.loadingBatchTimeout = setTimeout(() => {
//Detect distance scrolled down the page
const scrolledDown = window.pageYOffset + window.innerHeight
//Get height of div to properly detect scroll distance down
const height = document.getElementById('app').scrollHeight
//Load if less than 500px from the bottom
if(((height - scrolledDown) < 500) && this.scrollLoadEnabled && !this.loadingInProgress){
this.search(false, this.batchSize, true)
}
}, 30)
return
},
visibiltyChangeAction(event){
//Fuck this shit, just use web sockets
return
//@TODO - phase this out, update it via socket.io
//If user leaves page then returns to page, reload the first batch
if(this.lastVisibilityState == 'hidden' && document.visibilityState == 'visible'){
//Load initial batch, then tags, then other batch
this.search(false, this.firstLoadBatchSize)
.then( () => {
// return
})
}
this.lastVisibilityState = document.visibilityState
},
// @TODO Don't even trigger this if the note wasn't changed
updateSingleNote(noteId, focuseAndAnimate = true){
noteId = parseInt(noteId)
//Find local note, if it exists; continue
let note = null
if(this.$refs['note-'+noteId] && this.$refs['note-'+noteId][0] && this.$refs['note-'+noteId][0].note){
note = this.$refs['note-'+noteId][0].note
//Show that note is working on updating
this.$refs['note-'+noteId][0].showWorking = true
}
//Lookup one note using passed in ID
const postData = {
searchQuery: this.searchTerm,
searchTags: this.searchTags,
fastFilters:{
noteIdSet:[noteId]
}
}
//Note data must be fetched, then sorted into existing note data
axios.post('/api/note/search', postData)
.then(results => {
//Pull note data out of note set
let newNote = results.data.notes[0]
if(newNote === undefined){
return
}
if(note && newNote){
//go through each prop and update it with new values
Object.keys(newNote).forEach(prop => {
note[prop] = newNote[prop]
})
//Push new note to front if its modified or we want it to
if( focuseAndAnimate || note.updated != newNote.updated ){
// Find note, in section, move to front
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key].forEach( (searchNote, index) => {
if(searchNote.id == noteId){
//Remove note from location and push to front
this.noteSections[key].splice(index, 1)
this.noteSections[key].unshift(note)
return
}
})
})
this.$nextTick( () => {
//Trigger close animation on note
this.$refs['note-'+noteId][0].justClosed()
this.$refs['note-'+noteId][0].showWorking = false
})
}
}
//New notes don't exist in list, push them to the front
if(note == null){
this.noteSections.notes.unshift(newNote)
//Trigger close animation on note
if(this.$refs['note-'+noteId] && this.$refs['note-'+noteId][0]){
this.$refs['note-'+noteId][0].justClosed()
this.$refs['note-'+noteId][0].showWorking = false
}
}
if(this.$refs['note-'+noteId] && this.$refs['note-'+noteId][0]){
this.$refs['note-'+noteId][0].showWorking = false
}
//Trigger section rebuild
this.rebuildNoteCategorise()
})
.catch(error => {
console.log(error)
this.$bus.$emit('notification', 'Failed to Update Note')
})
},
searchAttachments(){
axios.post('/api/attachment/textsearch', {'searchTerm':this.searchTerm})
.then(results => {
this.foundAttachments = results.data
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Attachments') })
},
search(showLoading = true, notesInNextLoad = 10, mergeExisting = false){
return new Promise((resolve, reject) => {
//Don't double load note batches
if(this.loadingInProgress){
console.log('Loading already in progress')
return resolve(false)
}
//Reset a lot of stuff if we are not merging batches
if(!mergeExisting){
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key] = []
})
this.batchOffset = 0 // Reset batch offset if we are not merging note batches
}
this.searchResultsCount = 0
//Remove all filter limits from previous queries
delete this.fastFilters.limitSize
delete this.fastFilters.limitOffset
let postData = {
searchQuery: this.searchTerm,
searchTags: this.searchTags,
fastFilters: this.fastFilters,
}
//Save initial post data on first load
if(this.initialPostData == null){
this.initialPostData = JSON.stringify(postData)
}
//If post data is not the same as initial, show clear button
if(JSON.stringify(postData) != this.initialPostData){
this.showClear = true
}
if(notesInNextLoad && notesInNextLoad > 0){
//Create limit based off of the number of notes already loaded
postData.fastFilters.limitSize = notesInNextLoad
postData.fastFilters.limitOffset = this.batchOffset
}
//Perform search - or die
this.loadingInProgress = true
axios.post('/api/note/search', postData)
.then(response => {
// console.timeEnd('Fetch TitleCard Batch '+notesInNextLoad)
//Save the number of notes just loaded
this.batchOffset += response.data.notes.length
//Enable or disable scroll loading
this.scrollLoadEnabled = response.data.notes.length > 0
if(response.data.total > 0){
this.searchResultsCount = response.data.total
}
this.loadingInProgress = false
this.generateNoteCategories(response.data.notes, mergeExisting)
return resolve(true)
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Notes') })
})
},
rebuildNoteCategorise(){
let currentNotes = []
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key].forEach( note => {
currentNotes.push(note)
})
})
this.generateNoteCategories(currentNotes, false)
},
generateNoteCategories(notes, mergeExisting){
// Place each note in a category based on certain attributes and fast filters
//Reset all sections if we are not merging existing
if(!mergeExisting){
Object.keys(this.noteSections).forEach( key => {
this.noteSections[key] = []
})
}
//Sort notes into defined sections
notes.forEach(note => {
if(this.searchTerm.length > 0){
if(note.pinned == 1){
this.noteSections.pinned.push(note)
return
}
//Push to default note section
this.noteSections.notes.push(note)
return
}
//Display all tags in tag section
if(this.searchTags.length >= 1){
this.noteSections.tagged.push(note)
return
}
//Only show trashed notes when trashed
if(this.fastFilters.onlyShowTrashed == 1){
if(note.trashed == 1){
this.noteSections.trashed.push(note)
}
return
}
if(note.trashed == 1){
return
}
//Show archived notes
if(this.fastFilters.onlyArchived == 1){
if(note.pinned == 1 && note.archived == 1){
this.noteSections.pinned.push(note)
return
}
if(note.archived == 1){
this.noteSections.archived.push(note)
}
return
}
if(note.archived == 1){ return }
//Only show sent notes section if shared is selected
if(this.fastFilters.onlyShowSharedNotes == 1){
if(note.shared == 2){
this.noteSections.sent.push(note)
}
if(note.shareUsername != null){
this.noteSections.shared.push(note)
}
return
}
//Show shared notes on main list but not notes shared with you
if(note.shareUsername != null){ return }
// Pinned notes are always first, they can appear in the archive
if(note.pinned == 1){
this.noteSections.pinned.push(note)
return
}
//Push to default note section
this.noteSections.notes.push(note)
return
})
},
reset(){
this.showClear = false
this.scrollLoadEnabled = true
this.searchTerm = ''
this.searchTags = []
this.tagSuggestions = []
this.fastFilters = {}
this.foundAttachments = [] //Remove all attachments
this.updateFastFilters(5) //This loads notes
},
updateFastFilters(index){
//clear out tags
this.searchTags = []
this.tagSuggestions = []
this.loadingInProgress = false
this.searchTerm = ''
this.$bus.$emit('reset_fast_filters') //Clear out search
const options = [
'withLinks', // 'Only Show Notes with Links'
'withTags', // 'Only Show Notes with Tags'
'onlyArchived', //'Only Show Archived Notes'
'onlyShowSharedNotes', //Only show shared notes
'onlyShowTrashed',
'notesHome',
]
let filter = {}
filter[options[index]] = 1
this.fastFilters = filter
//Fetch First batch of notes with new filter
this.search(true, this.firstLoadBatchSize, false)
.then( r => this.search(false, this.batchSize, true))
}
}
}
</script>
<style type="text/css" scoped>
.detail {
float: right;
}
.note-card-display-area {
display: flex;
flex-wrap: wrap;
}
.display-area-title {
width: 100%;
display: inline-block;
}
.note-card-section {
/*padding-bottom: 15px;*/
}
.note-card-section + .note-card-section {
padding: 15px 0 0;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<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="sticky note outline icon"></i>
<div class="content">
The Scratch Pad
<div class="sub header">One place to put random junk</div>
</div>
</h2>
</div>
<div class="sixteen wide middle aligned column" v-if="quickNoteId > 0">
<div v-if="quickNoteId" v-on:click="openNoteEdit" class="ui compact basic button">
<i class="file outline icon"></i>
Open Note
</div>
<div class="ui compact basic right floated button shrinking" v-if="!showNewNoteConfirm" v-on:click="showNewNoteConfirm = true">
<i class="sync alternate reload icon"></i>
New Scratch Pad
</div>
<div v-if="showNewNoteConfirm" class="ui compact basic right floated button shrinking" v-on:click="showNewNoteConfirm = false">
<i class="close icon"></i>
Cancel
</div>
<div v-if="showNewNoteConfirm" class="ui compact basic right floated button shrinking" v-on:click="newQuickNote()">
<i class="green thumbs up icon"></i>
Confirm
</div>
</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 (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>
Files
</div>
</div>
</div>
</div>
<div class="one wide column"></div>
<div class="fourteen wide column">
<div class="note-card-text" v-html="savedQuickNoteText"></div>
</div>
<div class="one wide column"></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,
showNewNoteConfirm: false,
}
},
beforeCreate: function(){
//
// Perform Login check
//
this.$root.loginGateway()
},
beforeMount(){
this.$io.on('new_note_created', noteId => {
this.getQuickNote()
})
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
this.getQuickNote()
})
},
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: {
newQuickNote(){
this.showNewNoteConfirm = false
axios.post('/api/quick-note/new')
.then( ({data}) => {
this.savedQuickNoteText = ''
this.quickNoteId = ''
})
},
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';
//Enter Key submits by default
if(event.keyCode == 13){
this.appendQuickNote()
return
}
},
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
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Update Quick Note') })
},
getQuickNote(){
axios.post('/api/quick-note/get')
.then( ({data}) => {
this.savedQuickNoteText = data.text
this.quickNoteId = data.id
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Fetch Quick Note') })
},
}
}
</script>
@@ -0,0 +1,284 @@
<template>
<div class="squire-box">
<div>
<h3 class="ui dividing header">
<i class="inline green cog icon"></i>
Settings for {{ $store.getters.getUsername }}
</h3>
<h4>New Scratch Pad</h4>
<div class="ui segment">
<p>Create a new scratch pad. Old scratch pad will turn into a normal note.</p>
<div class="ui compact basic button shrinking" v-if="!showNewNoteConfirm" v-on:click="showNewNoteConfirm = true">
<i class="sync alternate reload icon"></i>
New Scratch Pad
</div>
<div v-if="showNewNoteConfirm" class="ui compact basic button shrinking" v-on:click="showNewNoteConfirm = false">
<i class="close icon"></i>
Cancel
</div>
<div v-if="showNewNoteConfirm" class="ui compact basic button shrinking" v-on:click="newQuickNote()">
<i class="green thumbs up icon"></i>
Confirm
</div>
</div>
<!-- Accent Color -->
<h4 class="ui header">
Accent Color
</h4>
<div class="ui segment">
<div class="ui doubling grid">
<div class="sixteen wide column">
<p>Theme changes are only saved to this browser.</p>
<div
v-for="color in themeColors"
class="ui compact basic button"
:style="`background: linear-gradient(0deg, ${color} 4%, rgba(0,0,0,0) 5%);`"
v-on:click="setAccentColor(color)">
<logo style="width: 33px; height: auto;" :color="color" />
</div>
</div>
</div>
</div>
<!-- Enable Two Factor -->
<h4>Two Factor Authentication</h4>
<div class="ui segment">
<div class="ui stackable grid">
<div class="six wide column">
<div class="ui tiny dividing header">1. Enter Password and get QR</div>
<div class="ui fluid action input">
<input type="password" placeholder="Current Password" v-model="password">
<div v-if="password.length == 0" class="ui disabled button">
Get QR code
</div>
<div v-if="password.length > 0" class="ui green button" v-on:click="getQrCode()">
Get QR code
</div>
</div>
</div>
<div class="four wide column">
<div class="ui tiny dividing header">2. Scan QR Code</div>
<p v-if="qrCode == ''">(QR Code will appear here)</p>
<img v-if="qrCode != ''" :src="qrCode" class="ui image" alt="QR Code">
</div>
<div class="six wide column">
<div class="ui tiny dividing header">3. Verify with code</div>
<div class="ui fluid action input" v-if="qrCode != ''">
<input type="text" placeholder="Verification Code" v-model="verificationToken" v-on:keyup.enter="verifyQrCode()">
<div class="ui green button">
Verify!
</div>
</div>
<div class="ui fluid action input" v-if="qrCode == ''">
<input type="text" placeholder="Verification Code" >
<div class="ui disabled button">
Verify!
</div>
</div>
</div>
</div>
</div>
<!-- change password -->
<h4>Change Password</h4>
<div class="ui segment">
<div class="ui stackable grid">
<div class="five wide column">
<p>Current Password</p>
<div class="ui fluid input">
<input v-model="change1" type="password" placeholder="Current Password">
</div>
</div>
<div class="five wide column">
<p>New Password</p>
<div class="ui fluid input">
<input v-model="change2" type="password" placeholder="New Password">
</div>
</div>
<div class="six wide column">
<p>Rereat New Password</p>
<div class="ui fluid action input">
<input v-model="change3" type="password" placeholder="Repeat Password">
<div v-on:click="passwordChange()" class="ui button" :class="{'green':(change1.length > 0 && change2 == change3)}">
Change it!
</div>
</div>
</div>
</div>
</div>
<!-- log out -->
<h4>Revoke Other Active Sessions</h4>
<div class="ui segment">
<div class="ui stackable grid">
<div class="sixteen wide column">
<p>Revoke access on any logged in device, except for the one you are currently using.<br><br></p>
<div class="ui button" v-on:click="revokeAllSessions()">
<i class="sign out icon"></i>
Log Out all other devices
</div>
</div>
</div>
</div>
<h4>Export All Data (In Development)</h4>
<div class="ui segment">
<p>Download all files and notes in raw text or html</p>
<div class="ui button">Export all Data</div>
</div>
<h4>Delete Account (In Development)</h4>
<div class="ui segment">
<div class="ui stackable grid">
<div class="eight wide column">
<p>Delete all data. This can not be undone.</p>
<div class="ui fluid input">
<input type="password" placeholder="Current Password" v-model="password">
</div>
</div>
<div class="four wide bottom aligned column">
<div class="ui fluid button">Verify</div>
</div>
<div class="four wide bottom aligned column">
<div class="ui disabled fluid button">Delete Account</div>
</div>
</div>
</div>
<div class="ui grid">
<div class="center aligned sixteen wide column">
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
</div>
</div>
</div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios'
export default {
name: 'SettingsPage',
components: {
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
},
data () {
return {
password: '',
qrCode: '',
verificationToken: '',
showNewNoteConfirm: false,
themeColors: [
'#21BA45', //Green
'#b5cc18', //Lime
'#00b5ad', //Teal
'#2185d0', //Blue
'#7128b9', //Violet
'#a333c8', // "Purple"
'#e03997', //Pink
'#db2828', //Red
'#f2711c', //Orange
'#fbbd08', //Yellow
'#767676', //Grey
'#303030', //Black-almost
],
change1: '',
change2: '',
change3: '',
}
},
methods: {
newQuickNote(){
this.showNewNoteConfirm = true
axios.post('/api/quick-note/new')
.then( ({data}) => {
this.showNewNoteConfirm = false
this.$store.dispatch('fetchAndUpdateUserTotals')
this.$bus.$emit('notification', 'New Scratch Pad Created')
})
},
setAccentColor(color){
let root = document.documentElement
root.style.setProperty('--main-accent', color)
localStorage.setItem('main-accent', color)
if(!color || color == '#21BA45'){
localStorage.removeItem('main-accent')
}
},
getQrCode(){
axios.post('/api/user/twofactorsetup', { password:this.password })
.then(({data}) => {
this.qrCode = data
})
},
verifyQrCode(){
axios.post('/api/user/verifytwofactorsetuptoken', { password:this.password, token: this.verificationToken })
.then(({data}) => {
if(data == true){
//Two FA is set up
} else {
//It failed
}
})
},
passwordChange(){
if(this.change1 == '' || this.change2 == '' || this.change3 == ''){
this.$bus.$emit('notification', 'All Password Fields Required')
return
}
if(this.change1 == this.change2){
this.$bus.$emit('notification', 'Old password matches new password')
return
}
if(this.change2 != this.change3){
this.$bus.$emit('notification', 'New Passwords do not match')
return
}
const postData = {
'currentPass':this.change1,
'newPass':this.change3
}
axios.post('/api/user/changepassword', postData)
.then(({data}) => {
if(data){
this.$bus.$emit('notification', 'Success: Password Changed')
this.change1 = ''
this.change2 = ''
this.change3 = ''
} else {
this.$bus.$emit('notification', 'Failed to change password')
this.change1 = ''
}
})
},
revokeAllSessions(){
axios.post('/api/user/revokesessions')
.then(({data}) => {
this.$bus.$emit('notification', 'All other active sessions revoked.')
})
}
}
}
</script>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="ui grid">
<div class="sixteen wide column"></div>
<div class="sixteen wide column" v-if="text.length > 0 || title.length > 0">
<div class="ui text container">
<div class="ui segment" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
<h1 v-if="title">{{title}}</h1>
<div v-if="text" v-html="text" class="squire-box"></div>
</div>
</div>
</div>
<div class="sixteen wide column" v-if="!$store.getters.getLoggedIn">
<div class="ui text container">
<div class="ui segment">
<div class="ui grid">
<div class="three wide middle aligned center aligned column">
<img class="small-logo" loading="lazy" src="/api/static/assets/logo.svg" alt="Solid Scribe Logo">
</div>
<div class="thirteen wide column">
<!-- header -->
<h2 class="ui header">
<div class="content">
Solid Scribe is an easy, free, secure Note App
<div class="sub header">
Encrypted notes, only readable by you. Unless you share them.
</div>
</div>
</h2>
<!-- buttons -->
<div class="ui grid">
<div class="eight wide center aligned column">
<router-link class="ui compact green button" to="/login">
<i class="plug icon"></i>Sign Up
</router-link>
</div>
<div class="eight wide center aligned column">
<router-link class="ui compact green button" to="/">
<i class="comment outline icon"></i>
Learn More
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ui sixteen wide center aligned column">
<h4>{{ failText }}</h4>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'SharePage',
data(){
return {
title: '',
text: '',
failText: '',
styleObject:{},
}
},
beforeMount(){
//You can put something here for live updates
// this.$io.on
this.openNote()
},
methods:{
fail(){
this.failText = 'Failed to open Shared Note'
this.$bus.$emit('notification', 'Failed to Open Shared Note')
},
openNote(){
const noteId = this.$route.params.id
const sharedKey = this.$route.params.token
axios.post('/api/public/opensharednote', {noteId, sharedKey})
.then( ({data}) => {
if(data.success){
this.title = data.title
this.text = data.text
this.styleObject = data.styleObject
} else {
this.fail()
}
})
.catch(error => { this.fail() })
}
}
}
</script>
<style type="text/css" scoped>
.small-logo {
width: 100%;
height: auto;
}
</style>
@@ -0,0 +1,17 @@
<template>
<div class="text-container squire-box">
<h3 class="ui dividing header">
<i class="green info circle icon"></i>
Solid Scribe Terms of Use
</h3>
<p>Welcome to Solid Scribe’s web-based application at solidscribe.com.<br></p><p><br></p><p>Your use of the Site and/or participation in any of the Services offered through the Site, is subject to these Terms of Use.<br></p><p>By registering an account or using the Site you agree to be bound by these Terms of Use.<br></p><p><br></p><p>The Company reserves the right to change these Terms of Use from time to time by publishing them on the Site.<br></p><p>By using the Site, you agree to the most current version of these Terms of Use as posted on the Site.<br></p><p><br></p><p>All content on the Site is provided "as is." Solid Scribe is not liable for any informational errors, incompleteness, delays or any actions taken in reliance on information contained on the Site. The Company cannot and does not guarantee that any content on the Site will be entirely accurate or up-to-date at all times as the information on the Site originates from various sources, which may or may not be within the control of the Company.<br></p><p><br></p><p>The Company is not responsible for any omission, deletion, failure to store, improper delivery or untimely delivery of any information or material. The Company is not responsible for any reliance upon or damage arising from the use of the Site, including technical damage resulting from downloading or accessing any information or material provided.<br></p><p><br></p><p>You understand and agree that the Company is not responsible for any content posted by you or third parties. You further understand that the Company does not monitor the content posted by other users.<br></p><p><br></p><p>The Company reserves the right to remove content that the Company deems inappropriate for any reason what so ever without notice. The Company also reserves the right, in the Company’s sole discretion, to remove a user's privilege to post content on the Site.<br></p><p><br></p><p>The Company does not in any manner endorse the content of its users and cannot and does not vouch for its reliability or otherwise accept liability for it.<br></p><p><br></p><p>Minors (persons under 18 years of age), persons or organizations that cannot enter into binding contracts under applicable law, and those who may be prevented by contractual obligations from entering into binding contracts with Solid Scribe are not allowed to use this Site.<br></p><p><br></p><p>The Company has the right, but not the obligation, to take any of the following actions in the Company’s sole discretion at any time and for any reason without providing you any prior notice:<br></p><p><br></p><p>1. Restrict, suspend, modify or terminate access to the Site or all or any part of the Company’s Services;<br></p><p>2. Change, suspend or discontinue all or any part of the Company’s Services;<br></p><p>3. Refuse, move, modify or remove any material submitted to the Site by any person, for any reason;<br></p><p>4. Refuse, move, modify or remove any content available on the Site;<br></p><p>5. Deactivate or delete your account and all related information and files in your account;<br></p><p>6. Establish, discontinue or change general practices and limits concerning use of the Site and the Company’s Services.<br></p><p><br></p><p>You agree that the Company is not liable to you or any third party for taking any of these actions. Any registration or other eligibility requirements are in the sole discretion of the Company and may be modified for any reason at any time.<br></p><p><br></p><p>YOUR INFORMATION AND CONTENT<br></p><p><br></p><p>Any information you provide to Solid Scribe or other users during your registration or use of the Site (in any public message area, through any mail feature, or otherwise generated out of your use of the Site or the Services), is subject to the following:<br></p><p><br></p><p>(a) You are solely responsible for the content of your information, postings or Notes. The Company reserves the right to take any action with respect to such information that it deems necessary or appropriate in its sole discretion for any reason, including if the Company believes information is inappropriate for the Site or the Services, may create liability for the Company.<br></p><p><br></p><p>(b) You agree that your information will not, to the best of your knowledge: (i) violate any law (including without limitation those governing export control, consumer protection, unfair competition, anti-discrimination or false advertising); or (ii) be libelous, defamatory, obscene, abusive, violate a third party's right to privacy, otherwise violate any applicable local, state, national or international law, amount to spamming or be otherwise inappropriate. You may not post content that degrades others on the basis of gender, race, class, ethnicity, national origin, religion, sexual preference, disability or other classification. Epithets and other language intended to intimidate, unlawfully threaten, unlawfully harass or incite violence are alLine truncated
</div>
</template>
<script>
export default {
name: 'TermsPage',
}
</script>
+39
View File
@@ -0,0 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
const HomePage = () => import('@/pages/HomePage.vue')
const LoginPage = () => import('@/pages/LoginPage.vue')
const HelpPage = () => import('@/pages/HelpPage.vue')
const TermsPage = () => import('@/pages/TermsPage.vue')
const SettingsPage = () => import('@/pages/SettingsPage.vue')
const SharePage = () => import('@/pages/SharePage.vue')
const NotesPage = () => import('@/pages/NotesPage.vue')
const QuickPage = () => import('@/pages/QuickPage.vue')
const AttachmentsPage = () => import('@/pages/AttachmentsPage.vue')
const OverviewPage = () => import('@/pages/OverviewPage.vue')
const BookmarkletPage = () => import('@/pages/BookmarkletPage.vue')
const NotFoundPage = () => import('@/pages/NotFoundPage.vue')
const MetrictrackingPage = () => import('@/pages/MetrictrackingPage.vue')
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'HomePage', meta: { title: 'Home' }, component: HomePage },
{ path: '/login', name: 'LoginPage', meta: { title: 'Login' }, component: LoginPage },
{ path: '/notes', name: 'Note Page', meta: { title: 'Notes' }, component: NotesPage },
{ path: '/notes/open/:id', name: 'Open Note', meta: { title: 'Open Note' }, component: NotesPage },
{ path: '/search/tags/:tag', name: 'Search Notes', meta: { title: 'Search Notes' }, component: NotesPage },
{ path: '/notes/open/:id/menu/:openMenu', name: 'Open Note Menu', meta: { title: 'Open Note Menu' }, component: NotesPage },
{ path: '/help', name: 'Help', meta: { title: 'Help' }, component: HelpPage },
{ path: '/terms', name: 'Terms', meta: { title: 'Terms' }, component: TermsPage },
{ path: '/bookmarklet', name: 'Bookmarklet', meta: { title: 'Bookmarklet' }, component: BookmarkletPage },
{ path: '/settings', name: 'Settings', meta: { title: 'Settings' }, component: SettingsPage },
{ path: '/public/note/:id/:token', name: 'Share', meta: { title: 'Shared' }, component: SharePage },
{ path: '/quick', name: 'Quick', meta: { title: 'Scratch Pad' }, component: QuickPage },
{ path: '/attachments', name: 'Attachments', meta: { title: 'Attachments' }, component: AttachmentsPage },
{ path: '/attachments/note/:id', name: 'Attachments for Note', meta: { title: 'Attachments for Note' }, component: AttachmentsPage },
{ path: '/attachments/type/:type', name: 'Attachments by Type', meta: { title: 'Attachments by Type' }, component: AttachmentsPage },
{ path: '/overview', name: 'Overview of Notes', meta: { title: 'Overview of Notes' }, component: OverviewPage },
{ path: '/metrictrack', name: 'Metric Tracking', meta: { title: 'Metric Tracking' }, component: MetrictrackingPage },
{ path: '/:pathMatch(.*)*', name: 'Page Not Found', meta: { title: '404 Page Not Found' }, component: NotFoundPage },
],
})
+196
View File
@@ -0,0 +1,196 @@
import { createStore } from 'vuex'
import axios from 'axios'
export default createStore({
state: {
username: null,
nightMode: false,
isUserOnMobile: false,
fetchTotalsTimeout: null,
userTotals: null, // {} // setting this to object breaks reactivity
activeSessions: 0,
},
mutations: {
setUsername(state, username){
localStorage.removeItem('username') //We only want one login token per computer
localStorage.setItem('username', username)
state.username = username
},
destroyLoginToken(state){
//Remove login token from local storage and from headers
localStorage.removeItem('loginToken')
localStorage.removeItem('username')
localStorage.removeItem('currentVersion')
localStorage.removeItem('snippetCache')
delete axios.defaults.headers.common['authorizationtoken']
state.username = null
state.userTotals = null
},
toggleNightMode(state, pastTheme){
const themes = {
'white':{
'body_bg_color': '#f1f1f1',//'#f5f6f7',
'small_element_bg_color': '#fff',
'text_color': '#3d3d3d',
'dark_border_color': '#d9d9d9',//'#DFE1E6',
'border_color': '#DFE1E6',
'menu-accent': '#cecece',
'menu-text': '#5e6268',
},
'black':{
'body_bg_color': 'rgb(12 4 30)',
//'#0f0f0f',//'#000',
'small_element_bg_color': '#000',
'text_color': '#FFF',
'dark_border_color': '#555',//'#ACACAC', //Lighter color to accent elemnts user can interact with
'border_color': '#505050',
'menu-accent': '#626262',
'menu-text': '#d9d9d9',
},
}
//Catch values not in set
const totalThemes = Object.keys(themes).length
state.nightMode++
if(state.nightMode > totalThemes-1){
state.nightMode = 0
}
if(pastTheme != null){
state.nightMode = pastTheme
}
//Final catch for numbers
if(Number.isInteger(parseInt(state.nightMode)) == false){
state.nightMode = 0
}
const currentTheme = Object.keys(themes)[state.nightMode]
//Toggle state and save to local storage
localStorage.setItem('nightMode', state.nightMode)
//Go through each color and set CSS variable
let root = document.documentElement
Object.keys( themes[currentTheme] ).forEach( attribute => {
root.style.setProperty('--'+attribute, themes[currentTheme][attribute])
})
},
detectIsUserOnMobile(state){
//Script from http://detectmobilebrowsers.com
//Create closure and pass in browser data and state
(function(a, state){
if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4)))
{
state.isUserOnMobile = true
}
})(navigator.userAgent||navigator.vendor||window.opera, state);
},
setSocketIoSocket(state, socket){
//Put socket id in axios headers
axios.defaults.headers.common['socketId'] = socket
state.socket = socket
},
setUserTotals(state, totalsObject){
if(!state.userTotals){
state.userTotals = {}
}
// retain old values loaded on initial, extended options load
let oldMissingValues = {}
Object.keys(state.userTotals).forEach(key => {
if(!totalsObject[key] && totalsObject[key] !== 0){
oldMissingValues[key] = state.userTotals[key]
}
})
// combine old settings with updated settings
let oldAndNew = Object.assign(oldMissingValues, totalsObject)
state.userTotals = oldAndNew
//Set computer version from server
const currentVersion = localStorage.getItem('currentVersion')
if(currentVersion == null){
localStorage.setItem('currentVersion', totalsObject.currentVersion)
return
}
//If version is already set and it doesn't match the server, reload app
if(currentVersion != totalsObject.currentVersion){
localStorage.setItem('currentVersion', totalsObject.currentVersion)
location.reload(true)
}
// console.log('-------------')
// Object.keys(totalsObject).forEach( key => {
// console.log(key + ' -- ' + totalsObject[key])
// })
},
setActiveSessions(state, countData){
//Count of the number of active socket.io sessions for this user
state.activeSessions = countData
},
hideMetricTrackingReminder(state){
if(state.userTotals){
state.userTotals['showTrackMetricsButton'] = false
}
}
},
getters: {
getUsername: state => {
return state.username
},
getLoggedIn: state => {
let weIn = (state.username && state.username.length > 0)
return weIn
},
getIsNightMode: state => {
return state.nightMode
},
getIsUserOnMobile: state => {
return state.isUserOnMobile
},
getIsNoteSettingsOpen: state => {
return state.isNoteSettingsOpen
},
getSocket: state => {
return state.socket
},
totals: state => {
return state.userTotals
},
getActiveSessions: state => {
return state.activeSessions
}
},
actions: {
fetchAndUpdateUserTotals ({ commit, state }) {
clearTimeout(state.fetchTotalsTimeout)
state.fetchTotalsTimeout = setTimeout(() => {
// load extended options on initial load
let postData = {
extendedOptions: !state.userTotals
}
axios.post('/api/user/totals', postData)
.then( ({data}) => {
commit('setUserTotals', data)
})
.catch( error => {
if(error.response && error.response.status == 400){
commit('destroyLoginToken')
location.reload()
}
})
}, 100)
}
}
})
+79
View File
@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue({
template: {
transformAssetUrls: { includeAbsolute: false },
},
}),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:3000',
'/socket': { target: 'http://localhost:3001', ws: true },
},
},
})