This commit is contained in:
Félix Dorn 2025-06-19 12:27:59 +02:00
commit c3ee96429c
46 changed files with 6006 additions and 0 deletions

9
.editorconfig Normal file
View 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
View file

@ -0,0 +1 @@
use flake

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

30
.gitignore vendored Normal file
View 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

Binary file not shown.

BIN
bin/neutralino-linux_armhf Executable file

Binary file not shown.

BIN
bin/neutralino-linux_x64 Executable file

Binary file not shown.

BIN
bin/neutralino-mac_arm64 Executable file

Binary file not shown.

BIN
bin/neutralino-mac_universal Executable file

Binary file not shown.

BIN
bin/neutralino-mac_x64 Executable file

Binary file not shown.

BIN
bin/neutralino-win_x64.exe Normal file

Binary file not shown.

1
build-scripts Submodule

@ -0,0 +1 @@
Subproject commit ececd00d5fcbc78b83947db8fbab4a4b628ffd13

27
flake.lock generated Normal file
View 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
View 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
View 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

Binary file not shown.

6
www/.prettierrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

39
www/README.md Normal file
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

24
www/eslint.config.ts Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

20
www/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

BIN
www/public/bell.mp3 Normal file

Binary file not shown.

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

File diff suppressed because one or more lines are too long

42
www/src/App.vue Normal file
View 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
View 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;
}

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View file

@ -0,0 +1,3 @@
<template>
<h1>Settings</h1>
</template>

12
www/tsconfig.app.json Normal file
View 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
View file

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
www/tsconfig.node.json Normal file
View 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
View 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)),
},
},
})