init
This commit is contained in:
commit
c3ee96429c
46 changed files with 6006 additions and 0 deletions
9
.editorconfig
Normal file
9
.editorconfig
Normal file
|
@ -0,0 +1,9 @@
|
|||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
1
.envrc
Normal file
1
.envrc
Normal file
|
@ -0,0 +1 @@
|
|||
use flake
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
BIN
bin/neutralino-linux_arm64
Executable file
BIN
bin/neutralino-linux_arm64
Executable file
Binary file not shown.
BIN
bin/neutralino-linux_armhf
Executable file
BIN
bin/neutralino-linux_armhf
Executable file
Binary file not shown.
BIN
bin/neutralino-linux_x64
Executable file
BIN
bin/neutralino-linux_x64
Executable file
Binary file not shown.
BIN
bin/neutralino-mac_arm64
Executable file
BIN
bin/neutralino-mac_arm64
Executable file
Binary file not shown.
BIN
bin/neutralino-mac_universal
Executable file
BIN
bin/neutralino-mac_universal
Executable file
Binary file not shown.
BIN
bin/neutralino-mac_x64
Executable file
BIN
bin/neutralino-mac_x64
Executable file
Binary file not shown.
BIN
bin/neutralino-win_x64.exe
Normal file
BIN
bin/neutralino-win_x64.exe
Normal file
Binary file not shown.
1
build-scripts
Submodule
1
build-scripts
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit ececd00d5fcbc78b83947db8fbab4a4b628ffd13
|
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1747426788,
|
||||
"narHash": "sha256-N4cp0asTsJCnRMFZ/k19V9akkxb7J/opG+K+jU57JGc=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "12a55407652e04dcf2309436eb06fef0d3713ef3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
56
flake.nix
Normal file
56
flake.nix
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
outputs = { nixpkgs, ... }:
|
||||
let pkgs = nixpkgs.legacyPackages."x86_64-linux";
|
||||
buildInputs = with pkgs; [
|
||||
gtk3
|
||||
glib
|
||||
cairo
|
||||
pango
|
||||
gdk-pixbuf
|
||||
atk
|
||||
at-spi2-atk
|
||||
at-spi2-core
|
||||
dbus
|
||||
libappindicator-gtk3
|
||||
webkitgtk_4_1
|
||||
xorg.libX11
|
||||
xorg.libXext
|
||||
xorg.libXrender
|
||||
xorg.libXtst
|
||||
xorg.libxcb
|
||||
xorg.libXcomposite
|
||||
xorg.libXdamage
|
||||
xorg.libXfixes
|
||||
xorg.libXrandr
|
||||
xorg.libXi
|
||||
xorg.libXcursor
|
||||
xorg.libXinerama
|
||||
libpng
|
||||
libjpeg
|
||||
libGL
|
||||
libGLU
|
||||
mesa
|
||||
fontconfig
|
||||
freetype
|
||||
# Not needed
|
||||
# stdenv.cc.cc.lib
|
||||
# glibc
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
gst_all_1.gst-plugins-ugly
|
||||
];
|
||||
in {
|
||||
devShells."x86_64-linux".default = pkgs.mkShell {
|
||||
nativeBuildInputs = [ pkgs.nodejs_24 pkgs.pnpm pkgs.git ];
|
||||
inherit buildInputs;
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH
|
||||
export GST_PLUGIN_PATH=${pkgs.gst_all_1.gstreamer}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-base}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-good}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-bad}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-ugly}/lib/gstreamer-1.0
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
38
neutralino.config.json
Normal file
38
neutralino.config.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"applicationId": "js.neutralino.zero",
|
||||
"version": "1.0.0",
|
||||
"defaultMode": "window",
|
||||
"documentRoot": "/www/dist/",
|
||||
"url": "/",
|
||||
"enableServer": true,
|
||||
"enableNativeAPI": true,
|
||||
"nativeAllowList": ["app.*"],
|
||||
"modes": {
|
||||
"window": {
|
||||
"title": "myapp",
|
||||
"width": 800,
|
||||
"height": 500,
|
||||
"minWidth": 400,
|
||||
"minHeight": 200,
|
||||
"icon": "/www/dist/favicon.ico"
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"binaryName": "myapp",
|
||||
"resourcesPath": "/www/dist/",
|
||||
"extensionsPath": "/extensions/",
|
||||
"clientLibrary": "/www/public/neutralino.js",
|
||||
"binaryVersion": "6.1.0",
|
||||
"clientVersion": "6.1.0"
|
||||
},
|
||||
"buildScript": {
|
||||
"mac": {
|
||||
"architecture": ["x64", "arm64", "universal"],
|
||||
"minimumOS": "10.13.0",
|
||||
"appName": "myapp",
|
||||
"appBundleName": "myapp",
|
||||
"appIdentifier": "com.marketmix.ext.bun.demo",
|
||||
"appIcon": "/packaging/pucoti.icns"
|
||||
}
|
||||
}
|
||||
}
|
BIN
packaging/pucoti.icns
Normal file
BIN
packaging/pucoti.icns
Normal file
Binary file not shown.
6
www/.prettierrc.json
Normal file
6
www/.prettierrc.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
39
www/README.md
Normal file
39
www/README.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
# pucoti
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
1
www/env.d.ts
vendored
Normal file
1
www/env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
24
www/eslint.config.ts
Normal file
24
www/eslint.config.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
)
|
BIN
www/favicon.ico
Normal file
BIN
www/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
20
www/index.html
Normal file
20
www/index.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pucoti</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bevan:ital@0;1&family=Inter:ital,wght@0,400..700;1,400..700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
44
www/package.json
Normal file
44
www/package.json
Normal file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "pucoti",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"date-fns": "^4.1.0",
|
||||
"pinia": "^3.0.1",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-oxlint": "^0.16.0",
|
||||
"eslint-plugin-vue": "~10.0.0",
|
||||
"jiti": "^2.4.2",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
3614
www/pnpm-lock.yaml
generated
Normal file
3614
www/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
BIN
www/public/bell.mp3
Normal file
BIN
www/public/bell.mp3
Normal file
Binary file not shown.
BIN
www/public/favicon.ico
Normal file
BIN
www/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
1
www/public/neutralino.js
Normal file
1
www/public/neutralino.js
Normal file
File diff suppressed because one or more lines are too long
42
www/src/App.vue
Normal file
42
www/src/App.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { usePucotiStore } from './stores/counter'
|
||||
import { humanTimeToMs } from './utils'
|
||||
import { tickClock, useIntervalFn } from './lib'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const audio = new Audio('/bell.mp3')
|
||||
|
||||
const store = usePucotiStore()
|
||||
store.setIntention('')
|
||||
|
||||
function checkTime() {
|
||||
const now = Date.now()
|
||||
const timeOnCountdown = store.timers.main.zeroAt - now
|
||||
if (timeOnCountdown <= 0) {
|
||||
if (now - store.lastRung > humanTimeToMs(store.ringEvery)) {
|
||||
store.lastRung = now
|
||||
audio.currentTime = 0
|
||||
audio.play()
|
||||
}
|
||||
} else {
|
||||
store.lastRung = 0
|
||||
}
|
||||
}
|
||||
|
||||
useIntervalFn(tickClock, 1000)
|
||||
useIntervalFn(checkTime, 500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
<header>
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
-->
|
||||
|
||||
<RouterView />
|
||||
</template>
|
32
www/src/assets/main.css
Normal file
32
www/src/assets/main.css
Normal file
|
@ -0,0 +1,32 @@
|
|||
@import 'tailwindcss/preflight.css' layer(base);
|
||||
|
||||
:root {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-display: 'Bevan', sans-serif;
|
||||
--color-light: oklch(0.9 0.1 88);
|
||||
|
||||
--color-dark: oklch(0.15 0.025 45);
|
||||
/* Darker is actually less dark. You're welcome. */
|
||||
--color-darker: oklch(0.2 0.025 45);
|
||||
--color-acid: rgb(183, 255, 183);
|
||||
|
||||
--timer-negative: #f00;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-light);
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
16
www/src/components/AppLogo.vue
Normal file
16
www/src/components/AppLogo.vue
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<span>Pucoti</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
span {
|
||||
font-family: var(--font-display);
|
||||
color: var(--color-light);
|
||||
background-color: var(--color-dark);
|
||||
padding: 0.25em 0.75em;
|
||||
font-style: italic;
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
51
www/src/components/Button.vue
Normal file
51
www/src/components/Button.vue
Normal file
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
shortcut?: string
|
||||
label: string
|
||||
outline?: boolean
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<button v-bind="$attrs" :class="{ outline: outline }">
|
||||
<span>{{ label }}</span>
|
||||
<kbd v-if="shortcut">{{ shortcut }}</kbd>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0 1vw;
|
||||
|
||||
padding: 0.5vw 0.8vw;
|
||||
|
||||
color: var(--color-light);
|
||||
border: 1px solid color-mix(in oklab, var(--color-light) 20%, transparent);
|
||||
background-image: linear-gradient(to top,
|
||||
transparent 0%,
|
||||
color-mix(in oklab, var(--color-light) 15%, var(--color-dark)) 90%);
|
||||
}
|
||||
|
||||
button.outline {
|
||||
border: none;
|
||||
background: var(--color-darker);
|
||||
padding: 0.3em 0.8em;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: monospace;
|
||||
width: clamp(1.2em, 2vw, 5em);
|
||||
height: clamp(1.2em, 2vw, 5em);
|
||||
background-color: var(--color-dark);
|
||||
border: 1px solid color-mix(in oklab, var(--color-light) 10%, transparent);
|
||||
}
|
||||
</style>
|
240
www/src/components/IntentionHistory.vue
Normal file
240
www/src/components/IntentionHistory.vue
Normal file
|
@ -0,0 +1,240 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { usePucotiStore } from '@/stores/counter'
|
||||
import { fmtSeconds } from '@/utils'
|
||||
import Button from './Button.vue'
|
||||
import type { IntentionHistoryItem } from '@/types'
|
||||
|
||||
const store = usePucotiStore()
|
||||
|
||||
// Reactive state for editing
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingValue = ref<string>('')
|
||||
|
||||
// Computed property for completed intentions with duration
|
||||
const completedIntentions = computed(() => {
|
||||
let history = store.intentionHistory.filter(
|
||||
(item) => item.intention != '' && item.end !== undefined,
|
||||
)
|
||||
return history
|
||||
.map((item: IntentionHistoryItem, index: number) => ({
|
||||
...item,
|
||||
index,
|
||||
duration: item.end ? item.end - item.start : 0,
|
||||
isCompleted: !!item.end,
|
||||
}))
|
||||
.reverse() // Show most recent first
|
||||
})
|
||||
|
||||
// Edit functionality
|
||||
const startEditing = (index: number, currentText: string) => {
|
||||
editingIndex.value = index
|
||||
editingValue.value = currentText
|
||||
nextTick(() => {
|
||||
const input = document.querySelector(`input[data-edit-index="${index}"]`) as HTMLInputElement
|
||||
if (input) {
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveEdit = (originalIndex: number) => {
|
||||
if (editingValue.value.trim()) {
|
||||
store.updateHistoricalIntention(originalIndex, editingValue.value.trim())
|
||||
}
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
editingIndex.value = null
|
||||
editingValue.value = ''
|
||||
}
|
||||
|
||||
const handleEditKeydown = (event: KeyboardEvent, originalIndex: number) => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveEdit(originalIndex)
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
// Continue functionality
|
||||
const continueFromHistory = (intention: string) => {
|
||||
store.continueFromHistory(intention)
|
||||
}
|
||||
|
||||
// Format duration helper
|
||||
const formatDuration = (milliseconds: number) => {
|
||||
const parts = fmtSeconds(milliseconds)
|
||||
return parts.join(':')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-section">
|
||||
<h2 class="history-title">History</h2>
|
||||
|
||||
<div v-if="completedIntentions.length === 0" class="empty-state">
|
||||
No intentions completed yet. Start working on something to see your history here.
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<div
|
||||
v-for="item in completedIntentions"
|
||||
:key="`${item.index}-${item.start}`"
|
||||
class="history-item"
|
||||
>
|
||||
<div class="history-item-content">
|
||||
<div class="intention-text">
|
||||
<input
|
||||
v-if="editingIndex === item.index"
|
||||
:data-edit-index="item.index"
|
||||
v-model="editingValue"
|
||||
class="intention-edit-input"
|
||||
@keydown="handleEditKeydown($event, item.index)"
|
||||
@blur="cancelEdit"
|
||||
@click.stop
|
||||
/>
|
||||
<span v-else class="intention-display">{{ item.intention }}</span>
|
||||
</div>
|
||||
|
||||
<div class="history-actions">
|
||||
<button
|
||||
v-if="editingIndex !== item.index"
|
||||
class="edit-button"
|
||||
title="Edit intention"
|
||||
@click="startEditing(item.index, item.intention)"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
|
||||
<div class="duration">{{ formatDuration(item.duration) }}</div>
|
||||
|
||||
<Button label="Continue" @click="continueFromHistory(item.intention)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-section {
|
||||
margin-top: clamp(2em, 3vw, 100vw);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
color: var(--color-acid);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5em, 2.5vw, 100vw);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: color-mix(in oklab, var(--color-light) 80%, transparent);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background-color: color-mix(in oklab, var(--color-light) 5%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-light) 15%, transparent);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5em;
|
||||
padding: 1em;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background-color: color-mix(in oklab, var(--color-light) 8%, transparent);
|
||||
}
|
||||
|
||||
.history-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.intention-text {
|
||||
flex: 1;
|
||||
min-width: 0; /* Allow text to shrink */
|
||||
}
|
||||
|
||||
.intention-display {
|
||||
color: var(--color-light);
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.intention-edit-input {
|
||||
width: 100%;
|
||||
background: color-mix(in oklab, var(--color-light) 10%, transparent);
|
||||
border: 1px solid var(--color-acid);
|
||||
border-radius: 4px;
|
||||
color: var(--color-light);
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.intention-edit-input:focus {
|
||||
border-color: var(--color-acid);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-acid) 20%, transparent);
|
||||
}
|
||||
|
||||
.history-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25em;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
background-color: color-mix(in oklab, var(--color-light) 15%, transparent);
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: var(--color-acid);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.9em;
|
||||
min-width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for webkit browsers */
|
||||
.history-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.history-list::-webkit-scrollbar-track {
|
||||
background: color-mix(in oklab, var(--color-light) 5%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.history-list::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-light) 20%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.history-list::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-light) 30%, transparent);
|
||||
}
|
||||
</style>
|
33
www/src/components/Timer.vue
Normal file
33
www/src/components/Timer.vue
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Timer } from '@/types'
|
||||
import { useListenerFn } from '@/lib'
|
||||
import { timerToMs, timerToString } from '@/utils'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
timer: Timer
|
||||
color: string
|
||||
}>()
|
||||
|
||||
const displayTime = ref('00:00')
|
||||
const color = ref('')
|
||||
|
||||
function handleClockTick() {
|
||||
displayTime.value = timerToString(props.timer)
|
||||
color.value = timerToMs(props.timer) < 0 ? 'var(--timer-negative)' : props.color
|
||||
}
|
||||
|
||||
onMounted(handleClockTick)
|
||||
useListenerFn('clock-tick', handleClockTick)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :style="{ color: color }">{{ displayTime }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
span {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2em, 12vw, 100em);
|
||||
}
|
||||
</style>
|
80
www/src/lib.ts
Normal file
80
www/src/lib.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function tickClock() {
|
||||
document.dispatchEvent(new Event('clock-tick'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size metrics of a simple text element.
|
||||
*
|
||||
* @param {HTMLElement} el The element to get the text metrics for
|
||||
* @param {string} fontSizeOverride The font size to use, in CSS units (e.g. '16px')
|
||||
*
|
||||
* @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
|
||||
*/
|
||||
export function getTextMetrics(el: HTMLElement, fontSizeOverride: string) {
|
||||
// re-use canvas object for better performance
|
||||
const canvas = getTextMetrics.canvas || (getTextMetrics.canvas = document.createElement('canvas'))
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
const fontWeight = getCssStyle(el, 'font-weight') || 'normal'
|
||||
const fontSize = fontSizeOverride || getCssStyle(el, 'font-size')
|
||||
const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman'
|
||||
context.font = `${fontWeight} ${fontSize} ${fontFamily}`
|
||||
|
||||
const text = el.innerText || el.value || el.getAttribute('placeholder')
|
||||
|
||||
const metrics = context.measureText(text)
|
||||
metrics.actualHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent
|
||||
return metrics
|
||||
}
|
||||
|
||||
export function getCssStyle(element: HTMLElement, prop: string) {
|
||||
return window.getComputedStyle(element, null).getPropertyValue(prop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the font size of the element to the maximum that fits inside the element.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
export function scaleMaxSize(element: HTMLElement) {
|
||||
let fontSizeLower = 0
|
||||
let fontSizeUpper = 2000
|
||||
|
||||
// Actual available, without the padding.lib.
|
||||
const width =
|
||||
element.clientWidth -
|
||||
parseFloat(getCssStyle(element, 'padding-left')) -
|
||||
parseFloat(getCssStyle(element, 'padding-right'))
|
||||
const height =
|
||||
element.clientHeight -
|
||||
parseFloat(getCssStyle(element, 'padding-top')) -
|
||||
parseFloat(getCssStyle(element, 'padding-bottom'))
|
||||
|
||||
// Simple binary search to find the largest font size that fits
|
||||
while (fontSizeUpper > fontSizeLower + 1) {
|
||||
const fontSize = Math.floor((fontSizeUpper + fontSizeLower) / 2)
|
||||
const metrics = getTextMetrics(element, fontSize + 'px')
|
||||
if (metrics.width > width || metrics.actualHeight > height) {
|
||||
fontSizeUpper = fontSize
|
||||
} else {
|
||||
fontSizeLower = fontSize
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(getTextMetrics(element, fontSizeLower + 'px'), element, "width", width, "height", height);
|
||||
|
||||
element.style.fontSize = fontSizeLower + 'px'
|
||||
}
|
||||
|
||||
export function useIntervalFn(cb: () => void, ms: number) {
|
||||
let intervalId: number = 0
|
||||
onMounted(() => (intervalId = setInterval(cb, ms)))
|
||||
onUnmounted(() => clearInterval(intervalId))
|
||||
}
|
||||
|
||||
export function useListenerFn(event: string, cb: () => void) {
|
||||
onMounted(() => document.addEventListener(event, cb))
|
||||
onUnmounted(() => document.removeEventListener(event, cb))
|
||||
}
|
14
www/src/main.ts
Normal file
14
www/src/main.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
897
www/src/neutralino.d.ts
vendored
Normal file
897
www/src/neutralino.d.ts
vendored
Normal file
|
@ -0,0 +1,897 @@
|
|||
// Type definitions for Neutralino 6.1.0
|
||||
// Project: https://github.com/neutralinojs
|
||||
// Definitions project: https://github.com/neutralinojs/neutralino.js
|
||||
|
||||
declare namespace Neutralino {
|
||||
|
||||
namespace filesystem {
|
||||
interface DirectoryEntry {
|
||||
entry: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
interface FileReaderOptions {
|
||||
pos: number;
|
||||
size: number;
|
||||
}
|
||||
interface DirectoryReaderOptions {
|
||||
recursive: boolean;
|
||||
}
|
||||
interface OpenedFile {
|
||||
id: number;
|
||||
eof: boolean;
|
||||
pos: number;
|
||||
lastRead: number;
|
||||
}
|
||||
interface Stats {
|
||||
size: number;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
createdAt: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
interface Watcher {
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
interface CopyOptions {
|
||||
recursive: boolean;
|
||||
overwrite: boolean;
|
||||
skip: boolean;
|
||||
}
|
||||
interface PathParts {
|
||||
rootName: string;
|
||||
rootDirectory: string;
|
||||
rootPath: string;
|
||||
relativePath: string;
|
||||
parentPath: string;
|
||||
filename: string;
|
||||
stem: string;
|
||||
extension: string;
|
||||
}
|
||||
interface Permissions {
|
||||
all: boolean;
|
||||
ownerAll: boolean;
|
||||
ownerRead: boolean;
|
||||
ownerWrite: boolean;
|
||||
ownerExec: boolean;
|
||||
groupAll: boolean;
|
||||
groupRead: boolean;
|
||||
groupWrite: boolean;
|
||||
groupExec: boolean;
|
||||
othersAll: boolean;
|
||||
othersRead: boolean;
|
||||
othersWrite: boolean;
|
||||
othersExec: boolean;
|
||||
}
|
||||
type PermissionsMode = "ADD" | "REPLACE" | "REMOVE";
|
||||
function createDirectory(path: string): Promise<void>;
|
||||
function remove(path: string): Promise<void>;
|
||||
function writeFile(path: string, data: string): Promise<void>;
|
||||
function appendFile(path: string, data: string): Promise<void>;
|
||||
function writeBinaryFile(path: string, data: ArrayBuffer): Promise<void>;
|
||||
function appendBinaryFile(path: string, data: ArrayBuffer): Promise<void>;
|
||||
function readFile(path: string, options?: FileReaderOptions): Promise<string>;
|
||||
function readBinaryFile(path: string, options?: FileReaderOptions): Promise<ArrayBuffer>;
|
||||
function openFile(path: string): Promise<number>;
|
||||
function createWatcher(path: string): Promise<number>;
|
||||
function removeWatcher(id: number): Promise<number>;
|
||||
function getWatchers(): Promise<Watcher[]>;
|
||||
function updateOpenedFile(id: number, event: string, data?: any): Promise<void>;
|
||||
function getOpenedFileInfo(id: number): Promise<OpenedFile>;
|
||||
function readDirectory(path: string, options?: DirectoryReaderOptions): Promise<DirectoryEntry[]>;
|
||||
function copy(source: string, destination: string, options?: CopyOptions): Promise<void>;
|
||||
function move(source: string, destination: string): Promise<void>;
|
||||
function getStats(path: string): Promise<Stats>;
|
||||
function getAbsolutePath(path: string): Promise<string>;
|
||||
function getRelativePath(path: string, base?: string): Promise<string>;
|
||||
function getPathParts(path: string): Promise<PathParts>;
|
||||
function getPermissions(path: string): Promise<Permissions>;
|
||||
function setPermissions(path: string, permissions: Permissions, mode: PermissionsMode): Promise<void>;
|
||||
}
|
||||
namespace os {
|
||||
// debug
|
||||
enum LoggerType {
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR",
|
||||
INFO = "INFO"
|
||||
}
|
||||
// os
|
||||
enum Icon {
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR",
|
||||
INFO = "INFO",
|
||||
QUESTION = "QUESTION"
|
||||
}
|
||||
enum MessageBoxChoice {
|
||||
OK = "OK",
|
||||
OK_CANCEL = "OK_CANCEL",
|
||||
YES_NO = "YES_NO",
|
||||
YES_NO_CANCEL = "YES_NO_CANCEL",
|
||||
RETRY_CANCEL = "RETRY_CANCEL",
|
||||
ABORT_RETRY_IGNORE = "ABORT_RETRY_IGNORE"
|
||||
}
|
||||
//clipboard
|
||||
enum ClipboardFormat {
|
||||
unknown = "unknown",
|
||||
text = "text",
|
||||
image = "image"
|
||||
}
|
||||
// NL_GLOBALS
|
||||
enum Mode {
|
||||
window = "window",
|
||||
browser = "browser",
|
||||
cloud = "cloud",
|
||||
chrome = "chrome"
|
||||
}
|
||||
enum OperatingSystem {
|
||||
Linux = "Linux",
|
||||
Windows = "Windows",
|
||||
Darwin = "Darwin",
|
||||
FreeBSD = "FreeBSD",
|
||||
Unknown = "Unknown"
|
||||
}
|
||||
enum Architecture {
|
||||
x64 = "x64",
|
||||
arm = "arm",
|
||||
itanium = "itanium",
|
||||
ia32 = "ia32",
|
||||
unknown = "unknown"
|
||||
}
|
||||
interface ExecCommandOptions {
|
||||
stdIn?: string;
|
||||
background?: boolean;
|
||||
cwd?: string;
|
||||
}
|
||||
interface ExecCommandResult {
|
||||
pid: number;
|
||||
stdOut: string;
|
||||
stdErr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
interface SpawnedProcess {
|
||||
id: number;
|
||||
pid: number;
|
||||
}
|
||||
interface SpawnedProcessOptions {
|
||||
cwd?: string;
|
||||
envs?: Record<string, string>;
|
||||
}
|
||||
interface Envs {
|
||||
[key: string]: string;
|
||||
}
|
||||
interface OpenDialogOptions {
|
||||
multiSelections?: boolean;
|
||||
filters?: Filter[];
|
||||
defaultPath?: string;
|
||||
}
|
||||
interface FolderDialogOptions {
|
||||
defaultPath?: string;
|
||||
}
|
||||
interface SaveDialogOptions {
|
||||
forceOverwrite?: boolean;
|
||||
filters?: Filter[];
|
||||
defaultPath?: string;
|
||||
}
|
||||
interface Filter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
interface TrayOptions {
|
||||
icon: string;
|
||||
menuItems: TrayMenuItem[];
|
||||
}
|
||||
interface TrayMenuItem {
|
||||
id?: string;
|
||||
text: string;
|
||||
isDisabled?: boolean;
|
||||
isChecked?: boolean;
|
||||
}
|
||||
type KnownPath = "config" | "data" | "cache" | "documents" | "pictures" | "music" | "video" | "downloads" | "savedGames1" | "savedGames2" | "temp";
|
||||
function execCommand(command: string, options?: ExecCommandOptions): Promise<ExecCommandResult>;
|
||||
function spawnProcess(command: string, options?: SpawnedProcessOptions): Promise<SpawnedProcess>;
|
||||
function updateSpawnedProcess(id: number, event: string, data?: any): Promise<void>;
|
||||
function getSpawnedProcesses(): Promise<SpawnedProcess[]>;
|
||||
function getEnv(key: string): Promise<string>;
|
||||
function getEnvs(): Promise<Envs>;
|
||||
function showOpenDialog(title?: string, options?: OpenDialogOptions): Promise<string[]>;
|
||||
function showFolderDialog(title?: string, options?: FolderDialogOptions): Promise<string>;
|
||||
function showSaveDialog(title?: string, options?: SaveDialogOptions): Promise<string>;
|
||||
function showNotification(title: string, content: string, icon?: Icon): Promise<void>;
|
||||
function showMessageBox(title: string, content: string, choice?: MessageBoxChoice, icon?: Icon): Promise<string>;
|
||||
function setTray(options: TrayOptions): Promise<void>;
|
||||
function open(url: string): Promise<void>;
|
||||
function getPath(name: KnownPath): Promise<string>;
|
||||
}
|
||||
namespace computer {
|
||||
interface MemoryInfo {
|
||||
physical: {
|
||||
total: number;
|
||||
available: number;
|
||||
};
|
||||
virtual: {
|
||||
total: number;
|
||||
available: number;
|
||||
};
|
||||
}
|
||||
interface KernelInfo {
|
||||
variant: string;
|
||||
version: string;
|
||||
}
|
||||
interface OSInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
}
|
||||
interface CPUInfo {
|
||||
vendor: string;
|
||||
model: string;
|
||||
frequency: number;
|
||||
architecture: string;
|
||||
logicalThreads: number;
|
||||
physicalCores: number;
|
||||
physicalUnits: number;
|
||||
}
|
||||
interface Display {
|
||||
id: number;
|
||||
resolution: Resolution;
|
||||
dpi: number;
|
||||
bpp: number;
|
||||
refreshRate: number;
|
||||
}
|
||||
interface Resolution {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface MousePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
function getMemoryInfo(): Promise<MemoryInfo>;
|
||||
function getArch(): Promise<string>;
|
||||
function getKernelInfo(): Promise<KernelInfo>;
|
||||
function getOSInfo(): Promise<OSInfo>;
|
||||
function getCPUInfo(): Promise<CPUInfo>;
|
||||
function getDisplays(): Promise<Display[]>;
|
||||
function getMousePosition(): Promise<MousePosition>;
|
||||
}
|
||||
namespace storage {
|
||||
function setData(key: string, data: string): Promise<void>;
|
||||
function getData(key: string): Promise<string>;
|
||||
function getKeys(): Promise<string[]>;
|
||||
}
|
||||
namespace debug {
|
||||
// debug
|
||||
enum LoggerType {
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR",
|
||||
INFO = "INFO"
|
||||
}
|
||||
// os
|
||||
enum Icon {
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR",
|
||||
INFO = "INFO",
|
||||
QUESTION = "QUESTION"
|
||||
}
|
||||
enum MessageBoxChoice {
|
||||
OK = "OK",
|
||||
OK_CANCEL = "OK_CANCEL",
|
||||
YES_NO = "YES_NO",
|
||||
YES_NO_CANCEL = "YES_NO_CANCEL",
|
||||
RETRY_CANCEL = "RETRY_CANCEL",
|
||||
ABORT_RETRY_IGNORE = "ABORT_RETRY_IGNORE"
|
||||
}
|
||||
//clipboard
|
||||
enum ClipboardFormat {
|
||||
unknown = "unknown",
|
||||
text = "text",
|
||||
image = "image"
|
||||
}
|
||||
// NL_GLOBALS
|
||||
enum Mode {
|
||||
window = "window",
|
||||
browser = "browser",
|
||||
cloud = "cloud",
|
||||
chrome = "chrome"
|
||||
}
|
||||
enum OperatingSystem {
|
||||
Linux = "Linux",
|
||||
Windows = "Windows",
|
||||
Darwin = "Darwin",
|
||||
FreeBSD = "FreeBSD",
|
||||
Unknown = "Unknown"
|
||||
}
|
||||
enum Architecture {
|
||||
x64 = "x64",
|
||||
arm = "arm",
|
||||
itanium = "itanium",
|
||||
ia32 = "ia32",
|
||||
unknown = "unknown"
|
||||
}
|
||||
function log(message: string, type?: LoggerType): Promise<void>;
|
||||
}
|
||||
namespace app {
|
||||
interface OpenActionOptions {
|
||||
url: string;
|
||||
}
|
||||
interface RestartOptions {
|
||||
args: string;
|
||||
}
|
||||
function exit(code?: number): Promise<void>;
|
||||
function killProcess(): Promise<void>;
|
||||
function restartProcess(options?: RestartOptions): Promise<void>;
|
||||
function getConfig(): Promise<any>;
|
||||
function broadcast(event: string, data?: any): Promise<void>;
|
||||
function readProcessInput(readAll?: boolean): Promise<string>;
|
||||
function writeProcessOutput(data: string): Promise<void>;
|
||||
function writeProcessError(data: string): Promise<void>;
|
||||
}
|
||||
namespace window {
|
||||
interface WindowOptions extends WindowSizeOptions, WindowPosOptions {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
fullScreen?: boolean;
|
||||
alwaysOnTop?: boolean;
|
||||
enableInspector?: boolean;
|
||||
borderless?: boolean;
|
||||
maximize?: boolean;
|
||||
hidden?: boolean;
|
||||
maximizable?: boolean;
|
||||
useSavedState?: boolean;
|
||||
exitProcessOnClose?: boolean;
|
||||
extendUserAgentWith?: string;
|
||||
injectGlobals?: boolean;
|
||||
injectClientLibrary?: boolean;
|
||||
injectScript?: string;
|
||||
processArgs?: string;
|
||||
}
|
||||
interface WindowSizeOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
resizable?: boolean;
|
||||
}
|
||||
interface WindowPosOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
interface WindowMenu extends Array<WindowMenuItem> {
|
||||
}
|
||||
interface WindowMenuItem {
|
||||
id?: string;
|
||||
text: string;
|
||||
isDisabled?: boolean;
|
||||
isChecked?: boolean;
|
||||
menuItems?: WindowMenuItem[];
|
||||
}
|
||||
function setTitle(title: string): Promise<void>;
|
||||
function getTitle(): Promise<string>;
|
||||
function maximize(): Promise<void>;
|
||||
function unmaximize(): Promise<void>;
|
||||
function isMaximized(): Promise<boolean>;
|
||||
function minimize(): Promise<void>;
|
||||
function unminimize(): Promise<void>;
|
||||
function isMinimized(): Promise<boolean>;
|
||||
function setFullScreen(): Promise<void>;
|
||||
function exitFullScreen(): Promise<void>;
|
||||
function isFullScreen(): Promise<boolean>;
|
||||
function show(): Promise<void>;
|
||||
function hide(): Promise<void>;
|
||||
function isVisible(): Promise<boolean>;
|
||||
function focus(): Promise<void>;
|
||||
function setIcon(icon: string): Promise<void>;
|
||||
function move(x: number, y: number): Promise<void>;
|
||||
function center(): Promise<void>;
|
||||
type DraggableRegionOptions = {
|
||||
/**
|
||||
* If set to `true`, the region will always capture the pointer,
|
||||
* ensuring dragging doesn't break on fast pointer movement.
|
||||
* Note that it prevents child elements from receiving any pointer events.
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
alwaysCapture?: boolean;
|
||||
/**
|
||||
* Minimum distance between cursor's starting and current position
|
||||
* after which dragging is started. This helps prevent accidental dragging
|
||||
* while interacting with child elements.
|
||||
* Defaults to `10`. (In pixels.)
|
||||
*/
|
||||
dragMinDistance?: number;
|
||||
};
|
||||
function setDraggableRegion(domElementOrId: string | HTMLElement, options?: DraggableRegionOptions): Promise<{
|
||||
success: true;
|
||||
message: string;
|
||||
}>;
|
||||
function unsetDraggableRegion(domElementOrId: string | HTMLElement): Promise<{
|
||||
success: true;
|
||||
message: string;
|
||||
}>;
|
||||
function setSize(options: WindowSizeOptions): Promise<void>;
|
||||
function getSize(): Promise<WindowSizeOptions>;
|
||||
function getPosition(): Promise<WindowPosOptions>;
|
||||
function setAlwaysOnTop(onTop: boolean): Promise<void>;
|
||||
function create(url: string, options?: WindowOptions): Promise<void>;
|
||||
function snapshot(path: string): Promise<void>;
|
||||
function setMainMenu(options: WindowMenu): Promise<void>;
|
||||
}
|
||||
namespace events {
|
||||
interface Response {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
type Builtin = "ready" | "trayMenuItemClicked" | "windowClose" | "serverOffline" | "clientConnect" | "clientDisconnect" | "appClientConnect" | "appClientDisconnect" | "extClientConnect" | "extClientDisconnect" | "extensionReady" | "neuDev_reloadApp";
|
||||
function on(event: string, handler: (ev: CustomEvent) => void): Promise<Response>;
|
||||
function off(event: string, handler: (ev: CustomEvent) => void): Promise<Response>;
|
||||
function dispatch(event: string, data?: any): Promise<Response>;
|
||||
function broadcast(event: string, data?: any): Promise<void>;
|
||||
}
|
||||
namespace extensions {
|
||||
interface ExtensionStats {
|
||||
loaded: string[];
|
||||
connected: string[];
|
||||
}
|
||||
function dispatch(extensionId: string, event: string, data?: any): Promise<void>;
|
||||
function broadcast(event: string, data?: any): Promise<void>;
|
||||
function getStats(): Promise<ExtensionStats>;
|
||||
}
|
||||
namespace updater {
|
||||
interface Manifest {
|
||||
applicationId: string;
|
||||
version: string;
|
||||
resourcesURL: string;
|
||||
}
|
||||
function checkForUpdates(url: string): Promise<Manifest>;
|
||||
function install(): Promise<void>;
|
||||
}
|
||||
namespace clipboard {
|
||||
interface ClipboardImage {
|
||||
width: number;
|
||||
height: number;
|
||||
bpp: number;
|
||||
bpr: number;
|
||||
redMask: number;
|
||||
greenMask: number;
|
||||
blueMask: number;
|
||||
redShift: number;
|
||||
greenShift: number;
|
||||
blueShift: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
// debug
|
||||
enum LoggerType {
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR",
|
||||
INFO = "INFO"
|
||||
}
|
||||
// os
|
||||
enum Icon {
|
||||
WARNING = "WARNING",
|
||||
ERROR = "ERROR",
|
||||
INFO = "INFO",
|
||||
QUESTION = "QUESTION"
|
||||
}
|
||||
enum MessageBoxChoice {
|
||||
OK = "OK",
|
||||
OK_CANCEL = "OK_CANCEL",
|
||||
YES_NO = "YES_NO",
|
||||
YES_NO_CANCEL = "YES_NO_CANCEL",
|
||||
RETRY_CANCEL = "RETRY_CANCEL",
|
||||
ABORT_RETRY_IGNORE = "ABORT_RETRY_IGNORE"
|
||||
}
|
||||
//clipboard
|
||||
enum ClipboardFormat {
|
||||
unknown = "unknown",
|
||||
text = "text",
|
||||
image = "image"
|
||||
}
|
||||
// NL_GLOBALS
|
||||
enum Mode {
|
||||
window = "window",
|
||||
browser = "browser",
|
||||
cloud = "cloud",
|
||||
chrome = "chrome"
|
||||
}
|
||||
enum OperatingSystem {
|
||||
Linux = "Linux",
|
||||
Windows = "Windows",
|
||||
Darwin = "Darwin",
|
||||
FreeBSD = "FreeBSD",
|
||||
Unknown = "Unknown"
|
||||
}
|
||||
enum Architecture {
|
||||
x64 = "x64",
|
||||
arm = "arm",
|
||||
itanium = "itanium",
|
||||
ia32 = "ia32",
|
||||
unknown = "unknown"
|
||||
}
|
||||
function getFormat(): Promise<ClipboardFormat>;
|
||||
function readText(): Promise<string>;
|
||||
function readImage(format?: string): Promise<ClipboardImage | null>;
|
||||
function writeText(data: string): Promise<void>;
|
||||
function writeImage(image: ClipboardImage): Promise<void>;
|
||||
function readHTML(): Promise<string>;
|
||||
function writeHTML(data: string): Promise<void>;
|
||||
function clear(): Promise<void>;
|
||||
}
|
||||
namespace resources {
|
||||
interface Stats {
|
||||
size: number;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
function getFiles(): Promise<string[]>;
|
||||
function getStats(path: string): Promise<Stats>;
|
||||
function extractFile(path: string, destination: string): Promise<void>;
|
||||
function extractDirectory(path: string, destination: string): Promise<void>;
|
||||
function readFile(path: string): Promise<string>;
|
||||
function readBinaryFile(path: string): Promise<ArrayBuffer>;
|
||||
}
|
||||
namespace server {
|
||||
function mount(path: string, target: string): Promise<void>;
|
||||
function unmount(path: string): Promise<void>;
|
||||
function getMounts(): Promise<void>;
|
||||
}
|
||||
namespace custom {
|
||||
function getMethods(): Promise<string[]>;
|
||||
}
|
||||
interface InitOptions {
|
||||
exportCustomMethods?: boolean;
|
||||
}
|
||||
function init(options?: InitOptions): void;
|
||||
type ErrorCode = "NE_FS_DIRCRER" | "NE_FS_RMDIRER" | "NE_FS_FILRDER" | "NE_FS_FILWRER" | "NE_FS_FILRMER" | "NE_FS_NOPATHE" | "NE_FS_COPYFER" | "NE_FS_MOVEFER" | "NE_OS_INVMSGA" | "NE_OS_INVKNPT" | "NE_ST_INVSTKY" | "NE_ST_STKEYWE" | "NE_RT_INVTOKN" | "NE_RT_NATPRME" | "NE_RT_APIPRME" | "NE_RT_NATRTER" | "NE_RT_NATNTIM" | "NE_CL_NSEROFF" | "NE_EX_EXTNOTC" | "NE_UP_CUPDMER" | "NE_UP_CUPDERR" | "NE_UP_UPDNOUF" | "NE_UP_UPDINER";
|
||||
interface Error {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
}
|
||||
interface OpenActionOptions {
|
||||
url: string;
|
||||
}
|
||||
interface RestartOptions {
|
||||
args: string;
|
||||
}
|
||||
interface MemoryInfo {
|
||||
physical: {
|
||||
total: number;
|
||||
available: number;
|
||||
};
|
||||
virtual: {
|
||||
total: number;
|
||||
available: number;
|
||||
};
|
||||
}
|
||||
interface KernelInfo {
|
||||
variant: string;
|
||||
version: string;
|
||||
}
|
||||
interface OSInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
}
|
||||
interface CPUInfo {
|
||||
vendor: string;
|
||||
model: string;
|
||||
frequency: number;
|
||||
architecture: string;
|
||||
logicalThreads: number;
|
||||
physicalCores: number;
|
||||
physicalUnits: number;
|
||||
}
|
||||
interface Display {
|
||||
id: number;
|
||||
resolution: Resolution;
|
||||
dpi: number;
|
||||
bpp: number;
|
||||
refreshRate: number;
|
||||
}
|
||||
interface Resolution {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface MousePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
interface ClipboardImage {
|
||||
width: number;
|
||||
height: number;
|
||||
bpp: number;
|
||||
bpr: number;
|
||||
redMask: number;
|
||||
greenMask: number;
|
||||
blueMask: number;
|
||||
redShift: number;
|
||||
greenShift: number;
|
||||
blueShift: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
interface ExtensionStats {
|
||||
loaded: string[];
|
||||
connected: string[];
|
||||
}
|
||||
interface DirectoryEntry {
|
||||
entry: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
interface FileReaderOptions {
|
||||
pos: number;
|
||||
size: number;
|
||||
}
|
||||
interface DirectoryReaderOptions {
|
||||
recursive: boolean;
|
||||
}
|
||||
interface OpenedFile {
|
||||
id: number;
|
||||
eof: boolean;
|
||||
pos: number;
|
||||
lastRead: number;
|
||||
}
|
||||
interface Stats {
|
||||
size: number;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
createdAt: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
interface Watcher {
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
interface CopyOptions {
|
||||
recursive: boolean;
|
||||
overwrite: boolean;
|
||||
skip: boolean;
|
||||
}
|
||||
interface PathParts {
|
||||
rootName: string;
|
||||
rootDirectory: string;
|
||||
rootPath: string;
|
||||
relativePath: string;
|
||||
parentPath: string;
|
||||
filename: string;
|
||||
stem: string;
|
||||
extension: string;
|
||||
}
|
||||
interface Permissions {
|
||||
all: boolean;
|
||||
ownerAll: boolean;
|
||||
ownerRead: boolean;
|
||||
ownerWrite: boolean;
|
||||
ownerExec: boolean;
|
||||
groupAll: boolean;
|
||||
groupRead: boolean;
|
||||
groupWrite: boolean;
|
||||
groupExec: boolean;
|
||||
othersAll: boolean;
|
||||
othersRead: boolean;
|
||||
othersWrite: boolean;
|
||||
othersExec: boolean;
|
||||
}
|
||||
type PermissionsMode = "ADD" | "REPLACE" | "REMOVE";
|
||||
interface ExecCommandOptions {
|
||||
stdIn?: string;
|
||||
background?: boolean;
|
||||
cwd?: string;
|
||||
}
|
||||
interface ExecCommandResult {
|
||||
pid: number;
|
||||
stdOut: string;
|
||||
stdErr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
interface SpawnedProcess {
|
||||
id: number;
|
||||
pid: number;
|
||||
}
|
||||
interface SpawnedProcessOptions {
|
||||
cwd?: string;
|
||||
envs?: Record<string, string>;
|
||||
}
|
||||
interface Envs {
|
||||
[key: string]: string;
|
||||
}
|
||||
interface OpenDialogOptions {
|
||||
multiSelections?: boolean;
|
||||
filters?: Filter[];
|
||||
defaultPath?: string;
|
||||
}
|
||||
interface FolderDialogOptions {
|
||||
defaultPath?: string;
|
||||
}
|
||||
interface SaveDialogOptions {
|
||||
forceOverwrite?: boolean;
|
||||
filters?: Filter[];
|
||||
defaultPath?: string;
|
||||
}
|
||||
interface Filter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
interface TrayOptions {
|
||||
icon: string;
|
||||
menuItems: TrayMenuItem[];
|
||||
}
|
||||
interface TrayMenuItem {
|
||||
id?: string;
|
||||
text: string;
|
||||
isDisabled?: boolean;
|
||||
isChecked?: boolean;
|
||||
}
|
||||
type KnownPath = "config" | "data" | "cache" | "documents" | "pictures" | "music" | "video" | "downloads" | "savedGames1" | "savedGames2" | "temp";
|
||||
interface Manifest {
|
||||
applicationId: string;
|
||||
version: string;
|
||||
resourcesURL: string;
|
||||
}
|
||||
interface WindowOptions extends WindowSizeOptions, WindowPosOptions {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
fullScreen?: boolean;
|
||||
alwaysOnTop?: boolean;
|
||||
enableInspector?: boolean;
|
||||
borderless?: boolean;
|
||||
maximize?: boolean;
|
||||
hidden?: boolean;
|
||||
maximizable?: boolean;
|
||||
useSavedState?: boolean;
|
||||
exitProcessOnClose?: boolean;
|
||||
extendUserAgentWith?: string;
|
||||
injectGlobals?: boolean;
|
||||
injectClientLibrary?: boolean;
|
||||
injectScript?: string;
|
||||
processArgs?: string;
|
||||
}
|
||||
interface WindowSizeOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
resizable?: boolean;
|
||||
}
|
||||
interface WindowPosOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
interface WindowMenu extends Array<WindowMenuItem> {
|
||||
}
|
||||
interface WindowMenuItem {
|
||||
id?: string;
|
||||
text: string;
|
||||
isDisabled?: boolean;
|
||||
isChecked?: boolean;
|
||||
menuItems?: WindowMenuItem[];
|
||||
}
|
||||
interface Response {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
type Builtin = "ready" | "trayMenuItemClicked" | "windowClose" | "serverOffline" | "clientConnect" | "clientDisconnect" | "appClientConnect" | "appClientDisconnect" | "extClientConnect" | "extClientDisconnect" | "extensionReady" | "neuDev_reloadApp";
|
||||
|
||||
}
|
||||
|
||||
// debug
|
||||
enum LoggerType {
|
||||
WARNING = 'WARNING',
|
||||
ERROR = 'ERROR',
|
||||
INFO = 'INFO'
|
||||
}
|
||||
|
||||
// os
|
||||
enum Icon {
|
||||
WARNING = 'WARNING',
|
||||
ERROR = 'ERROR',
|
||||
INFO = 'INFO',
|
||||
QUESTION = 'QUESTION'
|
||||
}
|
||||
|
||||
enum MessageBoxChoice {
|
||||
OK = 'OK',
|
||||
OK_CANCEL = 'OK_CANCEL',
|
||||
YES_NO = 'YES_NO',
|
||||
YES_NO_CANCEL = 'YES_NO_CANCEL',
|
||||
RETRY_CANCEL = 'RETRY_CANCEL',
|
||||
ABORT_RETRY_IGNORE = 'ABORT_RETRY_IGNORE'
|
||||
}
|
||||
|
||||
//clipboard
|
||||
enum ClipboardFormat {
|
||||
unknown = 'unknown',
|
||||
text = 'text',
|
||||
image = 'image'
|
||||
}
|
||||
|
||||
// NL_GLOBALS
|
||||
enum Mode {
|
||||
window = 'window',
|
||||
browser = 'browser',
|
||||
cloud = 'cloud',
|
||||
chrome = 'chrome'
|
||||
}
|
||||
|
||||
enum OperatingSystem {
|
||||
Linux = 'Linux',
|
||||
Windows = 'Windows',
|
||||
Darwin = 'Darwin',
|
||||
FreeBSD = 'FreeBSD',
|
||||
Unknown = 'Unknown'
|
||||
}
|
||||
|
||||
enum Architecture {
|
||||
x64 = 'x64',
|
||||
arm = 'arm',
|
||||
itanium = 'itanium',
|
||||
ia32 = 'ia32',
|
||||
unknown = 'unknown'
|
||||
}
|
||||
|
||||
|
||||
interface Response {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type Builtin =
|
||||
'ready' |
|
||||
'trayMenuItemClicked' |
|
||||
'windowClose' |
|
||||
'serverOffline' |
|
||||
'clientConnect' |
|
||||
'clientDisconnect' |
|
||||
'appClientConnect' |
|
||||
'appClientDisconnect' |
|
||||
'extClientConnect' |
|
||||
'extClientDisconnect' |
|
||||
'extensionReady' |
|
||||
'neuDev_reloadApp'
|
||||
|
||||
|
||||
// --- globals ---
|
||||
/** Mode of the application: window, browser, cloud, or chrome */
|
||||
declare const NL_MODE: Mode;
|
||||
/** Application port */
|
||||
declare const NL_PORT: number;
|
||||
/** Command-line arguments */
|
||||
declare const NL_ARGS: string[];
|
||||
/** Basic authentication token */
|
||||
declare const NL_TOKEN: string;
|
||||
/** Neutralinojs client version */
|
||||
declare const NL_CVERSION: string;
|
||||
/** Application identifier */
|
||||
declare const NL_APPID: string;
|
||||
/** Application version */
|
||||
declare const NL_APPVERSION: string;
|
||||
/** Application path */
|
||||
declare const NL_PATH: string;
|
||||
/** Application data path */
|
||||
declare const NL_DATAPATH: string;
|
||||
/** Returns true if extensions are enabled */
|
||||
declare const NL_EXTENABLED: boolean;
|
||||
/** Returns true if the client library is injected */
|
||||
declare const NL_GINJECTED: boolean;
|
||||
/** Returns true if globals are injected */
|
||||
declare const NL_CINJECTED: boolean;
|
||||
/** Operating system name: Linux, Windows, Darwin, FreeBSD, or Uknown */
|
||||
declare const NL_OS: OperatingSystem;
|
||||
/** CPU architecture: x64, arm, itanium, ia32, or unknown */
|
||||
declare const NL_ARCH: Architecture;
|
||||
/** Neutralinojs server version */
|
||||
declare const NL_VERSION: string;
|
||||
/** Current working directory */
|
||||
declare const NL_CWD: string;
|
||||
/** Identifier of the current process */
|
||||
declare const NL_PID: string;
|
||||
/** Source of application resources: bundle or directory */
|
||||
declare const NL_RESMODE: string;
|
||||
/** Release commit of the client library */
|
||||
declare const NL_CCOMMIT: string;
|
||||
/** An array of custom methods */
|
||||
declare const NL_CMETHODS: string[];
|
||||
|
20
www/src/router/index.ts
Normal file
20
www/src/router/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
97
www/src/stores/counter.ts
Normal file
97
www/src/stores/counter.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { IntentionHistoryItem, Timer } from '@/types'
|
||||
import { tickClock } from '@/lib'
|
||||
|
||||
export const usePucotiStore = defineStore('pucoti', {
|
||||
state: () => {
|
||||
const countdownDuration = 10 * 60 * 1000
|
||||
const now = new Date().getTime()
|
||||
const ringTime = now + countdownDuration
|
||||
|
||||
return {
|
||||
countdownDuration,
|
||||
intentionHistory: [] as IntentionHistoryItem[],
|
||||
pucotiStart: now,
|
||||
lastRung: 0,
|
||||
ringEvery: '20s', // seconds
|
||||
secondaryTimers: ['total', 'onIntention'],
|
||||
commands: [
|
||||
{
|
||||
at: '0m',
|
||||
every: '1m',
|
||||
cmd: "notify-send 'Pucoti' 'Time was up 1+ minute ago !!'",
|
||||
lastRan: 0,
|
||||
},
|
||||
],
|
||||
timers: {
|
||||
main: {
|
||||
zeroAt: ringTime,
|
||||
increasing: false,
|
||||
name: 'Main timer',
|
||||
color: 'var(--color-light)',
|
||||
},
|
||||
onIntention: {
|
||||
zeroAt: now,
|
||||
increasing: true,
|
||||
name: 'On intention',
|
||||
color: 'var(--color-acid)',
|
||||
},
|
||||
} as Record<string, Timer>,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
intentionStart(): number {
|
||||
if (this.intentionHistory.length > 0) {
|
||||
return this.intentionHistory[this.intentionHistory.length - 1].start
|
||||
}
|
||||
return Date.now()
|
||||
},
|
||||
intention(): string {
|
||||
if (this.intentionHistory.length > 0) {
|
||||
return this.intentionHistory[this.intentionHistory.length - 1].intention
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setIntention(intention: string) {
|
||||
const now = Date.now()
|
||||
if (this.intentionHistory.length > 0) {
|
||||
this.intentionHistory[this.intentionHistory.length - 1].end = now
|
||||
}
|
||||
|
||||
this.intentionHistory.push({
|
||||
intention,
|
||||
start: now,
|
||||
})
|
||||
this.timers.onIntention.zeroAt = now
|
||||
},
|
||||
addTime(ms: number) {
|
||||
this.timers.main.zeroAt += ms
|
||||
tickClock()
|
||||
},
|
||||
setRingIn(ms: number) {
|
||||
this.timers.main.zeroAt = Date.now() + ms
|
||||
tickClock()
|
||||
},
|
||||
updateHistoricalIntention(index: number, newIntention: string) {
|
||||
if (index >= 0 && index < this.intentionHistory.length) {
|
||||
this.intentionHistory[index].intention = newIntention
|
||||
}
|
||||
},
|
||||
continueFromHistory(intention: string) {
|
||||
// Set the current intention without resetting the timer
|
||||
const now = Date.now()
|
||||
if (this.intentionHistory.length > 0) {
|
||||
this.intentionHistory[this.intentionHistory.length - 1].end = now
|
||||
}
|
||||
|
||||
this.intentionHistory.push({
|
||||
intention,
|
||||
start: now,
|
||||
})
|
||||
this.timers.onIntention.zeroAt = now
|
||||
},
|
||||
},
|
||||
})
|
12
www/src/types/index.d.ts
vendored
Normal file
12
www/src/types/index.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
export type IntentionHistoryItem = {
|
||||
intention: string
|
||||
start: number
|
||||
end?: number
|
||||
}
|
||||
|
||||
export type Timer = {
|
||||
zeroAt: number
|
||||
increasing: boolean
|
||||
color: string
|
||||
name: string
|
||||
}
|
154
www/src/utils.ts
Normal file
154
www/src/utils.ts
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { format } from 'date-fns'
|
||||
import type { Timer } from './types'
|
||||
|
||||
export const MINUTE = 60 * 1000
|
||||
|
||||
export function timerToMs(timer: Timer): number {
|
||||
return timer.increasing ? Date.now() - timer.zeroAt : timer.zeroAt - Date.now()
|
||||
}
|
||||
|
||||
export function timerToString(timer: Timer): string {
|
||||
let time = timerToMs(timer)
|
||||
|
||||
if (time < 0) {
|
||||
time = -time
|
||||
}
|
||||
|
||||
const parts = fmtSeconds(time)
|
||||
return parts.join(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time in seconds to the parts of a string.
|
||||
*
|
||||
* Example: 3661 -> ["1", "01", "01"]
|
||||
* Example: 61 -> ["01", "01"]
|
||||
*
|
||||
* @param {number} time Duration in seconds
|
||||
* @returns {string[]} Array of strings representing the time in the format [hours, minutes, seconds]. Hours is there only if it's not 0
|
||||
*/
|
||||
export function fmtSeconds(time: number) {
|
||||
const seconds = Math.floor(Math.abs(time) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const parts = []
|
||||
if (hours > 0) {
|
||||
parts.push(hours)
|
||||
}
|
||||
parts.push((minutes % 60).toString().padStart(2, '0'))
|
||||
parts.push((seconds % 60).toString().padStart(2, '0'))
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a human duration such as "1h 30m" to seconds.
|
||||
* @param {string} duration - The duration string to convert
|
||||
* @returns {number} The duration in miliseconds
|
||||
*/
|
||||
export function humanTimeToMs(duration: string): number {
|
||||
if (duration.startsWith('-')) {
|
||||
return -humanTimeToMs(duration.slice(1))
|
||||
}
|
||||
|
||||
// Parse the duration.
|
||||
let total = 0
|
||||
const multiplier = { s: 1, m: 60, h: 3600, d: 86400 }
|
||||
|
||||
const parts = duration.split(' ')
|
||||
for (const part of parts) {
|
||||
try {
|
||||
const value = parseInt(part.slice(0, -1))
|
||||
const unit = part.slice(-1)
|
||||
|
||||
if (isNaN(value) || !(unit in multiplier)) {
|
||||
throw new Error(`Invalid duration part: ${part}`)
|
||||
}
|
||||
|
||||
total += value * multiplier[unit]
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid duration part: ${part}`)
|
||||
}
|
||||
}
|
||||
|
||||
return total * 1000
|
||||
}
|
||||
|
||||
export function fmtTimeRelative(seconds: Date): string {
|
||||
/**
|
||||
* Get a datetime object or a number Epoch timestamp and return a
|
||||
* pretty string like 'an hour ago', 'Yesterday', '3 months ago',
|
||||
* 'just now', etc.
|
||||
*
|
||||
* The time must be in the past.
|
||||
*/
|
||||
|
||||
const diff = Date.now() - seconds.getTime()
|
||||
const secondDiff = Math.floor(diff / 1000)
|
||||
const dayDiff = Math.floor(secondDiff / 86400)
|
||||
|
||||
if (dayDiff < 0) {
|
||||
throw new Error('Invalid date difference')
|
||||
}
|
||||
|
||||
if (dayDiff === 0) {
|
||||
if (secondDiff < 10) return 'just now'
|
||||
if (secondDiff < 60) return `${secondDiff}s ago`
|
||||
if (secondDiff < 120) return `1m ${secondDiff % 60}s ago`
|
||||
if (secondDiff < 3600) return `${Math.floor(secondDiff / 60)}m ago`
|
||||
if (secondDiff < 7200) return `1h ${Math.floor((secondDiff % 3600) / 60)}m ago`
|
||||
if (secondDiff < 86400) return `${Math.floor(secondDiff / 3600)}h ago`
|
||||
}
|
||||
if (dayDiff <= 1) return 'Yesterday'
|
||||
if (dayDiff < 7) return `${dayDiff} days ago`
|
||||
if (dayDiff < 31) return `${Math.floor(dayDiff / 7)} weeks ago`
|
||||
if (dayDiff < 365) return `${Math.floor(dayDiff / 30)} months ago`
|
||||
|
||||
return `${Math.floor(dayDiff / 365)} years ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Human readable time format for dates in the past.
|
||||
* E.g. "at 12:34", "Yest at 12:34", "Mon at 12:34", "Mon 12 at 12:34", "Mon 12 Jan at 12:34"
|
||||
*/
|
||||
export function fmtTimeAbsolute(seconds: Date): string {
|
||||
const now = new Date()
|
||||
|
||||
const diff = now.getTime() - seconds.getTime()
|
||||
const dayDiff = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (dayDiff < 0) {
|
||||
throw new Error('Invalid date difference')
|
||||
}
|
||||
|
||||
if (dayDiff === 0) {
|
||||
// return `at ${format(seconds, 'HH:mm')}`;
|
||||
return format(seconds, "'at' HH:mm")
|
||||
}
|
||||
if (dayDiff === 1) {
|
||||
// return `Yest at ${format(seconds, 'HH:mm')}`;
|
||||
return format(seconds, "'Yest' HH:mm")
|
||||
}
|
||||
if (dayDiff < 7) {
|
||||
// return `${format(seconds, 'EEE')} at ${format(seconds, 'HH:mm')}`;
|
||||
return format(seconds, "EEE 'at' HH:mm")
|
||||
}
|
||||
if (dayDiff < 31 && now.getMonth() === seconds.getMonth()) {
|
||||
// return `${format(seconds, 'EEE dd')} at ${format(seconds, 'HH:mm')}`;
|
||||
return format(seconds, "EEE dd 'at' HH:mm")
|
||||
}
|
||||
if (now.getFullYear() === seconds.getFullYear()) {
|
||||
// return `${format(seconds, 'EEE dd MMM')} at ${format(seconds, 'HH:mm')}`;
|
||||
return format(seconds, "EEE dd MMM 'at' HH:mm")
|
||||
}
|
||||
// return `${format(seconds, 'EEE dd MMM yyyy')} at ${format(seconds, 'HH:mm')}`;
|
||||
return format(seconds, "EEE dd MMM yyyy 'at' HH:mm")
|
||||
}
|
||||
|
||||
export function fmtTime(seconds: Date, relative: boolean = true): string {
|
||||
return relative ? fmtTimeRelative(seconds) : fmtTimeAbsolute(seconds)
|
||||
}
|
||||
|
||||
export function computeTimerEnd(timer: number, start: number): number {
|
||||
// +0.5 to show visually round time -> more satisfying
|
||||
return timer + (Math.round(Date.now() / 1000 + 0.5) - start)
|
||||
}
|
345
www/src/views/HomeView.vue
Normal file
345
www/src/views/HomeView.vue
Normal file
|
@ -0,0 +1,345 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, nextTick, useTemplateRef } from 'vue'
|
||||
import Button from '@/components/Button.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import Timer from '@/components/Timer.vue'
|
||||
import IntentionHistory from '@/components/IntentionHistory.vue'
|
||||
import { usePucotiStore } from '@/stores/counter'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { humanTimeToMs, MINUTE } from '@/utils'
|
||||
import { useListenerFn } from '@/lib'
|
||||
|
||||
const store = usePucotiStore()
|
||||
|
||||
const isEditingTime = ref<boolean>(false)
|
||||
const newTimeValue = ref<string>('')
|
||||
const timeInputRef = ref<HTMLInputElement | null>(null)
|
||||
const startEditingTime = () => {
|
||||
isEditingTime.value = true
|
||||
nextTick(() => {
|
||||
if (timeInputRef.value) {
|
||||
timeInputRef.value.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
const updateTimer = (e) => {
|
||||
try {
|
||||
let newTime = humanTimeToMs(e.target.value)
|
||||
store.setRingIn(newTime)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
stopEditingTime()
|
||||
}
|
||||
|
||||
const stopEditingTime = () => {
|
||||
isEditingTime.value = false
|
||||
newTimeValue.value = ''
|
||||
}
|
||||
|
||||
const isEditingIntention = ref<boolean>(false)
|
||||
const $intention = useTemplateRef<HTMLInputElement>('intention')
|
||||
|
||||
const startEditingIntention = () => {
|
||||
isEditingIntention.value = true
|
||||
nextTick(() => {
|
||||
if ($intention.value) {
|
||||
$intention.value.focus()
|
||||
$intention.value.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopEditingIntention = () => {
|
||||
$intention.value = store.intention // Revert to the current store value
|
||||
$intention.value.blur() // blur to take focus away from the input
|
||||
isEditingIntention.value = false
|
||||
}
|
||||
|
||||
function onIntentionInput(e: KeyboardEvent) {
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.target as HTMLInputElement
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
store.setIntention(target.value)
|
||||
stopEditingIntention()
|
||||
} else if (e.key === 'Escape') {
|
||||
target.value = store.intention
|
||||
stopEditingIntention()
|
||||
}
|
||||
}
|
||||
|
||||
useListenerFn('keydown', (e: KeyboardEvent) => {
|
||||
// If we are editing either intention or time, global shortcuts are disabled
|
||||
if (isEditingIntention.value || isEditingTime.value) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'j':
|
||||
store.addTime(-MINUTE)
|
||||
break
|
||||
case 'J':
|
||||
store.addTime(-5 * MINUTE)
|
||||
break
|
||||
case 'k':
|
||||
store.addTime(MINUTE)
|
||||
break
|
||||
case 'K':
|
||||
store.addTime(5 * MINUTE)
|
||||
break
|
||||
case 'r':
|
||||
store.setRingIn(store.countdownDuration)
|
||||
break
|
||||
case 'h':
|
||||
router.push('/help')
|
||||
break
|
||||
case 's':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'p':
|
||||
router.push('/intentionhistory')
|
||||
break
|
||||
case 'Enter':
|
||||
startEditingIntention()
|
||||
break
|
||||
default:
|
||||
// Key corresponding to 0-9 -> set to that minute
|
||||
// + if shift is pressed, set to 10*key
|
||||
if (e.code.startsWith('Digit')) {
|
||||
let digit = parseInt(e.code[5])
|
||||
if (e.shiftKey) {
|
||||
digit *= 10
|
||||
}
|
||||
store.setRingIn(digit * MINUTE)
|
||||
store.countdownDuration = digit * MINUTE
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<RouterLink to="/">
|
||||
<AppLogo />
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://pucoti.com" class="">Download</a>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink to="/settings" class="">Settings</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="intention-area">
|
||||
<div v-if="!isEditingIntention" class="intention-placeholder" @click="startEditingIntention">
|
||||
{{ store.intention || 'Enter your intention' }}
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
id="intention"
|
||||
type="text"
|
||||
ref="intention"
|
||||
:value="store.intention"
|
||||
placeholder="Enter your intention"
|
||||
@keydown.stop="onIntentionInput"
|
||||
@blur="stopEditingIntention"
|
||||
/>
|
||||
</div>
|
||||
<div class="main-layout">
|
||||
<div class="timer-layout">
|
||||
<div class="action-item-list">
|
||||
<Button label="-1 min" shortcut="j" @click="store.addTime(-MINUTE)" />
|
||||
<Button label="-5 min" shortcut="J" @click="store.addTime(-5 * MINUTE)" />
|
||||
</div>
|
||||
<div v-if="!isEditingTime" class="main-timer-container" @click="startEditingTime">
|
||||
<Timer :timer="store.timers.main" color="var(--color-light)" />
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
ref="timeInputRef"
|
||||
v-model="newTimeValue"
|
||||
placeholder="1h 10m"
|
||||
class="timer-input"
|
||||
@keyup.escape="stopEditingTime"
|
||||
@keyup.enter="updateTimer"
|
||||
@blur="stopEditingTime"
|
||||
/>
|
||||
<div class="action-item-list">
|
||||
<Button label="+1 min" shortcut="k" @click="store.addTime(MINUTE)" />
|
||||
<Button label="+5 min" shortcut="K" @click="store.addTime(5 * MINUTE)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-action-items">
|
||||
<Button label="Create room" shortcut="C" outline />
|
||||
<Button label="Enter room" shortcut="E" outline />
|
||||
</div>
|
||||
<div class="below">
|
||||
<IntentionHistory />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
padding: 1vw 1vw 0 1vw;
|
||||
}
|
||||
|
||||
nav > ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav > ul > li:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
nav > ul > li + li {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
|
||||
main {
|
||||
background: var(--color-dark);
|
||||
margin: 1vw 1vw 0 1vw;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
margin-top: clamp(1em, 1.5vw, 100vw);
|
||||
color: var(--color-acid);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1em, 2vw, 100vw);
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-timer-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: color-mix(in oklab, var(--color-light) 10%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
margin-top: clamp(1em, 1.5vw, 100vw);
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'action-left timer action-right'
|
||||
'. timer .'
|
||||
'. footer .'
|
||||
'. below .';
|
||||
grid-template-columns: auto 1fr auto;
|
||||
column-gap: 1vw;
|
||||
}
|
||||
|
||||
.timer-layout {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.action-item-list:first-of-type {
|
||||
grid-area: action-left;
|
||||
}
|
||||
|
||||
.main-timer-container {
|
||||
grid-area: timer;
|
||||
}
|
||||
|
||||
.below {
|
||||
grid-area: below;
|
||||
}
|
||||
|
||||
.action-item-list:last-of-type {
|
||||
grid-area: action-right;
|
||||
}
|
||||
|
||||
.action-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-item-list > * + * {
|
||||
margin-top: 1vw;
|
||||
}
|
||||
|
||||
.footer-action-items {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
margin-top: clamp(1em, 1.5vw, 100vw);
|
||||
}
|
||||
|
||||
.footer-action-items > * + * {
|
||||
margin-left: 1vw;
|
||||
}
|
||||
|
||||
.timer-input {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(4em, 12vw, 100vw);
|
||||
color: var(--color-light);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.timer-input::placeholder {
|
||||
color: color-mix(in srgb, var(--color-light) 50%, transparent);
|
||||
}
|
||||
|
||||
.intention-area {
|
||||
margin-top: clamp(1em, 1.5vw, 100vw);
|
||||
text-align: center;
|
||||
/* Center the content within the div */
|
||||
}
|
||||
|
||||
.intention-placeholder {
|
||||
display: inline-block;
|
||||
/* Allows padding and margin */
|
||||
color: var(--color-acid);
|
||||
background-color: color-mix(in oklab, var(--color-light) 10%, transparent);
|
||||
font-family: var(--font-display);
|
||||
font-size: 3em;
|
||||
/* Match input font size */
|
||||
padding: 0.1em 0.5em;
|
||||
/* Add some padding to make it look like an input */
|
||||
cursor: text;
|
||||
/* Indicate it's clickable/editable */
|
||||
min-width: 300px;
|
||||
/* Ensure a minimum width */
|
||||
}
|
||||
|
||||
#intention {
|
||||
display: inline-block;
|
||||
/* Use inline-block for consistent centering */
|
||||
color: var(--color-acid);
|
||||
background-color: color-mix(in oklab, var(--color-light) 10%, transparent);
|
||||
font-family: var(--font-display);
|
||||
font-size: 3em;
|
||||
text-align: center;
|
||||
border: none;
|
||||
/* Remove default input border */
|
||||
outline: none;
|
||||
/* Remove default input outline */
|
||||
padding: 0.1em 0.5em;
|
||||
/* Match placeholder padding */
|
||||
min-width: 300px;
|
||||
/* Match placeholder width */
|
||||
}
|
||||
|
||||
#intention::placeholder {
|
||||
color: color-mix(in oklab, var(--color-acid) 40%, transparent);
|
||||
}
|
||||
</style>
|
3
www/src/views/SettingsView.vue
Normal file
3
www/src/views/SettingsView.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<h1>Settings</h1>
|
||||
</template>
|
12
www/tsconfig.app.json
Normal file
12
www/tsconfig.app.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
11
www/tsconfig.json
Normal file
11
www/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
19
www/tsconfig.node.json
Normal file
19
www/tsconfig.node.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
16
www/vite.config.ts
Normal file
16
www/vite.config.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), vueDevTools(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue