mirror of
https://github.com/cupcakearmy/ora.git
synced 2024-12-21 23:56:31 +00:00
commit
2272c1c7ac
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,6 +3,7 @@ yarn-error.log
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
.cache
|
.cache
|
||||||
|
.parcel-cache
|
||||||
dist
|
dist
|
||||||
*.zip
|
*.zip
|
||||||
*.pem
|
*.pem
|
||||||
@ -11,4 +12,4 @@ web-ext-artifacts
|
|||||||
|
|
||||||
# Design
|
# Design
|
||||||
*.afphoto
|
*.afphoto
|
||||||
*.afdesign
|
*.afdesign
|
||||||
|
8
.parcelrc
Normal file
8
.parcelrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@parcel/config-webextension",
|
||||||
|
"transformers": {
|
||||||
|
"*.svelte": [
|
||||||
|
"parcel-transformer-svelte"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.8] 2021-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Options to dismiss
|
||||||
|
- Option for idle
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bugs
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Dependencies update
|
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
1. Bump version in `manifest.json` and `src/shared/footer.svelte`
|
1. Bump version in `manifest.json` and `src/dashboard/components/Footer.svelte`
|
||||||
2. Build with `yarn run dist`
|
2. Build with `pnpm run dist`
|
||||||
|
|
||||||
## Firefox
|
## Firefox
|
||||||
|
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
## Current
|
|
||||||
|
|
||||||
## Backlog
|
|
||||||
|
|
||||||
- Dark mode support
|
|
||||||
- Options
|
|
||||||
- Dashboard
|
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "Ora",
|
"name": "Ora",
|
||||||
"version": "0.7",
|
"version": "0.8",
|
||||||
|
|
||||||
"description": "See how much time you spend on each website and set limits",
|
"description": "See how much time you spend on each website and set limits",
|
||||||
"icons": {
|
"icons": {
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"scripts": ["./src/background/index.js"]
|
"scripts": ["./src/background/index.js"]
|
||||||
},
|
},
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
"page": "./src/options/index.html"
|
"page": "./src/dashboard/index.html#/options?nav=false"
|
||||||
},
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@ -33,5 +33,5 @@
|
|||||||
"js": ["./src/client/index.js"]
|
"js": ["./src/client/index.js"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"web_accessible_resources": ["./icons/watch.png", "./icons/watch-alt.png"]
|
"web_accessible_resources": ["./icons/watch.png", "./icons/watch-alt.png", "./src/dashboard/index.html"]
|
||||||
}
|
}
|
||||||
|
64
package.json
64
package.json
@ -3,43 +3,51 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Nicco",
|
"name": "Niccolo Borgioli",
|
||||||
"email": "hi@nicco.io",
|
"email": "hi@nicco.io",
|
||||||
"url": "https://nicco.io"
|
"url": "https://nicco.io"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/cupcakearmy/ora",
|
"homepage": "https://github.com/cupcakearmy/ora",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf dist .cache web-ext-artifacts ora.zip",
|
"clean": "rm -rf dist .parcel-cache web-ext-artifacts ora.zip",
|
||||||
"dev": "parcel watch --no-hmr manifest.json src/dashboard/index.html",
|
"dev": "parcel watch --target dev --no-hmr ./manifest.json",
|
||||||
"build": "parcel build --no-content-hash --no-source-maps --no-minify manifest.json src/dashboard/index.html",
|
"build": "parcel build --target prod ./manifest.json",
|
||||||
"dist": "rm -rf dist && yarn run build && yarn pack:ff && yarn run pack:zip",
|
"dist": "run-s clean build pack:*",
|
||||||
"pack:zip": "zip -r ./ora.zip dist/*",
|
"pack:zip": "zip -r ./ora.zip dist/prod/*",
|
||||||
"pack:ff": "web-ext build -s dist --overwrite-dest"
|
"pack:ff": "web-ext build -s dist/prod --overwrite-dest"
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"dev": {
|
||||||
|
"sourceMap": {
|
||||||
|
"inline": true,
|
||||||
|
"inlineSources": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prod": {}
|
||||||
},
|
},
|
||||||
"browserslist": [
|
|
||||||
"last 2 chrome versions",
|
|
||||||
"last 2 firefox versions"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^6.1.1",
|
"d3": "^7",
|
||||||
"dayjs": "^1.8.36",
|
"dayjs": "^1.10.7",
|
||||||
"dexie": "^3.0.2",
|
"dexie": "^3.2.0",
|
||||||
"faker": "^5.1.0",
|
"dexie-export-import": "^1.0.3",
|
||||||
"file-saver": "^2.0.2",
|
"faker": "5.5.2",
|
||||||
"joi": "^17.2.1",
|
"file-saver": "^2.0.5",
|
||||||
"lodash": "^4.17.20",
|
"joi": "^17.4.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"pretty-bytes": "^5.6.0",
|
||||||
"spectre.css": "^0.5.9",
|
"spectre.css": "^0.5.9",
|
||||||
"svelte-spa-router": "^3.0.0",
|
"svelte-spa-router": "^3.2.0",
|
||||||
"tailwindcss": "^1.8.10",
|
"tailwindcss": "^2",
|
||||||
"webextension-polyfill": "^0.6.0"
|
"webextension-polyfill": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/firefox-webext-browser": "^78.0.1",
|
"@parcel/config-webextension": "^2.0.1",
|
||||||
"@types/lodash": "^4.14.161",
|
"@parcel/core": "^2.0.1",
|
||||||
"parcel-bundler": "^1.12.4",
|
"@types/lodash": "^4.14.177",
|
||||||
"parcel-plugin-svelte": "^4.0.6",
|
"npm-run-all": "^4.1.5",
|
||||||
"parcel-plugin-web-extension": "^1.6.1",
|
"parcel": "^2.0.1",
|
||||||
"svelte": "^3.25.1",
|
"parcel-transformer-svelte": "^1.2.3",
|
||||||
"web-ext": "^5.2.0"
|
"svelte": "^3.44.2",
|
||||||
|
"web-ext": "^6.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
7351
pnpm-lock.yaml
generated
Normal file
7351
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,9 @@ import browser from 'webextension-polyfill'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import { dashboard } from '../shared/utils'
|
import { dashboard } from '../shared/utils'
|
||||||
import { insertLog, normalizeTimestamp, Limits, DB } from '../shared/db'
|
import { insertLog, normalizeTimestamp, DB } from '../shared/db'
|
||||||
import { getUsageForHost, percentagesToBool } from '../shared/lib'
|
import { getSettingsWithDefaults, getUsageForHost, percentagesToBool } from '../shared/lib'
|
||||||
|
import { DismissValidator, checkForErrors } from '../shared/validation'
|
||||||
|
|
||||||
browser.browserAction.onClicked.addListener(() => browser.tabs.create({ url: dashboard, active: true }))
|
browser.browserAction.onClicked.addListener(() => browser.tabs.create({ url: dashboard, active: true }))
|
||||||
|
|
||||||
@ -18,22 +19,33 @@ async function log() {
|
|||||||
const window = windows.find((window) => window.id === tab.windowId)
|
const window = windows.find((window) => window.id === tab.windowId)
|
||||||
return tab.active && window.focused
|
return tab.active && window.focused
|
||||||
})
|
})
|
||||||
.map(({ id, title, url }) => {
|
.map(({ url, audible, mutedInfo }) => {
|
||||||
const { host } = new URL(url)
|
const { host } = new URL(url)
|
||||||
return { id, title, host }
|
return { host, audio: audible && !mutedInfo.muted }
|
||||||
})
|
})
|
||||||
|
.filter((x) => x.host)
|
||||||
|
|
||||||
await Promise.all(
|
if (active.length === 0) return
|
||||||
active.map(({ host }) => {
|
|
||||||
if (host)
|
const settings = await getSettingsWithDefaults()
|
||||||
return insertLog({
|
let idle = false
|
||||||
timestamp: normalizeTimestamp(new Date()),
|
if (settings.idleTimeout > 0) {
|
||||||
host,
|
idle = dayjs(settings.lastActivity).add(settings.idleTimeout, 'minutes').isBefore(dayjs())
|
||||||
seconds: (frequency / 1000) | 0,
|
}
|
||||||
})
|
|
||||||
|
const inserted = active
|
||||||
|
.filter((tab) => !idle || tab.audio)
|
||||||
|
.map((tab) => {
|
||||||
|
return insertLog({
|
||||||
|
timestamp: normalizeTimestamp(new Date()),
|
||||||
|
host: tab.host,
|
||||||
|
seconds: (frequency / 1000) | 0,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
await Promise.all(inserted)
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteOldLogs() {
|
async function deleteOldLogs() {
|
||||||
@ -48,5 +60,19 @@ setInterval(deleteOldLogs, 5 * 60 * 1000) // Delete old logs every 5 minutes
|
|||||||
setInterval(log, frequency)
|
setInterval(log, frequency)
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
return getUsageForHost(message).then((percentages) => percentagesToBool(percentages))
|
switch (message.type) {
|
||||||
|
case 'check':
|
||||||
|
return getUsageForHost(message.host).then((percentages) => percentagesToBool(percentages))
|
||||||
|
case 'report':
|
||||||
|
DB.settings.put({ key: 'lastActivity', value: new Date() })
|
||||||
|
break
|
||||||
|
case 'dismiss':
|
||||||
|
const entry = {
|
||||||
|
host: message.host,
|
||||||
|
timestamp: new Date(),
|
||||||
|
duration: message.duration,
|
||||||
|
}
|
||||||
|
if (!checkForErrors(DismissValidator, entry)) DB.dismiss.put(entry)
|
||||||
|
break
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
98
src/client/App.svelte
Normal file
98
src/client/App.svelte
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<script>
|
||||||
|
import { blocked } from './blocked'
|
||||||
|
import { init } from './reporter'
|
||||||
|
import { buttons, dismiss } from './dismiss'
|
||||||
|
|
||||||
|
init()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrapper" class:hidden={!$blocked}>
|
||||||
|
<div>
|
||||||
|
<h1>Overtime</h1>
|
||||||
|
<div>You have no time left on this website.</div>
|
||||||
|
<hr />
|
||||||
|
<div class="dismiss">
|
||||||
|
<div>
|
||||||
|
<i> dismiss for... </i>
|
||||||
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
{#each buttons as button}
|
||||||
|
<!-- svelte-ignore a11y-missing-attribute -->
|
||||||
|
<a on:click={() => dismiss(button.duration)}>{button.label}</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
position: fixed;
|
||||||
|
color: #000;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: hsla(0, 0%, 100%, 0.975);
|
||||||
|
z-index: 999999999;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
user-select: none;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||||
|
'Helvetica Neue', sans-serif;
|
||||||
|
transition: all 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper.hidden {
|
||||||
|
/* display: none; */
|
||||||
|
bottom: -100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.wrapper {
|
||||||
|
background-color: hsl(0, 0%, 0%, 0.925);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper > div {
|
||||||
|
margin: 3rem auto;
|
||||||
|
max-width: 25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss .links {
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-top: -0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss .links a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1em;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 100ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss .links a:hover {
|
||||||
|
transform: translateX(-0.25em);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px inset currentColor;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
</style>
|
17
src/client/blocked.js
Normal file
17
src/client/blocked.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import browser from 'webextension-polyfill'
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
|
async function check(set) {
|
||||||
|
if (window.document.hidden) return
|
||||||
|
const isBlocked = await browser.runtime.sendMessage({
|
||||||
|
type: 'check',
|
||||||
|
host: window.location.host,
|
||||||
|
})
|
||||||
|
set(isBlocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blocked = new writable(false, (set) => {
|
||||||
|
check(set)
|
||||||
|
const interval = setInterval(() => check(set), 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
})
|
25
src/client/dismiss.js
Normal file
25
src/client/dismiss.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import * as duration from 'dayjs/plugin/duration'
|
||||||
|
dayjs.extend(duration)
|
||||||
|
|
||||||
|
import browser from 'webextension-polyfill'
|
||||||
|
|
||||||
|
import { isDev } from '../shared/utils'
|
||||||
|
import { blocked } from './blocked'
|
||||||
|
|
||||||
|
export const buttons = [
|
||||||
|
...(isDev ? [{ label: 'Dev', duration: dayjs.duration({ seconds: 5 }) }] : []),
|
||||||
|
{ label: '1 minute', duration: dayjs.duration({ minutes: 1 }) },
|
||||||
|
{ label: '5 minutes', duration: dayjs.duration({ minutes: 5 }) },
|
||||||
|
{ label: '15 minutes', duration: dayjs.duration({ minutes: 15 }) },
|
||||||
|
{ label: '1 hour', duration: dayjs.duration({ hours: 1 }) },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function dismiss(duration) {
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: 'dismiss',
|
||||||
|
duration: duration.asMilliseconds(),
|
||||||
|
host: window.location.host,
|
||||||
|
})
|
||||||
|
blocked.set(false)
|
||||||
|
}
|
@ -1,43 +1,5 @@
|
|||||||
import browser from 'webextension-polyfill'
|
import App from './App.svelte'
|
||||||
|
|
||||||
let wrapper
|
wrapper = window.document.createElement('div')
|
||||||
|
window.document.body.appendChild(wrapper)
|
||||||
function init() {
|
new App({ target: wrapper })
|
||||||
wrapper = window.document.createElement('div')
|
|
||||||
Object.assign(wrapper.style, {
|
|
||||||
display: 'none',
|
|
||||||
position: 'fixed',
|
|
||||||
top: '0',
|
|
||||||
left: '0',
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
zIndex: '999999999',
|
|
||||||
})
|
|
||||||
wrapper.classList.add('ora--wrapper')
|
|
||||||
wrapper.classList.add('hidden')
|
|
||||||
|
|
||||||
const inner = window.document.createElement('div')
|
|
||||||
Object.assign(inner.style, {
|
|
||||||
fontFamily: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif`,
|
|
||||||
margin: '3em auto',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '20em',
|
|
||||||
})
|
|
||||||
inner.innerHTML = `
|
|
||||||
<h1>Overtime ⏱</h1>
|
|
||||||
<p>You have no time left on this website 🥺</p>
|
|
||||||
`
|
|
||||||
wrapper.appendChild(inner)
|
|
||||||
window.document.body.appendChild(wrapper)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function check() {
|
|
||||||
if (window.document.hidden) return
|
|
||||||
const isBlocked = await browser.runtime.sendMessage(window.location.host)
|
|
||||||
wrapper.style.display = isBlocked ? 'initial' : 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
setInterval(check, 5000)
|
|
||||||
check()
|
|
||||||
|
19
src/client/reporter.js
Normal file
19
src/client/reporter.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import browser from 'webextension-polyfill'
|
||||||
|
|
||||||
|
let lastReported = 0
|
||||||
|
|
||||||
|
function logActivity() {
|
||||||
|
const now = Date.now()
|
||||||
|
// Limit reports to once every second
|
||||||
|
if (now - lastReported < 1000) return
|
||||||
|
lastReported = now
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: 'report',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
window.document.addEventListener('mousemove', logActivity, false)
|
||||||
|
window.document.addEventListener('keydown', logActivity, false)
|
||||||
|
window.document.addEventListener('scroll', logActivity, false)
|
||||||
|
}
|
@ -1,20 +1,41 @@
|
|||||||
<script>
|
<script>
|
||||||
import Router, { link } from 'svelte-spa-router'
|
import Router, { link, querystring } from 'svelte-spa-router'
|
||||||
|
|
||||||
|
import Toasts from './components/Toasts.svelte'
|
||||||
import Dev from './components/Dev.svelte'
|
import Dev from './components/Dev.svelte'
|
||||||
import Dashboard from './pages/Dashboard.svelte'
|
import Dashboard from './pages/Dashboard.svelte'
|
||||||
import Limits from './pages/Limits.svelte'
|
import Limits from './pages/Limits.svelte'
|
||||||
import Footer from '../shared/footer.svelte'
|
import Footer from './components/Footer.svelte'
|
||||||
|
import Options from './pages/Options.svelte'
|
||||||
|
|
||||||
import { isDev } from '../shared/utils'
|
import { isDev } from '../shared/utils'
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
'/': Dashboard,
|
'/': Dashboard,
|
||||||
|
'/options': Options,
|
||||||
'/limits': Limits,
|
'/limits': Limits,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: nav = new URLSearchParams($querystring).get('nav') !== 'false'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if isDev}
|
||||||
|
<Dev />
|
||||||
|
{/if}
|
||||||
|
<main>
|
||||||
|
{#if nav}
|
||||||
|
<div class="mb-8">
|
||||||
|
<a use:link href="/"><button class="btn">Dashboard</button></a>
|
||||||
|
<a use:link href="/limits"><button class="btn">Limits</button></a>
|
||||||
|
<a use:link href="/options"><button class="btn">Options</button></a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Router {routes} />
|
||||||
|
<Toasts />
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
@ -23,18 +44,3 @@
|
|||||||
max-width: 50em;
|
max-width: 50em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{#if isDev}
|
|
||||||
<Dev />
|
|
||||||
{/if}
|
|
||||||
<main>
|
|
||||||
<div class="mb-8">
|
|
||||||
<a href="../options/index.html"><button class="btn">Options</button></a>
|
|
||||||
<a use:link={'/'}><button class="btn">Dashboard</button></a>
|
|
||||||
<a use:link={'/limits'}><button class="btn">Limits</button></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Router {routes} />
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
|
@ -89,6 +89,8 @@
|
|||||||
onMount(render)
|
onMount(render)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svg bind:this={wrapper} preserveAspectRatio="xMidYMid meet" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
svg {
|
svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -98,5 +100,3 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<svg bind:this={wrapper} preserveAspectRatio="xMidYMid meet" />
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const format = 'YYYY-MM-DD'
|
const format = 'YYYY-MM-DD'
|
||||||
@ -16,10 +15,10 @@
|
|||||||
$: output(internal)
|
$: output(internal)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<input class="form-input input-sm" type="date" bind:value={internal} {name} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
input {
|
input {
|
||||||
width: 7rem !important;
|
width: 7rem !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<input class="form-input input-sm" type="date" bind:value={internal} {name} />
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import faker from 'faker'
|
import faker from 'faker/dist/faker.js'
|
||||||
import day from 'dayjs'
|
import day from 'dayjs'
|
||||||
import { range, random } from 'lodash'
|
import { range, random } from 'lodash'
|
||||||
|
|
||||||
import { insertLog, normalizeTimestamp, DB, clear as clearDB } from '../../shared/db'
|
import { insertLog, normalizeTimestamp, clear as clearDB } from '../../shared/db'
|
||||||
|
|
||||||
let loading = false
|
let loading = false
|
||||||
|
|
||||||
@ -21,6 +21,8 @@
|
|||||||
await insertLog({ host, timestamp, seconds })
|
await insertLog({ host, timestamp, seconds })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@ -36,6 +38,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="p-2">
|
||||||
|
<button class="btn btn-sm" class:loading disabled={loading} on:click={fill}>Add Random Data</button>
|
||||||
|
<button class="btn btn-sm btn-error" class:loading disabled={loading} on:click={clear}>Delete data</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
div {
|
div {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -43,8 +50,3 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="p-2">
|
|
||||||
<button class="btn btn-sm" class:loading disabled={loading} on:click={fill}>Add Random Data</button>
|
|
||||||
<button class="btn btn-sm btn-error" class:loading disabled={loading} on:click={clear}>Delete data</button>
|
|
||||||
</div>
|
|
||||||
|
14
src/dashboard/components/DurationInput.svelte
Normal file
14
src/dashboard/components/DurationInput.svelte
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script>
|
||||||
|
export let value
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if Array.isArray(value)}
|
||||||
|
<input type="number" class="form-input" placeholder="1" bind:value={value[0]} />
|
||||||
|
<select class="form-select" bind:value={value[1]}>
|
||||||
|
<option value="m">Minutes</option>
|
||||||
|
<option value="h">Hours</option>
|
||||||
|
<option value="d">Days</option>
|
||||||
|
<option value="w">Weeks</option>
|
||||||
|
<option value="M">Months</option>
|
||||||
|
</select>
|
||||||
|
{/if}
|
28
src/dashboard/components/FileUpload.svelte
Normal file
28
src/dashboard/components/FileUpload.svelte
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script>
|
||||||
|
let text = 'Select File'
|
||||||
|
|
||||||
|
export let file
|
||||||
|
let input
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (!input || !input.files.length) return
|
||||||
|
file = input.files[0]
|
||||||
|
text = file.name
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="btn">
|
||||||
|
{text}
|
||||||
|
<input bind:this={input} on:change={validate} class="input" accept="application/json" type="file" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
label {
|
||||||
|
width: 18em;
|
||||||
|
max-width: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
label input[type='file'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
16
src/dashboard/components/Footer.svelte
Normal file
16
src/dashboard/components/Footer.svelte
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<footer>
|
||||||
|
<small>
|
||||||
|
Made with ❤️ by
|
||||||
|
<a href="https://nicco.io" target="_blank" rel="noreferrer">🐘</a>
|
||||||
|
— v0.8 —
|
||||||
|
<a href="https://github.com/cupcakearmy/ora" target="_blank" rel="noreferrer">Source Code</a>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
@ -19,23 +19,23 @@
|
|||||||
end = new Date()
|
end = new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intervals = [
|
||||||
|
{ label: 'Year', set: set('year', 1) },
|
||||||
|
{ label: 'Month', set: set('month', 1) },
|
||||||
|
{ label: 'Week', set: set('week', 1) },
|
||||||
|
{ label: '3 Days', set: set('day', 3) },
|
||||||
|
{ label: 'Today', set: set('day', 0) },
|
||||||
|
]
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
onMount(() => set('day', 0)())
|
onMount(() => set('day', 0)())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.spacer {
|
|
||||||
width: 0.5em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm" on:click={all}>All</button>
|
{#each intervals as interval (interval.label)}
|
||||||
<button class="btn btn-sm" on:click={set('month', 1)}>Month</button>
|
<button class="btn btn-sm" on:click={interval.set}>{interval.label}</button>
|
||||||
<button class="btn btn-sm" on:click={set('week', 1)}>Week</button>
|
{/each}
|
||||||
<button class="btn btn-sm" on:click={set('day', 3)}>3 Days</button>
|
|
||||||
<button class="btn btn-sm" on:click={set('day', 0)}>Today</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@ -43,3 +43,9 @@
|
|||||||
<DateInput bind:date={end} />
|
<DateInput bind:date={end} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spacer {
|
||||||
|
width: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -21,7 +21,8 @@
|
|||||||
style={`width:${percentage}%;`}
|
style={`width:${percentage}%;`}
|
||||||
aria-valuenow={percentage}
|
aria-valuenow={percentage}
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuemax="100" />
|
aria-valuemax="100"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
{/each}
|
{/each}
|
||||||
|
@ -3,11 +3,14 @@
|
|||||||
import { cloneDeep } from 'lodash'
|
import { cloneDeep } from 'lodash'
|
||||||
|
|
||||||
import { DB } from '../../shared/db'
|
import { DB } from '../../shared/db'
|
||||||
|
import { checkForErrors, LimitValidator } from '../../shared/validation'
|
||||||
|
import DurationInput from './DurationInput.svelte'
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const init = { limit: ['1', 'h'], every: [1, 'd'] }
|
const init = { limit: ['1', 'h'], every: [1, 'd'] }
|
||||||
|
|
||||||
export let limit = null
|
export let limit = null
|
||||||
|
export let error = null
|
||||||
$: active = limit !== null
|
$: active = limit !== null
|
||||||
|
|
||||||
function add() {
|
function add() {
|
||||||
@ -23,6 +26,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
const errors = checkForErrors(LimitValidator, limit)
|
||||||
|
if (errors) {
|
||||||
|
error = errors
|
||||||
|
return
|
||||||
|
}
|
||||||
await DB.limits.put(limit)
|
await DB.limits.put(limit)
|
||||||
dispatch('update')
|
dispatch('update')
|
||||||
close()
|
close()
|
||||||
@ -45,25 +53,11 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="form-label">Rules</div>
|
<div class="form-label">Rules</div>
|
||||||
{#each limit.rules as { limit, every }, i}
|
{#each limit.rules as rule, i}
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input type="text" class="form-input" placeholder="1" bind:value={limit[0]} />
|
<DurationInput bind:value={rule.limit} />
|
||||||
<select class="form-select" bind:value={limit[1]}>
|
|
||||||
<option value="m">Minutes</option>
|
|
||||||
<option value="h">Hours</option>
|
|
||||||
<option value="d">Days</option>
|
|
||||||
<option value="w">Weeks</option>
|
|
||||||
<option value="M">Months</option>
|
|
||||||
</select>
|
|
||||||
<span class="input-group-addon">every</span>
|
<span class="input-group-addon">every</span>
|
||||||
<input type="text" class="form-input" bind:value={every[0]} />
|
<DurationInput bind:value={rule.every} />
|
||||||
<select class="form-select" bind:value={every[1]}>
|
|
||||||
<option value="m">Minutes</option>
|
|
||||||
<option value="h">Hours</option>
|
|
||||||
<option value="d">Days</option>
|
|
||||||
<option value="w">Weeks</option>
|
|
||||||
<option value="M">Months</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-error input-group-btn" on:click={del(i)}>X</button>
|
<button class="btn btn-error input-group-btn" on:click={del(i)}>X</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@ -73,6 +67,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div>
|
<div>
|
||||||
|
{#if error}
|
||||||
|
<span class="text-error">{error}</span>
|
||||||
|
{/if}
|
||||||
<button on:click={close} class="btn">Cancel</button>
|
<button on:click={close} class="btn">Cancel</button>
|
||||||
<button on:click={save} class="btn btn-primary">Save</button>
|
<button on:click={save} class="btn btn-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
17
src/dashboard/components/StorageQuota.svelte
Normal file
17
src/dashboard/components/StorageQuota.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script>
|
||||||
|
import pretty from 'pretty-bytes'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
let usage = null
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const estimate = await window.navigator.storage.estimate()
|
||||||
|
usage = pretty(estimate.usage)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if usage === null}
|
||||||
|
<span class="loading" />
|
||||||
|
{:else}
|
||||||
|
<span>Storage used: <span class="font-mono">{usage}</span></span>
|
||||||
|
{/if}
|
19
src/dashboard/components/Toast.svelte
Normal file
19
src/dashboard/components/Toast.svelte
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { scale } from 'svelte/transition'
|
||||||
|
|
||||||
|
export let toast
|
||||||
|
|
||||||
|
let show = true
|
||||||
|
onMount(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
show = false
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div class="toast toast-{toast.type}" transition:scale>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
24
src/dashboard/components/Toasts.svelte
Normal file
24
src/dashboard/components/Toasts.svelte
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script>
|
||||||
|
import { toasts } from '../toasts'
|
||||||
|
import Toast from './Toast.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
{#each $toasts as toast}
|
||||||
|
<div class="mt-2">
|
||||||
|
<Toast {toast} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
</style>
|
@ -3,16 +3,26 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
|
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
|
||||||
<link href="../../node_modules/tailwindcss/dist/tailwind.css" rel="stylesheet" />
|
<link href="../../node_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||||
|
'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
text-shadow: rgba(0, 0, 0, 0.01) 0 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<title>Ora</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root" />
|
||||||
<script src="./main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -53,21 +53,6 @@
|
|||||||
onMount(calculate)
|
onMount(calculate)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
table td,
|
|
||||||
table th {
|
|
||||||
padding: 0.25rem 0.06rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table td.same {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
table td :global(a:visited) {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex justify-between items-center mb-8">
|
||||||
<h2 class="text-2xl">Dashboard</h2>
|
<h2 class="text-2xl">Dashboard</h2>
|
||||||
<RangeChooser bind:start bind:end />
|
<RangeChooser bind:start bind:end />
|
||||||
@ -91,3 +76,18 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</table>
|
</table>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
table td,
|
||||||
|
table th {
|
||||||
|
padding: 0.25rem 0.06rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td.same {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td :global(a:visited) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
import { cloneDeep } from 'lodash'
|
||||||
|
|
||||||
import RulesEditor from '../components/RulesEditor.svelte'
|
import RulesEditor from '../components/RulesEditor.svelte'
|
||||||
import Rules from '../components/Rules.svelte'
|
import Rules from '../components/Rules.svelte'
|
||||||
@ -15,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function edit(id) {
|
function edit(id) {
|
||||||
limit = limits.find((limit) => limit.id === id)
|
limit = cloneDeep(limits.find((limit) => limit.id === id))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@ -30,12 +31,6 @@
|
|||||||
onMount(load)
|
onMount(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
td {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<RulesEditor bind:limit on:update={load} />
|
<RulesEditor bind:limit on:update={load} />
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
@ -70,3 +65,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="loading loading-lg" />
|
<div class="loading loading-lg" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
7
src/dashboard/pages/Options.svelte
Normal file
7
src/dashboard/pages/Options.svelte
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script>
|
||||||
|
import YourData from '../views/YourData.svelte'
|
||||||
|
import Settings from '../views/Settings.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Settings />
|
||||||
|
<YourData />
|
7
src/dashboard/toasts.js
Normal file
7
src/dashboard/toasts.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
|
export const toasts = writable([])
|
||||||
|
|
||||||
|
export function notify(message, type = 'success') {
|
||||||
|
toasts.update((toasts) => [...toasts, { message, type }])
|
||||||
|
}
|
47
src/dashboard/views/Settings.svelte
Normal file
47
src/dashboard/views/Settings.svelte
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
import { DB } from '../../shared/db'
|
||||||
|
import { getSettingsWithDefaults } from '../../shared/lib'
|
||||||
|
import { notify } from '../toasts'
|
||||||
|
|
||||||
|
let settings = null
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
settings = await getSettingsWithDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
await DB.settings.put({ key, value })
|
||||||
|
}
|
||||||
|
notify('Saved')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-2xl">Settings</h2>
|
||||||
|
{#if settings}
|
||||||
|
<form class="mt-2" on:submit|preventDefault={save}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Retention
|
||||||
|
<small>(Days)</small>
|
||||||
|
<input class="form-input" type="number" min="3" max="365" step="1" bind:value={settings.retention} />
|
||||||
|
</label>
|
||||||
|
<label class="form-label">
|
||||||
|
Idle Timeout
|
||||||
|
<small>(Minutes)</small>
|
||||||
|
<input class="form-input" type="number" min="0" step="1" bind:value={settings.idleTimeout} />
|
||||||
|
<p>Stop tracking after a certain period of idle behavior. <span class="font-mono">0</span> to disable.</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="loading loading-lg" />
|
||||||
|
{/if}
|
53
src/dashboard/views/YourData.svelte
Normal file
53
src/dashboard/views/YourData.svelte
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<script>
|
||||||
|
import { saveAs } from 'file-saver'
|
||||||
|
import dj from 'dayjs'
|
||||||
|
|
||||||
|
import FileUpload from '../components/FileUpload.svelte'
|
||||||
|
|
||||||
|
import { clear, DB } from '../../shared/db'
|
||||||
|
import { longPress } from '../../shared/lib'
|
||||||
|
import { notify } from '../toasts'
|
||||||
|
|
||||||
|
let file
|
||||||
|
let loading = false
|
||||||
|
|
||||||
|
async function exportDB() {
|
||||||
|
try {
|
||||||
|
loading = true
|
||||||
|
const blob = await DB.export()
|
||||||
|
const filename = `Ora [${dj().format('YYYY-MM-DD HH-mm-ss')}].json`
|
||||||
|
saveAs(blob, filename)
|
||||||
|
notify('Exported')
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDB() {
|
||||||
|
await clear()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDB() {
|
||||||
|
try {
|
||||||
|
loading = true
|
||||||
|
await clear()
|
||||||
|
await DB.import(file)
|
||||||
|
notify('Imported')
|
||||||
|
} catch (e) {
|
||||||
|
notify('Error importing', 'error')
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-2xl">Your Data</h2>
|
||||||
|
<div class="mt-2">
|
||||||
|
<FileUpload bind:file />
|
||||||
|
<button class="btn btn-primary" class:loading on:click={importDB} disabled={!file}>Import</button>
|
||||||
|
<button class="btn btn-primary" class:loading on:click={exportDB}>Export</button>
|
||||||
|
<button class="btn btn-error tooltip" class:loading data-tooltip="Hold to delete" use:longPress={clearDB}>
|
||||||
|
Delete all data
|
||||||
|
</button>
|
||||||
|
</div>
|
@ -1,109 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import browser from 'webextension-polyfill'
|
|
||||||
import { saveAs } from 'file-saver'
|
|
||||||
import dj from 'dayjs'
|
|
||||||
|
|
||||||
import FileUpload from './FileUpload.svelte'
|
|
||||||
import Footer from '../shared/footer.svelte'
|
|
||||||
|
|
||||||
import { dashboard, isDev } from '../shared/utils'
|
|
||||||
import { dump as dumpDB, load as loadDB, clear as clearDB, validate } from '../shared/db'
|
|
||||||
import { longPress } from '../shared/lib'
|
|
||||||
|
|
||||||
const DEFAULT = {
|
|
||||||
retention: 90,
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = DEFAULT
|
|
||||||
let uploaded
|
|
||||||
let disabled = true
|
|
||||||
|
|
||||||
async function read() {
|
|
||||||
settings = {
|
|
||||||
...DEFAULT,
|
|
||||||
...(await browser.storage.local.get()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function write() {
|
|
||||||
return browser.storage.local.set(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reset() {
|
|
||||||
await browser.storage.local.clear()
|
|
||||||
await read()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dump() {
|
|
||||||
const data = await dumpDB()
|
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json;charset=utf-8' })
|
|
||||||
const filename = `Ora [${dj().format('YYYY-MM-DD HH-mm-ss')}].json`
|
|
||||||
saveAs(blob, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clear() {
|
|
||||||
await clearDB()
|
|
||||||
alert('Done')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
await loadDB(uploaded)
|
|
||||||
alert('Imported')
|
|
||||||
} catch {
|
|
||||||
alert('Error importing')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
disabled = !validate(uploaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(read)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
main {
|
|
||||||
padding: 1em;
|
|
||||||
margin: auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 50em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<a href={dashboard} target={isDev ? '' : '_blank'}><button class="btn">Dashboard</button></a>
|
|
||||||
|
|
||||||
<h2 class="mt-8 text-2xl">Settings</h2>
|
|
||||||
<form class="mt-2" on:submit|preventDefault={write}>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">
|
|
||||||
Retention
|
|
||||||
<small>(Days)</small>
|
|
||||||
<input
|
|
||||||
id="retention"
|
|
||||||
class="form-input"
|
|
||||||
type="number"
|
|
||||||
min="3"
|
|
||||||
max="365"
|
|
||||||
step="1"
|
|
||||||
bind:value={settings.retention} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<button type="reset" class="btn" on:click={reset}>Reset</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<h2 class="mt-8 text-2xl">Your Data</h2>
|
|
||||||
<div class="mt-2">
|
|
||||||
<FileUpload bind:value={uploaded} />
|
|
||||||
<button class="btn btn-primary" on:click={load} {disabled}>Import</button>
|
|
||||||
<button class="btn btn-primary" on:click={dump}>Export</button>
|
|
||||||
<button class="btn btn-error tooltip" data-tooltip="Hold to delete" use:longPress={clear}>Delete all data</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
@ -1,47 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { isEqual } from 'lodash'
|
|
||||||
|
|
||||||
let text = 'Select File'
|
|
||||||
|
|
||||||
export let value = undefined
|
|
||||||
|
|
||||||
let input
|
|
||||||
let error
|
|
||||||
|
|
||||||
function validate() {
|
|
||||||
if (!input || !input.files.length) return
|
|
||||||
|
|
||||||
const file = input.files[0]
|
|
||||||
text = file.name
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (data) => {
|
|
||||||
try {
|
|
||||||
error = false
|
|
||||||
const text = data.target.result
|
|
||||||
const parsed = JSON.parse(text)
|
|
||||||
value = parsed
|
|
||||||
} catch {
|
|
||||||
error = true
|
|
||||||
value = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
label {
|
|
||||||
width: 18em;
|
|
||||||
max-width: 100%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
label input[type='file'] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<label class="btn">
|
|
||||||
{#if error}Invalid file{:else}{text}{/if}
|
|
||||||
<input bind:this={input} on:change={validate} class="input" accept="application/json" type="file" />
|
|
||||||
</label>
|
|
@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
|
|
||||||
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
|
|
||||||
<link href="./main.css" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>Ora</h1>
|
|
||||||
<p>Ora helps you track down time consuming websites</p>
|
|
||||||
<a href="../dashboard/index.html" target="_blank"><button class="btn btn-primary btn-lg">Go to the Dashboard</button></a>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<form id="form">
|
|
||||||
<h4>Settings</h4>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">
|
|
||||||
Frequency <small>(Minutes)</small>
|
|
||||||
<input id="frequency" class="form-input" type="number" min="3" step="1" />
|
|
||||||
</label>
|
|
||||||
<label class="form-label">
|
|
||||||
Retention <small>(Days)</small>
|
|
||||||
<input id="retention" class="form-input" type="number" min="3" max="365" step="1" />
|
|
||||||
</label>
|
|
||||||
<button id="reset" type="submit" class="btn">Reset</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="./main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,42 +0,0 @@
|
|||||||
import browser from 'webextension-polyfill'
|
|
||||||
|
|
||||||
const DEFAULT = {
|
|
||||||
frequency: 3,
|
|
||||||
retention: 90,
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
return await browser.storage.local.get()
|
|
||||||
} catch {
|
|
||||||
return DEFAULT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(settings) {
|
|
||||||
return browser.storage.local.set(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const frequency = window.document.getElementById('frequency')
|
|
||||||
const retention = window.document.getElementById('retention')
|
|
||||||
const reset = window.document.getElementById('reset')
|
|
||||||
const form = window.document.getElementById('form')
|
|
||||||
|
|
||||||
form.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
save({ frequency: frequency.value, retention: retention.value })
|
|
||||||
})
|
|
||||||
|
|
||||||
reset.addEventListener('click', async () => {
|
|
||||||
await browser.storage.local.clear()
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
|
|
||||||
load().then((saved) => {
|
|
||||||
frequency.value = saved.frequency
|
|
||||||
retention.value = saved.retention
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
window.document.addEventListener('DOMContentLoaded', init)
|
|
@ -1,18 +1,26 @@
|
|||||||
import dj from 'dayjs'
|
import dj from 'dayjs'
|
||||||
import Dexie from 'dexie'
|
|
||||||
import RelativeTime from 'dayjs/plugin/relativeTime'
|
import RelativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import Duration from 'dayjs/plugin/duration'
|
import Duration from 'dayjs/plugin/duration'
|
||||||
import Joi from 'joi'
|
import Dexie from 'dexie'
|
||||||
|
import 'dexie-export-import'
|
||||||
|
|
||||||
|
import { LogValidator, checkForErrors } from '../shared/validation'
|
||||||
|
|
||||||
dj.extend(Duration)
|
dj.extend(Duration)
|
||||||
dj.extend(RelativeTime)
|
dj.extend(RelativeTime)
|
||||||
|
|
||||||
export const DB = new Dexie('ora')
|
export const DB = new Dexie('ora')
|
||||||
|
|
||||||
DB.version(2).stores({
|
DB.version(2).stores({
|
||||||
logs: `++id, host, timestamp`,
|
logs: `++id, host, timestamp`,
|
||||||
limits: `++id, host`,
|
limits: `++id, host`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DB.version(3).stores({
|
||||||
|
settings: `key, value`,
|
||||||
|
dismiss: `host, timestamp, duration`,
|
||||||
|
})
|
||||||
|
|
||||||
export function normalizeTimestamp(timestamp) {
|
export function normalizeTimestamp(timestamp) {
|
||||||
// Normalize every dato to 15 minutes
|
// Normalize every dato to 15 minutes
|
||||||
const t = dj(timestamp)
|
const t = dj(timestamp)
|
||||||
@ -28,58 +36,11 @@ export async function insertLog({ timestamp, host, seconds }) {
|
|||||||
const saved = await DB.logs.where({ host, timestamp }).first()
|
const saved = await DB.logs.where({ host, timestamp }).first()
|
||||||
const data = Object.assign({ host, timestamp, seconds: 0 }, saved)
|
const data = Object.assign({ host, timestamp, seconds: 0 }, saved)
|
||||||
data.seconds += seconds
|
data.seconds += seconds
|
||||||
|
const error = checkForErrors(LogValidator, data)
|
||||||
|
if (error) throw new Error(error)
|
||||||
await DB.logs.put(data)
|
await DB.logs.put(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clear() {
|
export async function clear() {
|
||||||
await DB.limits.clear()
|
return Promise.allSettled(DB.tables.map((table) => DB.table(table.name).clear()))
|
||||||
await DB.logs.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dump() {
|
|
||||||
return {
|
|
||||||
limits: await DB.limits.toArray(),
|
|
||||||
logs: await DB.logs.toArray(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validate(data) {
|
|
||||||
const schema = Joi.object({
|
|
||||||
limits: Joi.array().items(
|
|
||||||
Joi.object({
|
|
||||||
host: Joi.string(),
|
|
||||||
id: Joi.number(),
|
|
||||||
rules: Joi.array().items(
|
|
||||||
Joi.object({
|
|
||||||
limit: Joi.array().items(Joi.string(), Joi.number()),
|
|
||||||
every: Joi.array().items(Joi.string(), Joi.number()),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
logs: Joi.array().items(
|
|
||||||
Joi.object({
|
|
||||||
host: Joi.string(),
|
|
||||||
id: Joi.number(),
|
|
||||||
seconds: Joi.number(),
|
|
||||||
timestamp: Joi.string(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const validated = schema.validate(data, { presence: 'required' })
|
|
||||||
return !validated.error
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function load(data) {
|
|
||||||
if (!validate(data)) throw new Error('Invalid data')
|
|
||||||
|
|
||||||
await clear()
|
|
||||||
await DB.limits.bulkAdd(data.limits)
|
|
||||||
await DB.logs.bulkAdd(
|
|
||||||
data.logs.map((log) => ({
|
|
||||||
...log,
|
|
||||||
timestamp: new Date(log.timestamp),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
<script>
|
|
||||||
// import manifest from '../../'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
footer {
|
|
||||||
margin-top: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<a
|
|
||||||
href="https://github.com/cupcakearmy/ora"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer">Source Code</a>
|
|
||||||
- v0.7
|
|
||||||
<br />
|
|
||||||
Made with ❤️ by
|
|
||||||
<a href="https://nicco.io" target="_blank" rel="noreferrer">🐘</a>
|
|
||||||
</footer>
|
|
@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
|
|
||||||
<link href="../../node_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
|
||||||
'Helvetica Neue', sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
text-shadow: rgba(0, 0, 0, 0.01) 0 0 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<title>Ora</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root" />
|
|
||||||
<script src="./main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -2,6 +2,7 @@ import { groupBy, orderBy, sum } from 'lodash'
|
|||||||
import dj from 'dayjs'
|
import dj from 'dayjs'
|
||||||
|
|
||||||
import { DB } from './db.js'
|
import { DB } from './db.js'
|
||||||
|
import { SettingsValidator } from './validation.js'
|
||||||
|
|
||||||
export async function data({ start, end }) {
|
export async function data({ start, end }) {
|
||||||
const logs = await getLogsBetweenDates({ start, end })
|
const logs = await getLogsBetweenDates({ start, end })
|
||||||
@ -51,7 +52,14 @@ export function getUsageForRules(host, rules) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsageForHost(host) {
|
export async function getUsageForHost(host) {
|
||||||
|
const dismiss = await DB.dismiss.where({ host }).first()
|
||||||
|
if (dismiss) {
|
||||||
|
const isDismissed = dj().isBefore(dj(dismiss.timestamp).add(dismiss.duration, 'ms'))
|
||||||
|
if (isDismissed) return []
|
||||||
|
}
|
||||||
|
|
||||||
const limit = await DB.limits.where({ host }).first()
|
const limit = await DB.limits.where({ host }).first()
|
||||||
|
if (!limit) return []
|
||||||
return await Promise.all(getUsageForRules(host, limit.rules))
|
return await Promise.all(getUsageForRules(host, limit.rules))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,3 +67,9 @@ export function percentagesToBool(percentages) {
|
|||||||
const blocked = percentages.map((p) => p >= 100).includes(true)
|
const blocked = percentages.map((p) => p >= 100).includes(true)
|
||||||
return blocked
|
return blocked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSettingsWithDefaults() {
|
||||||
|
const values = await DB.settings.toArray()
|
||||||
|
const fromDB = Object.fromEntries(values.map((v) => [v.key, v.value]))
|
||||||
|
return SettingsValidator.validate(fromDB).value
|
||||||
|
}
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
import App from './App.svelte'
|
|
||||||
|
|
||||||
new App({ target: window.document.getElementById('root') })
|
|
50
src/shared/validation.js
Normal file
50
src/shared/validation.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import Joi from 'joi'
|
||||||
|
|
||||||
|
export const DurationTupleValidator = Joi.array().length(2).ordered(Joi.number(), Joi.string())
|
||||||
|
|
||||||
|
export const LimitValidator = Joi.object({
|
||||||
|
host: Joi.string().domain(),
|
||||||
|
id: Joi.number().optional(),
|
||||||
|
rules: Joi.array().items(
|
||||||
|
Joi.object({
|
||||||
|
limit: DurationTupleValidator,
|
||||||
|
every: DurationTupleValidator,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogValidator = Joi.object({
|
||||||
|
host: Joi.string(),
|
||||||
|
id: Joi.number().optional(),
|
||||||
|
seconds: Joi.number(),
|
||||||
|
timestamp: Joi.date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DismissValidator = Joi.object({
|
||||||
|
host: Joi.string(),
|
||||||
|
timestamp: Joi.date(),
|
||||||
|
duration: Joi.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SettingsValidator = Joi.object({
|
||||||
|
lastActivity: Joi.date()
|
||||||
|
.default(() => new Date())
|
||||||
|
.optional(), // Last user activity, to calculate idle time
|
||||||
|
retention: Joi.number().default(90).optional(), // Days to keep logs
|
||||||
|
idleTimeout: DurationTupleValidator.default(5).optional(), // Idle timeout in minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DBValidator = Joi.object({
|
||||||
|
limits: Joi.array().items(LimitValidator).optional(),
|
||||||
|
logs: Joi.array().items(LogValidator).optional(),
|
||||||
|
settings: SettingsValidator.optional(),
|
||||||
|
dismiss: Joi.array().items(DismissValidator).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function checkForErrors(validator, data) {
|
||||||
|
const validated = validator.validate(data, { presence: 'required' })
|
||||||
|
if (validated.error) {
|
||||||
|
console.error('Validation error', validated.error)
|
||||||
|
}
|
||||||
|
return validated.error
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user