Mindlist v1: FOSS Neurolist clone (PWA + Capacitor Android), v3 logo, signed release
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
# Secrets — NEVER commit (Openrouter.md contains a live API key)
|
||||||
|
Openrouter.md
|
||||||
|
**/keystore.properties
|
||||||
|
**/*.keystore
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.npm/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Vite / PWA build output (rebuilt via `npm run build`)
|
||||||
|
# (dist/ ignored above)
|
||||||
|
|
||||||
|
# Android / Gradle
|
||||||
|
android/.gradle/
|
||||||
|
android/app/build/
|
||||||
|
android/build/
|
||||||
|
android/captures/
|
||||||
|
android/local.properties
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Capacitor (generated platform can be re-added via `npx cap add android`)
|
||||||
|
# NOTE: android/ IS committed intentionally (icons + signing config),
|
||||||
|
# only build outputs above are ignored.
|
||||||
|
|
||||||
|
# macOS / editors
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Logo render previews (sources are logo/*.svg)
|
||||||
|
logo/v*.png
|
||||||
|
logo/fg-*.png
|
||||||
|
|
||||||
|
# Screenshots
|
||||||
|
/tmp/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Mindlist — Free Open-Source Neurolist Alternative (BYOK)
|
||||||
|
|
||||||
|
PWA built with Vite + React + TS + Tailwind + Dexie. Local-first, no backend, no tracking.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Magic Subtasks Easy/Medium/Hard with Life Context shaping
|
||||||
|
- Brain Dump AI Import, AI Edit chat patching
|
||||||
|
- Single-task Focus playlist timer + voice announcements + dynamic end-time + Wake Lock
|
||||||
|
- Task Library reuse, calendar export (.ics with VALARM), dark mode, offline heuristic fallback
|
||||||
|
- BYOK OpenRouter, default `deepseek/deepseek-v4.1-flash` (`response_format: json_object`)
|
||||||
|
|
||||||
|
## BYOK setup
|
||||||
|
1. Get a key at https://openrouter.ai/keys
|
||||||
|
2. Open ⚙️ Settings in the app, paste key, confirm model `deepseek/deepseek-v4.1-flash`
|
||||||
|
3. Key is stored in `localStorage` only (`mindlist.or.key`), sent directly to `https://openrouter.ai/api/v1/chat/completions`. Never committed.
|
||||||
|
|
||||||
|
> The example key in `../Openrouter.md` is revoked/placeholder — do not reuse.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npx vitest run
|
||||||
|
npm run build # PWA in dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
Static hosting: `npm run build` → deploy `dist/` to GitHub Pages / Netlify / Vercel. PWA installable on Android via browser → Add to Home screen. Optional Capacitor/TWA wrapper for Play Store later.
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT — free for personal + commercial use.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
def keystoreProps = new Properties()
|
||||||
|
def keystorePropsFile = rootProject.file('keystore.properties')
|
||||||
|
if (keystorePropsFile.exists()) {
|
||||||
|
keystoreProps.load(new FileInputStream(keystorePropsFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.mindlist.foss"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "app.mindlist.foss"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keystorePropsFile.exists()) {
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file(keystoreProps['storeFile'])
|
||||||
|
storePassword keystoreProps['storePassword']
|
||||||
|
keyAlias keystoreProps['keyAlias']
|
||||||
|
keyPassword keystoreProps['keyPassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
if (keystorePropsFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package app.mindlist.foss;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#0B6E64</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Mindlist</string>
|
||||||
|
<string name="title_activity_main">Mindlist</string>
|
||||||
|
<string name="package_name">app.mindlist.foss</string>
|
||||||
|
<string name="custom_url_scheme">app.mindlist.foss</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
androidxActivityVersion = '1.11.0'
|
||||||
|
androidxAppCompatVersion = '1.7.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
|
androidxCoreVersion = '1.17.0'
|
||||||
|
androidxFragmentVersion = '1.8.9'
|
||||||
|
coreSplashScreenVersion = '1.2.0'
|
||||||
|
androidxWebkitVersion = '1.14.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.3.0'
|
||||||
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
|
cordovaAndroidVersion = '14.0.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'app.mindlist.foss',
|
||||||
|
appName: 'Mindlist',
|
||||||
|
webDir: 'dist'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#14b8a6" />
|
||||||
|
<meta name="description" content="Mindlist — free open-source ADHD task breaker. BYOK OpenRouter, micro-steps, focus timer." />
|
||||||
|
<title>Mindlist — Free ADHD Task Breaker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<!-- adaptive-icon foreground: v3 glyph centered on transparent, 59% safe-zone diameter -->
|
||||||
|
<path d="M 256 124 A 132 132 0 1 0 360 180" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<circle cx="316" cy="138" r="16" fill="#ffffff"/>
|
||||||
|
<path d="M 210 270 l 34 34 l 64 -76" fill="none" stroke="#ffffff" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#14b8a6"/>
|
||||||
|
<stop offset="1" stop-color="#0f766e"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
|
||||||
|
<!-- focus ring: calm timer metaphor, gap at top-right -->
|
||||||
|
<path d="M 256 128 a 128 128 0 1 0 90.5 37.5" fill="none" stroke="#ffffff" stroke-width="46" stroke-linecap="round"/>
|
||||||
|
<!-- check: done, dopamine win -->
|
||||||
|
<path d="M 198 262 l 42 42 l 78 -92" fill="none" stroke="#ffffff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.18)" stroke-width="2"/>
|
||||||
|
<!-- focus ring: calm timer metaphor, wide 75deg gap top-right -->
|
||||||
|
<path d="M 256 124 A 132 132 0 1 0 360 180" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- timer dot: distinctiveness cue at gap end -->
|
||||||
|
<circle cx="316" cy="138" r="16" fill="#ffffff"/>
|
||||||
|
<!-- check: done — shrunk 15%, optically centered -->
|
||||||
|
<path d="M 210 270 l 34 34 l 64 -76" fill="none" stroke="#ffffff" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 860 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.18)" stroke-width="2"/>
|
||||||
|
<!-- focus ring: calm timer metaphor, wide 75deg gap top-right -->
|
||||||
|
<path d="M 256 124 A 132 132 0 1 0 360 180" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- timer dot: distinctiveness cue at gap end -->
|
||||||
|
<circle cx="316" cy="138" r="16" fill="#ffffff"/>
|
||||||
|
<!-- check: done — shrunk 15%, optically centered -->
|
||||||
|
<path d="M 210 270 l 34 34 l 64 -76" fill="none" stroke="#ffffff" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 860 B |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffffff" stop-opacity="0.14"/>
|
||||||
|
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.25)" stroke-width="3"/>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#sheen)"/>
|
||||||
|
<!-- focus ring: calm timer metaphor, wide gap top-right -->
|
||||||
|
<path d="M 256 124 A 132 132 0 1 0 360 180" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- timer-hand knob: structural pip fused to ring end -->
|
||||||
|
<circle cx="360" cy="180" r="21" fill="#ffffff"/>
|
||||||
|
<!-- check: done — lightened to sit under ring weight -->
|
||||||
|
<path d="M 215 270 l 30 30 l 56 -68" fill="none" stroke="#ffffff" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.25)" stroke-width="3"/>
|
||||||
|
<!-- focus ring: timer at 12 o'clock, 70deg gap centered top -->
|
||||||
|
<path d="M 180 148 A 132 132 0 1 0 332 148" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- timer-hand knob fused to ring end, nudged outward to preserve notch -->
|
||||||
|
<circle cx="336" cy="143" r="16" fill="#ffffff"/>
|
||||||
|
<!-- check: done, lightened + optically centered -->
|
||||||
|
<path d="M 212 258 l 30 30 l 58 -70" fill="none" stroke="#ffffff" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 881 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.25)" stroke-width="3"/>
|
||||||
|
<!-- open focus ring: calm mindful gap, 80deg centered at 12 o'clock -->
|
||||||
|
<path d="M 171 155 A 132 132 0 1 0 341 155" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- check: done -->
|
||||||
|
<path d="M 208 256 l 32 32 l 62 -74" fill="none" stroke="#ffffff" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 726 B |
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.5.2",
|
||||||
|
"@capacitor/cli": "^8.5.2",
|
||||||
|
"@capacitor/core": "^8.5.2",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"dexie": "^4.4.6",
|
||||||
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"lucide-react": "^1.49.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.18)" stroke-width="2"/>
|
||||||
|
<!-- focus ring: calm timer metaphor, wide 75deg gap top-right -->
|
||||||
|
<path d="M 256 124 A 132 132 0 1 0 360 180" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- timer dot: distinctiveness cue at gap end -->
|
||||||
|
<circle cx="316" cy="138" r="16" fill="#ffffff"/>
|
||||||
|
<!-- check: done — shrunk 15%, optically centered -->
|
||||||
|
<path d="M 210 270 l 34 34 l 64 -76" fill="none" stroke="#ffffff" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 860 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d9488"/>
|
||||||
|
<stop offset="1" stop-color="#0b6e64"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="118" fill="url(#bg)" stroke="rgba(0,0,0,0.18)" stroke-width="2"/>
|
||||||
|
<!-- focus ring: calm timer metaphor, wide 75deg gap top-right -->
|
||||||
|
<path d="M 256 124 A 132 132 0 1 0 360 180" fill="none" stroke="#ffffff" stroke-width="36" stroke-linecap="round"/>
|
||||||
|
<!-- timer dot: distinctiveness cue at gap end -->
|
||||||
|
<circle cx="316" cy="138" r="16" fill="#ffffff"/>
|
||||||
|
<!-- check: done — shrunk 15%, optically centered -->
|
||||||
|
<path d="M 210 270 l 34 34 l 64 -76" fill="none" stroke="#ffffff" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
CalendarPlus,
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Copy,
|
||||||
|
Home,
|
||||||
|
ListTodo,
|
||||||
|
Moon,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Settings2,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
Trash2,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Wand2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { beep, heuristicPlan, speak } from './lib/fallback';
|
||||||
|
import {
|
||||||
|
callOpenRouter,
|
||||||
|
getKey,
|
||||||
|
getModel,
|
||||||
|
setKey,
|
||||||
|
setModel,
|
||||||
|
type GenPlan,
|
||||||
|
} from './lib/openrouter';
|
||||||
|
import { buildAiEditSystem, buildBrainDumpSystem, buildSystemPrompt, type Difficulty } from './lib/prompts';
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
downloadICS,
|
||||||
|
getDark,
|
||||||
|
getLifeContext,
|
||||||
|
getVoiceOn,
|
||||||
|
setDark,
|
||||||
|
setLifeContext,
|
||||||
|
setVoiceOn,
|
||||||
|
uid,
|
||||||
|
type Step,
|
||||||
|
type Task,
|
||||||
|
} from './lib/store';
|
||||||
|
|
||||||
|
const CATS = ['Work', 'Home', 'Personal', 'Health'] as const;
|
||||||
|
const CAT_EMOJI: Record<string, string> = { Work: '💼', Home: '🏠', Personal: '🌱', Health: '💚' };
|
||||||
|
|
||||||
|
function fmt(s: number) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedEnd(steps: Step[]): Date {
|
||||||
|
const remain = steps.filter((s) => s.status !== 'done').reduce((a, s) => a + s.secondsLeft, 0);
|
||||||
|
return new Date(Date.now() + remain * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [view, setView] = useState<'today' | 'new' | 'dump' | 'library'>('today');
|
||||||
|
const [catFilter, setCatFilter] = useState<string>('All');
|
||||||
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||||
|
const [focusMode, setFocusMode] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [showAiEdit, setShowAiEdit] = useState(false);
|
||||||
|
const [dark, setDarkState] = useState(getDark());
|
||||||
|
const [voice, setVoice] = useState(getVoiceOn());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
setDark(dark);
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
|
const tasks = useLiveQuery(() => db.tasks.orderBy('createdAt').reverse().toArray(), []) ?? [];
|
||||||
|
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? tasks.find((t) => t.status !== 'done') ?? null;
|
||||||
|
const activeSteps = useLiveQuery(
|
||||||
|
() => (activeTask ? db.steps.where('taskId').equals(activeTask.id).sortBy('order') : Promise.resolve([] as Step[])),
|
||||||
|
[activeTask?.id],
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => (catFilter === 'All' ? tasks : tasks.filter((t) => t.category === catFilter)),
|
||||||
|
[tasks, catFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[#f6f7f7] text-slate-800 dark:bg-[#0f1115] dark:text-slate-100">
|
||||||
|
<div className="mx-auto flex min-h-dvh w-full max-w-[480px] flex-col border-x border-black/5 bg-white dark:border-white/10 dark:bg-[#15181e]">
|
||||||
|
{/* header */}
|
||||||
|
<header className="sticky top-0 z-20 flex items-center justify-between bg-teal-700 px-4 pb-3 pt-[max(1rem,env(safe-area-inset-top))] text-white dark:bg-teal-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src="logo.svg" alt="" aria-hidden="true" className="size-9 rounded-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-bold leading-none">mindlist</p>
|
||||||
|
<p className="text-[11px] text-teal-50">free ADHD task breaker</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button aria-label={voice ? 'Mute voice announcements' : 'Enable voice announcements'} aria-pressed={voice} onClick={() => { const v = !voice; setVoice(v); setVoiceOn(v); }}
|
||||||
|
className="grid size-11 place-items-center rounded-full hover:bg-white/20">{voice ? <Volume2 size={18} /> : <VolumeX size={18} />}</button>
|
||||||
|
<button aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'} aria-pressed={dark} onClick={() => setDarkState(!dark)}
|
||||||
|
className="grid size-11 place-items-center rounded-full hover:bg-white/20">{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||||
|
<button aria-label="Open settings" onClick={() => setShowSettings(true)}
|
||||||
|
className="grid size-11 place-items-center rounded-full hover:bg-white/20"><Settings2 size={18} /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* category tabs */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto px-4 py-3" role="toolbar" aria-label="Filter by category">
|
||||||
|
{['All', ...CATS].map((c) => (
|
||||||
|
<button key={c} aria-pressed={catFilter === c} onClick={() => setCatFilter(c)}
|
||||||
|
className={`min-h-[44px] shrink-0 rounded-full px-4 py-2 text-[13px] font-medium ${catFilter === c ? 'bg-teal-700 text-white' : 'bg-slate-100 text-slate-700 dark:bg-white/10 dark:text-slate-200'}`}>
|
||||||
|
{c === 'All' ? 'All' : `${CAT_EMOJI[c]} ${c}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="flex-1 px-4 pb-28">
|
||||||
|
{view === 'today' && (
|
||||||
|
<TodayView tasks={filtered} doneCount={doneCount} activeSteps={activeSteps}
|
||||||
|
onOpen={(t) => { setActiveTaskId(t.id); setView('new'); }}
|
||||||
|
onFocus={(t) => { setActiveTaskId(t.id); setFocusMode(true); }} />
|
||||||
|
)}
|
||||||
|
{view === 'new' && (
|
||||||
|
<ComposerView activeTask={activeTask} activeSteps={activeSteps}
|
||||||
|
onCreated={(id) => setActiveTaskId(id)}
|
||||||
|
onFocus={() => setFocusMode(true)}
|
||||||
|
onEdit={() => setShowAiEdit(true)} />
|
||||||
|
)}
|
||||||
|
{view === 'dump' && <DumpView onCreated={(id) => { setActiveTaskId(id); setView('new'); }} />}
|
||||||
|
{view === 'library' && (
|
||||||
|
<LibraryView tasks={tasks}
|
||||||
|
onOpen={(t) => { setActiveTaskId(t.id); setView('new'); }}
|
||||||
|
onReuse={async (t) => {
|
||||||
|
const steps = await db.steps.where('taskId').equals(t.id).sortBy('order');
|
||||||
|
const nid = uid();
|
||||||
|
await db.tasks.add({ ...t, id: nid, status: 'planned', createdAt: Date.now(), completedAt: undefined });
|
||||||
|
for (const [i, s] of steps.entries())
|
||||||
|
await db.steps.add({ ...s, id: uid(), taskId: nid, order: i, status: 'todo', secondsLeft: s.minutes * 60 });
|
||||||
|
setActiveTaskId(nid); setView('new');
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* bottom nav */}
|
||||||
|
<nav className="fixed bottom-0 left-1/2 z-20 w-full max-w-[480px] -translate-x-1/2 border-t border-black/10 bg-white/95 px-6 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2 backdrop-blur dark:border-white/10 dark:bg-[#15181e]/95">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<NavBtn icon={<Home size={20} />} label="Today" active={view === 'today'} onClick={() => setView('today')} />
|
||||||
|
<NavBtn icon={<Plus size={20} />} label="New" active={view === 'new'} onClick={() => setView('new')} />
|
||||||
|
<button onClick={() => { if (activeTaskId) { setFocusMode(true); } else { setView('new'); } }} aria-label={activeTaskId ? "Start focus timer" : "Create new task"}
|
||||||
|
className="grid size-12 place-items-center rounded-full bg-teal-700 text-2xl text-white"><Sparkles size={22} /></button>
|
||||||
|
<NavBtn icon={<Brain size={20} />} label="Dump" active={view === 'dump'} onClick={() => setView('dump')} />
|
||||||
|
<NavBtn icon={<ListTodo size={20} />} label="Library" active={view === 'library'} onClick={() => setView('library')} />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{focusMode && activeTask && (
|
||||||
|
<FocusMode task={activeTask} steps={activeSteps} voice={voice} onClose={() => setFocusMode(false)} />
|
||||||
|
)}
|
||||||
|
{showAiEdit && activeTask && (
|
||||||
|
<AiEditSheet task={activeTask} steps={activeSteps} onClose={() => setShowAiEdit(false)} />
|
||||||
|
)}
|
||||||
|
{showSettings && <SettingsSheet onClose={() => setShowSettings(false)} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavBtn({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} aria-current={active ? 'page' : undefined} className={`flex min-h-[48px] min-w-[56px] flex-col items-center justify-center gap-0.5 px-2 py-1 text-[11px] font-medium ${active ? 'text-teal-700 dark:text-teal-300' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{icon}{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Today ---------------- */
|
||||||
|
function TodayView({ tasks, doneCount, activeSteps, onOpen, onFocus }: {
|
||||||
|
tasks: Task[]; doneCount: number; activeSteps: Step[];
|
||||||
|
onOpen: (t: Task) => void; onFocus: (t: Task) => void;
|
||||||
|
}) {
|
||||||
|
const today = new Date().toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||||
|
const active = tasks.find((t) => t.status !== 'done');
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-2xl bg-teal-50 p-4 dark:bg-white/5">
|
||||||
|
<p className="text-[13px] text-slate-500 dark:text-slate-400">📅 {today} · 🎉 {doneCount} tasks overall completed</p>
|
||||||
|
{active ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[15px] font-semibold">▶ {active.title}</p>
|
||||||
|
<p className="text-[12px] text-slate-500">{activeSteps.filter((s) => s.status === 'done').length}/{activeSteps.length} steps · ~{active.totalMin} min</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button onClick={() => onFocus(active)} className="min-h-[44px] rounded-xl bg-teal-700 px-4 py-2 text-[13px] font-semibold text-white">Continue focus</button>
|
||||||
|
<button onClick={() => onOpen(active)} className="rounded-xl bg-white px-4 py-2 text-[13px] font-semibold text-teal-700 shadow dark:bg-white/10 dark:text-teal-200">Details</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-[14px]">Nothing planned. Tap <b>+ New</b> or <b>Dump</b> to break a scary task down. 🌱</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tasks.map((t) => (
|
||||||
|
<button key={t.id} onClick={() => onOpen(t)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl border border-black/5 bg-white p-3 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<span className="text-xl">{CAT_EMOJI[t.category] ?? '✅'}</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className={`block text-[14px] font-medium ${t.status === 'done' ? 'line-through opacity-60' : ''}`}>{t.title}</span>
|
||||||
|
<span className="text-[12px] text-slate-500">{t.difficulty} · {t.totalMin} min · {t.status}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="text-slate-300" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tasks.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-dashed border-teal-300 p-6 text-center text-[13px] text-slate-500">
|
||||||
|
🧠 #1 AI Planner for ADHD & neurodivergence<br />Break down stressful tasks → focus timer keeps you on track.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Composer ---------------- */
|
||||||
|
function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
||||||
|
activeTask: Task | null; activeSteps: Step[];
|
||||||
|
onCreated: (id: string) => void; onFocus: () => void; onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [category, setCategory] = useState<string>('Home');
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
|
||||||
|
const generate = useCallback(async () => {
|
||||||
|
const t = title.trim() || activeTask?.title || '';
|
||||||
|
if (!t) { setErr('Type a task first — e.g. “clean my room”.'); return; }
|
||||||
|
setLoading(true); setErr(''); setNotice('');
|
||||||
|
try {
|
||||||
|
const ctx = getLifeContext();
|
||||||
|
let plan: GenPlan;
|
||||||
|
if (getKey()) {
|
||||||
|
try {
|
||||||
|
const { plan: p, raw } = await callOpenRouter({ system: buildSystemPrompt(difficulty, ctx), user: t });
|
||||||
|
if (!p) throw new Error(`AI returned invalid JSON. Raw: ${raw.slice(0, 200)}`);
|
||||||
|
plan = p;
|
||||||
|
} catch (e) {
|
||||||
|
// offline / 429 / 5xx fallback so timer still works
|
||||||
|
plan = heuristicPlan(t, difficulty);
|
||||||
|
setNotice(`Offline mode — AI unavailable, used built-in breakdown. Add key in ⚙️ for smarter plans.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plan = heuristicPlan(t, difficulty); // offline demo mode
|
||||||
|
}
|
||||||
|
const id = activeTask && activeTask.title === t ? activeTask.id : uid();
|
||||||
|
if (activeTask && activeTask.title === t) {
|
||||||
|
await db.steps.where('taskId').equals(id).delete();
|
||||||
|
await db.tasks.update(id, { difficulty, category, totalMin: plan.totalMin, status: 'planned', lifeContext: ctx });
|
||||||
|
} else {
|
||||||
|
await db.tasks.add({ id, title: t, category, difficulty, lifeContext: ctx, totalMin: plan.totalMin, status: 'planned', createdAt: Date.now() });
|
||||||
|
}
|
||||||
|
for (const [i, s] of plan.steps.entries())
|
||||||
|
await db.steps.add({ id: uid(), taskId: id, order: i, emoji: s.emoji, text: s.text, minutes: s.minutes, secondsLeft: s.minutes * 60, status: 'todo' });
|
||||||
|
onCreated(id);
|
||||||
|
setTitle('');
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Generation failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [title, activeTask, category, difficulty, onCreated]);
|
||||||
|
|
||||||
|
const toggleStep = async (s: Step) => {
|
||||||
|
await db.steps.update(s.id, { status: s.status === 'done' ? 'todo' : 'done', secondsLeft: s.status === 'done' ? s.minutes * 60 : 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-2xl bg-slate-100 p-3 dark:bg-white/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="task-title" className="sr-only">Task title</label>
|
||||||
|
<input id="task-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={activeTask?.title ?? 'clean my room'}
|
||||||
|
className="flex-1 rounded-xl border border-black/10 bg-white px-3 py-2.5 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||||
|
<button onClick={generate} disabled={loading}
|
||||||
|
className="flex min-h-[44px] items-center gap-1 rounded-xl bg-teal-700 px-3 py-2.5 text-[13px] font-bold text-white disabled:opacity-50">
|
||||||
|
<Wand2 size={15} /> {loading ? '…' : 'Break down'}</button>
|
||||||
|
</div>
|
||||||
|
{!getKey() && <p className="mt-1.5 text-[11px] text-amber-600 dark:text-amber-300">⚙️ No API key — using offline demo breakdown. Add BYOK key in ⚙️ for real AI.</p>}
|
||||||
|
<fieldset className="mt-2 flex gap-1.5">
|
||||||
|
<legend className="sr-only">Difficulty</legend>
|
||||||
|
{(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
|
||||||
|
<button key={d} aria-pressed={difficulty === d} onClick={() => setDifficulty(d)}
|
||||||
|
className={`min-h-[44px] flex-1 rounded-xl px-2 py-2 text-[12px] font-semibold capitalize ${difficulty === d ? 'bg-slate-900 text-white dark:bg-teal-300 dark:text-teal-950' : 'bg-white text-slate-500 dark:bg-black/30 dark:text-slate-300'}`}>{d}</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="mt-2 flex gap-1.5">
|
||||||
|
<legend className="sr-only">Category</legend>
|
||||||
|
{CATS.map((c) => (
|
||||||
|
<button key={c} aria-pressed={category === c} onClick={() => setCategory(c)}
|
||||||
|
className={`min-h-[44px] flex-1 rounded-xl px-2 py-2 text-[12px] ${category === c ? 'bg-teal-100 font-bold text-teal-800 dark:bg-teal-600/20 dark:text-teal-100' : 'bg-white text-slate-500 dark:bg-black/30 dark:text-slate-300'}`}>{CAT_EMOJI[c]} {c}</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
{err && <p role="alert" className="mt-2 text-[12px] text-red-600 dark:text-red-300">{err}</p>}
|
||||||
|
{notice && <p aria-live="polite" className="mt-2 text-[12px] text-amber-700 dark:text-amber-300">{notice}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTask && (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="flex items-center justify-between bg-slate-50 px-3 py-2 dark:bg-black/20">
|
||||||
|
<p className="text-[13px] font-semibold">{activeTask.title} <span className="font-normal text-slate-400">· overall duration: {activeTask.totalMin} min</span></p>
|
||||||
|
<button onClick={onEdit} className="min-h-[44px] rounded-lg bg-teal-700 px-3 py-2 text-[12px] font-bold text-white dark:bg-teal-600/20 dark:text-teal-100">🤖 AI Edit</button>
|
||||||
|
</div>
|
||||||
|
<div className="nice-scroll max-h-[46dvh] divide-y divide-black/5 overflow-y-auto dark:divide-white/5">
|
||||||
|
{activeSteps.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
|
<button onClick={() => toggleStep(s)} aria-label={s.status === 'done' ? `Mark "${s.text}" not done` : `Mark "${s.text}" done`}
|
||||||
|
className={`grid size-11 shrink-0 place-items-center rounded-full border ${s.status === 'done' ? 'border-teal-600 bg-teal-600 text-white' : 'border-slate-300 text-transparent'}`}>
|
||||||
|
<Check size={15} /></button>
|
||||||
|
<span className="text-[15px]">{s.emoji}</span>
|
||||||
|
<span className={`flex-1 text-[13.5px] ${s.status === 'done' ? 'line-through opacity-50' : ''}`}>{s.text}</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-white/10 dark:text-slate-300">{s.minutes} m</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activeSteps.length === 0 && <p className="p-4 text-center text-[13px] text-slate-400">No steps yet — hit Break down.</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-3">
|
||||||
|
<button onClick={onFocus} disabled={activeSteps.length === 0}
|
||||||
|
className="min-h-[48px] flex-1 rounded-xl bg-teal-700 py-2.5 text-[14px] font-bold text-white disabled:opacity-40">▶ Start focus playlist</button>
|
||||||
|
<button onClick={async () => { if (activeTask) { downloadICS(activeTask, activeSteps); } }}
|
||||||
|
aria-label="Export to calendar (.ics)" className="grid min-h-[44px] min-w-[44px] place-items-center rounded-xl border border-black/10 text-slate-600 dark:border-white/10 dark:text-slate-300">
|
||||||
|
<CalendarPlus size={17} /></button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (!activeTask) return;
|
||||||
|
await db.steps.where('taskId').equals(activeTask.id).delete();
|
||||||
|
await db.tasks.delete(activeTask.id);
|
||||||
|
}} aria-label="Delete this task" className="grid min-h-[44px] min-w-[44px] place-items-center rounded-xl border border-black/10 text-slate-500 dark:border-white/10"><Trash2 size={17} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Focus ---------------- */
|
||||||
|
function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[]; voice: boolean; onClose: () => void }) {
|
||||||
|
const ordered = useMemo(() => [...steps].sort((a, b) => a.order - b.order), [steps]);
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [running, setRunning] = useState(true);
|
||||||
|
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||||
|
const cur = ordered[Math.min(idx, Math.max(0, ordered.length - 1))];
|
||||||
|
const announced = useRef('');
|
||||||
|
|
||||||
|
// Sync to first unfinished when task changes (fixes stale idx + resume-at-0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (syncedFor !== task.id && ordered.length > 0) {
|
||||||
|
const i = ordered.findIndex((s) => s.status !== 'done' && s.status !== 'skipped');
|
||||||
|
setIdx(i < 0 ? 0 : i);
|
||||||
|
setSyncedFor(task.id);
|
||||||
|
} else if (ordered.length && idx >= ordered.length) setIdx(ordered.length - 1);
|
||||||
|
}, [ordered, idx, syncedFor, task.id]);
|
||||||
|
|
||||||
|
// Esc to close + dialog semantics
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', h);
|
||||||
|
return () => window.removeEventListener('keydown', h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lock: { release?: () => void } | null = null;
|
||||||
|
(navigator as unknown as { wakeLock?: { request: (k: string) => Promise<{ release: () => void }> } }).wakeLock?.request('screen').then((l) => { lock = l; }).catch(() => {});
|
||||||
|
return () => { try { lock?.release?.(); } catch { /* noop */ } };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cur || !running) return;
|
||||||
|
if (announced.current !== cur.id) {
|
||||||
|
announced.current = cur.id;
|
||||||
|
speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`, voice && getVoiceOn());
|
||||||
|
if (cur.status === 'todo') db.steps.update(cur.id, { status: 'active' }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (cur.secondsLeft <= 0) {
|
||||||
|
beep();
|
||||||
|
db.steps.update(cur.id, { status: 'done' }).catch(() => {});
|
||||||
|
const next = ordered[idx + 1];
|
||||||
|
if (next) {
|
||||||
|
// Reset next if revisiting a finished step so it doesn't instantly skip
|
||||||
|
if (next.status === 'done' && next.secondsLeft <= 0)
|
||||||
|
db.steps.update(next.id, { status: 'todo', secondsLeft: next.minutes * 60 }).catch(() => {});
|
||||||
|
speak(`Done. Next: ${next.text}`, voice && getVoiceOn());
|
||||||
|
setIdx(idx + 1);
|
||||||
|
} else {
|
||||||
|
speak('All steps finished. Well done.', voice && getVoiceOn());
|
||||||
|
db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
|
||||||
|
setRunning(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
db.steps.update(cur.id, { secondsLeft: Math.max(0, cur.secondsLeft - 1) }).catch(() => {});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [cur, running, idx, ordered, task.id, voice, onClose]);
|
||||||
|
|
||||||
|
if (!cur) return null;
|
||||||
|
const total = cur.minutes * 60 || 1;
|
||||||
|
const frac = Math.max(0, Math.min(1, cur.secondsLeft / total));
|
||||||
|
const R = 84; const C = 2 * Math.PI * R;
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
const prev = Math.max(0, idx - 1);
|
||||||
|
const target = ordered[prev];
|
||||||
|
// Reset target so going back never lands on 0s (fixes instant auto-skip)
|
||||||
|
if (target && target.secondsLeft <= 0)
|
||||||
|
db.steps.update(target.id, { status: 'todo', secondsLeft: Math.max(60, target.minutes * 60) }).catch(() => {});
|
||||||
|
else if (target && target.status === 'done')
|
||||||
|
db.steps.update(target.id, { status: 'todo' }).catch(() => {});
|
||||||
|
setIdx(prev);
|
||||||
|
};
|
||||||
|
const completeStep = () => {
|
||||||
|
db.steps.update(cur.id, { status: 'done', secondsLeft: 0 }).catch(() => {});
|
||||||
|
if (idx + 1 < ordered.length) setIdx(idx + 1);
|
||||||
|
else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); }
|
||||||
|
};
|
||||||
|
const skipStep = () => {
|
||||||
|
db.steps.update(cur.id, { status: 'skipped', secondsLeft: 0 }).catch(() => {});
|
||||||
|
if (idx + 1 < ordered.length) setIdx(idx + 1);
|
||||||
|
else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div role="dialog" aria-modal="true" aria-label={`Focus: ${cur.text}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="focus-breathe w-full max-w-[380px] rounded-3xl bg-white p-5 text-center shadow-xl dark:bg-[#1b1f27]">
|
||||||
|
<div className="flex items-center justify-between text-[13px] text-slate-600 dark:text-slate-300">
|
||||||
|
<button onClick={onClose} aria-label="Back to list" className="flex min-h-[44px] min-w-[44px] items-center justify-center gap-1 rounded-xl"><ChevronLeft size={16} /> List</button>
|
||||||
|
<span aria-live="off">Step {idx + 1} of {ordered.length}</span>
|
||||||
|
<details className="text-[12px]"><summary className="flex min-h-[44px] min-w-[44px] cursor-pointer items-center gap-1" aria-label="Show projected end time"><Clock size={13} /> {projectedEnd(ordered).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</summary>
|
||||||
|
<span>Ends ~{projectedEnd(ordered).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} at current pace</span></details>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 text-[12px] font-medium text-slate-500 dark:text-slate-400">{task.title}</h2>
|
||||||
|
<div className="relative mx-auto mt-2 size-[220px]">
|
||||||
|
<svg viewBox="0 0 200 200" className="size-full -rotate-90" aria-hidden="true">
|
||||||
|
<circle cx="100" cy="100" r={R} fill="none" strokeWidth="14" className="stroke-slate-200 dark:stroke-white/10" />
|
||||||
|
<circle cx="100" cy="100" r={R} fill="none" stroke="#0f766e" strokeWidth="14" strokeLinecap="round"
|
||||||
|
className="timer-ring" strokeDasharray={C} strokeDashoffset={C * (1 - frac)} />
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<p className="text-3xl font-bold tabular-nums" role="timer" aria-label={`${fmt(cur.secondsLeft)} remaining`}>{fmt(cur.secondsLeft)}</p>
|
||||||
|
<p className="max-w-[180px] text-[14px] font-medium">{cur.emoji} {cur.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-6 mt-1 h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-white/10" role="progressbar" aria-label="Time remaining" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(frac * 100)}>
|
||||||
|
<div className="h-full rounded-full bg-teal-700" style={{ width: `${frac * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-3">
|
||||||
|
<button onClick={goBack} aria-label="Go to previous step" className="grid size-12 place-items-center rounded-full bg-slate-100 dark:bg-white/10"><ChevronLeft size={19} /></button>
|
||||||
|
<button onClick={() => setRunning(!running)} aria-label={running ? 'Pause timer' : 'Resume timer'} aria-pressed={running}
|
||||||
|
className="grid size-14 place-items-center rounded-full bg-teal-700 text-white">
|
||||||
|
{running ? <Pause size={22} /> : <Play size={22} />}</button>
|
||||||
|
<button onClick={completeStep} aria-label="Complete this step" className="grid size-12 place-items-center rounded-full bg-teal-700 text-white"><Check size={19} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-center gap-2">
|
||||||
|
<button onClick={() => db.steps.update(cur.id, { secondsLeft: cur.secondsLeft + 60, minutes: cur.minutes + 1 }).catch(() => {})}
|
||||||
|
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">+1 min</button>
|
||||||
|
<button onClick={skipStep} aria-label="Skip this step"
|
||||||
|
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">Skip →</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-slate-400">One step at a time. Future steps hidden to protect focus.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Brain dump ---------------- */
|
||||||
|
function DumpView({ onCreated }: { onCreated: (id: string) => void }) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const go = async () => {
|
||||||
|
if (!text.trim()) { setErr('Dump something first.'); return; }
|
||||||
|
setLoading(true); setErr('');
|
||||||
|
try {
|
||||||
|
const offlineSplit = () =>
|
||||||
|
text.split(/\n|;/).map((x) => x.trim()).filter(Boolean).slice(0, 8)
|
||||||
|
.map((x) => ({ emoji: '📌', text: x.slice(0, 60), minutes: 15 }));
|
||||||
|
let items: { emoji: string; text: string; minutes: number }[];
|
||||||
|
if (getKey()) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${getKey()}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.origin, 'X-Title': 'Mindlist FOSS' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: getModel(),
|
||||||
|
messages: [{ role: 'system', content: buildBrainDumpSystem(getLifeContext()) }, { role: 'user', content: text }],
|
||||||
|
response_format: { type: 'json_object' }, max_tokens: 1200, temperature: 0.5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`OpenRouter ${res.status}`);
|
||||||
|
const j = await res.json();
|
||||||
|
const raw: string = j?.choices?.[0]?.message?.content ?? '{}';
|
||||||
|
const cleaned = raw.replace(/```json|```/g, '');
|
||||||
|
const s = cleaned.indexOf('{'); const e = cleaned.lastIndexOf('}');
|
||||||
|
const obj = JSON.parse(cleaned.slice(s, e + 1));
|
||||||
|
const rawItems = Array.isArray(obj.tasks) ? obj.tasks : [];
|
||||||
|
items = rawItems
|
||||||
|
.filter((x: unknown) => x && typeof x === 'object')
|
||||||
|
.map((x: Record<string, unknown>) => ({
|
||||||
|
emoji: String(x['emoji'] ?? '📌').slice(0, 8),
|
||||||
|
text: String(x['text'] ?? '').slice(0, 80) || 'Untitled',
|
||||||
|
minutes: Math.max(1, Math.min(60, Number(x['minutes']) || 15)),
|
||||||
|
}))
|
||||||
|
.slice(0, 8);
|
||||||
|
if (!items.length) items = offlineSplit();
|
||||||
|
} catch {
|
||||||
|
items = offlineSplit();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items = offlineSplit();
|
||||||
|
}
|
||||||
|
if (!items.length) throw new Error('No tasks found.');
|
||||||
|
let first = '';
|
||||||
|
for (const it of items) {
|
||||||
|
const plan = heuristicPlan(it.text, 'medium');
|
||||||
|
const id = uid();
|
||||||
|
if (!first) first = id;
|
||||||
|
await db.tasks.add({ id, title: it.text, category: 'Personal', difficulty: 'medium', lifeContext: getLifeContext(), totalMin: plan.totalMin, status: 'planned', createdAt: Date.now() });
|
||||||
|
for (const [i, st] of plan.steps.entries())
|
||||||
|
await db.steps.add({ id: uid(), taskId: id, order: i, emoji: st.emoji, text: st.text, minutes: st.minutes, secondsLeft: st.minutes * 60, status: 'todo' });
|
||||||
|
}
|
||||||
|
setText('');
|
||||||
|
onCreated(first);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Import failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-2xl bg-teal-50 p-4 dark:bg-white/5">
|
||||||
|
<p className="flex items-center gap-1.5 text-[14px] font-bold"><Brain size={16} /> AI Import (Brain Dump)</p>
|
||||||
|
<p className="text-[12px] text-slate-500">Paste chaos. We extract tasks + auto-break each down.</p>
|
||||||
|
<label htmlFor="dump-text" className="sr-only">Brain dump text</label>
|
||||||
|
<textarea id="dump-text" value={text} onChange={(e) => setText(e.target.value)} rows={6}
|
||||||
|
placeholder={'dentist tomorrow, kitchen is chaos, Q3 slides, buy milk…'}
|
||||||
|
className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||||
|
{err && <p className="mt-1 text-[12px] text-red-500">{err}</p>}
|
||||||
|
<button onClick={go} disabled={loading}
|
||||||
|
className="mt-2 min-h-[48px] w-full rounded-xl bg-teal-700 py-2.5 text-[14px] font-bold text-white disabled:opacity-50">
|
||||||
|
{loading ? 'Importing…' : '✨ Import + break down'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Library ---------------- */
|
||||||
|
function LibraryView({ tasks, onOpen, onReuse }: { tasks: Task[]; onOpen: (t: Task) => void; onReuse: (t: Task) => void }) {
|
||||||
|
if (!tasks.length) return <p className="rounded-2xl bg-slate-50 p-6 text-center text-[13px] text-slate-500 dark:bg-white/5">Library empty. Breakdowns you create are saved here for one-tap reuse. 📚</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center gap-2 rounded-2xl border border-black/5 bg-white p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<button onClick={() => onOpen(t)} className="flex-1 text-left">
|
||||||
|
<span className="block text-[14px] font-medium">{CAT_EMOJI[t.category]} {t.title}</span>
|
||||||
|
<span className="text-[12px] text-slate-400">{t.difficulty} · {t.totalMin} min · {new Date(t.createdAt).toLocaleDateString()}</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onReuse(t)} aria-label={`Reuse ${t.title}`} title="Reuse" className="grid min-h-[44px] min-w-[44px] place-items-center rounded-xl bg-teal-50 text-teal-800 dark:bg-white/10 dark:text-teal-200"><Copy size={15} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- AI Edit ---------------- */
|
||||||
|
function AiEditSheet({ task, steps, onClose }: { task: Task; steps: Step[]; onClose: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', h);
|
||||||
|
return () => window.removeEventListener('keydown', h);
|
||||||
|
}, [onClose]);
|
||||||
|
const [instr, setInstr] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const apply = async () => {
|
||||||
|
if (!instr.trim()) { setErr('Say what to change — e.g. “no vacuum, make steps shorter”.'); return; }
|
||||||
|
setLoading(true); setErr('');
|
||||||
|
const offlineExpand = (): GenPlan => {
|
||||||
|
const longest = [...steps].sort((a, b) => b.minutes - a.minutes)[0];
|
||||||
|
const out = steps.flatMap((s) => (longest && s.id === longest.id
|
||||||
|
? [{ emoji: s.emoji, text: `${s.text} — part 1`, minutes: Math.max(1, Math.ceil(s.minutes / 2)) },
|
||||||
|
{ emoji: '🔍', text: `${s.text} — part 2`, minutes: Math.max(1, Math.floor(s.minutes / 2)) }]
|
||||||
|
: [{ emoji: s.emoji, text: s.text, minutes: s.minutes }]));
|
||||||
|
return { steps: out, totalMin: out.reduce((a, s) => a + s.minutes, 0) };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const prior = { steps: steps.map((s) => ({ emoji: s.emoji, text: s.text, minutes: s.minutes })), totalMin: task.totalMin };
|
||||||
|
let plan: GenPlan;
|
||||||
|
if (getKey()) {
|
||||||
|
try {
|
||||||
|
const { plan: p, raw } = await callOpenRouter({
|
||||||
|
system: buildAiEditSystem(task.difficulty, task.lifeContext || getLifeContext()),
|
||||||
|
user: `PRIOR:\n${JSON.stringify(prior)}\n\nCHANGE REQUEST:\n${instr}`,
|
||||||
|
});
|
||||||
|
if (!p) throw new Error(`Invalid JSON. ${raw.slice(0, 150)}`);
|
||||||
|
plan = p;
|
||||||
|
} catch {
|
||||||
|
plan = offlineExpand();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plan = offlineExpand();
|
||||||
|
}
|
||||||
|
await db.steps.where('taskId').equals(task.id).delete();
|
||||||
|
for (const [i, s] of plan.steps.entries())
|
||||||
|
await db.steps.add({ id: uid(), taskId: task.id, order: i, emoji: s.emoji, text: s.text, minutes: s.minutes, secondsLeft: s.minutes * 60, status: 'todo' });
|
||||||
|
await db.tasks.update(task.id, { totalMin: plan.totalMin, status: 'planned' });
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Edit failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div role="dialog" aria-modal="true" aria-label={`AI edit ${task.title}`} onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-full max-w-[480px] rounded-t-3xl bg-white p-4 pb-[max(1rem,env(safe-area-inset-bottom))] dark:bg-[#1b1f27]">
|
||||||
|
<p className="text-[14px] font-bold">🤖 AI Edit — {task.title}</p>
|
||||||
|
<p className="text-[12px] text-slate-600 dark:text-slate-300">overall duration: {task.totalMin} min · try “make it more detailed”, “no oven”, “shorter phases”</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<label className="sr-only" htmlFor="aiedit-input">Describe the change</label>
|
||||||
|
<input id="aiedit-input" value={instr} onChange={(e) => setInstr(e.target.value)} placeholder="Make the last step more detailed…"
|
||||||
|
className="min-h-[44px] flex-1 rounded-xl border border-black/10 bg-slate-50 px-3 py-2.5 text-[16px] outline-none focus:border-teal-600 dark:border-white/10 dark:bg-black/30" />
|
||||||
|
<button onClick={apply} disabled={loading} className="min-h-[44px] rounded-xl bg-teal-700 px-5 text-[13px] font-bold text-white disabled:opacity-50">Send</button>
|
||||||
|
</div>
|
||||||
|
{err && <p className="mt-1 text-[12px] text-red-600 dark:text-red-300">{err}</p>}
|
||||||
|
<button onClick={onClose} className="mt-2 min-h-[44px] w-full rounded-xl bg-slate-100 py-2 text-[13px] font-medium dark:bg-white/10">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Settings ---------------- */
|
||||||
|
function SettingsSheet({ onClose }: { onClose: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', h);
|
||||||
|
return () => window.removeEventListener('keydown', h);
|
||||||
|
}, [onClose]);
|
||||||
|
const [key, setKeyState] = useState(getKey());
|
||||||
|
const [model, setModelState] = useState(getModel());
|
||||||
|
const [ctx, setCtx] = useState(getLifeContext());
|
||||||
|
const saved = useRef(false);
|
||||||
|
const save = () => {
|
||||||
|
setKey(key); setModel(model || 'deepseek/deepseek-v4.1-flash'); setLifeContext(ctx);
|
||||||
|
saved.current = true;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Settings" onClick={(e) => e.stopPropagation()}
|
||||||
|
className="nice-scroll max-h-[88dvh] w-full max-w-[480px] overflow-y-auto rounded-t-3xl bg-white p-4 pb-[max(1rem,env(safe-area-inset-bottom))] dark:bg-[#1b1f27]">
|
||||||
|
<p className="text-[15px] font-bold">⚙️ Settings — BYOK + Life Context</p>
|
||||||
|
<label htmlFor="set-key" className="mt-3 block text-[12px] font-semibold text-slate-600 dark:text-slate-300">OPENROUTER API KEY (stored on-device only)</label>
|
||||||
|
<input id="set-key" value={key} onChange={(e) => setKeyState(e.target.value)} type="password" placeholder="sk-or-v1-…"
|
||||||
|
className="mt-1 w-full rounded-xl border border-black/10 bg-slate-50 px-3 py-2.5 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||||
|
<label htmlFor="set-model" className="mt-3 block text-[12px] font-semibold text-slate-600 dark:text-slate-300">MODEL</label>
|
||||||
|
<input id="set-model" value={model} onChange={(e) => setModelState(e.target.value)} placeholder="deepseek/deepseek-v4.1-flash"
|
||||||
|
className="mt-1 w-full rounded-xl border border-black/10 bg-slate-50 px-3 py-2.5 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||||
|
<p className="mt-1 text-[11px] text-slate-400">Default: <code>deepseek/deepseek-v4.1-flash</code> · 1M ctx · structured outputs. Change to any OpenRouter slug.</p>
|
||||||
|
<label htmlFor="set-ctx" className="mt-3 block text-[12px] font-semibold text-slate-600 dark:text-slate-300">LIFE CONTEXT (shapes every breakdown)</label>
|
||||||
|
<textarea id="set-ctx" value={ctx} onChange={(e) => setCtx(e.target.value)} rows={4}
|
||||||
|
placeholder="e.g. small studio, no dishwasher, low energy evenings, easily distracted by phone…"
|
||||||
|
className="mt-1 w-full rounded-xl border border-black/10 bg-slate-50 p-3 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button onClick={save} className="min-h-[48px] flex-1 rounded-xl bg-teal-700 py-2.5 text-[14px] font-bold text-white">Save context</button>
|
||||||
|
<button onClick={onClose} className="min-h-[48px] flex-1 rounded-xl bg-slate-100 py-2.5 text-[14px] font-medium dark:bg-white/10">Close</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={async () => { if (confirm('Delete all local tasks?')) { await db.tasks.clear(); await db.steps.clear(); onClose(); } }}
|
||||||
|
className="mt-2 min-h-[44px] w-full rounded-xl border border-red-200 py-2 text-[13px] font-medium text-red-600 dark:text-red-300">Delete all local data</button>
|
||||||
|
<p className="mt-2 text-center text-[11px] text-slate-400">FOSS · MIT · keys never leave your device except to api.openrouter.ai · <Home size={0} /></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { heuristicPlan } from './lib/fallback';
|
||||||
|
import { parsePlanExported } from './lib/openrouter';
|
||||||
|
import { buildSystemPrompt } from './lib/prompts';
|
||||||
|
|
||||||
|
describe('heuristicPlan', () => {
|
||||||
|
it('kitchen hard sums correctly', () => {
|
||||||
|
const p = heuristicPlan('The kitchen has descended into chaos', 'hard');
|
||||||
|
expect(p.steps.length).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(p.totalMin).toBe(p.steps.reduce((a, s) => a + s.minutes, 0));
|
||||||
|
});
|
||||||
|
it('generic easy is short', () => {
|
||||||
|
const p = heuristicPlan('do something vague', 'easy');
|
||||||
|
expect(p.steps.length).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prompts', () => {
|
||||||
|
it('embeds difficulty + context', () => {
|
||||||
|
const s = buildSystemPrompt('hard', 'small studio, low energy');
|
||||||
|
expect(s).toMatch(/hyper-granular/i);
|
||||||
|
expect(s).toMatch(/small studio/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsePlan', () => {
|
||||||
|
it('parses fenced json', () => {
|
||||||
|
const raw = '```json\n{"steps":[{"emoji":"🧽","text":"Wipe counters","minutes":4}],"totalMin":4}\n```';
|
||||||
|
const p = parsePlanExported(raw);
|
||||||
|
expect(p?.steps[0].text).toMatch(/Wipe/);
|
||||||
|
expect(p?.totalMin).toBe(4);
|
||||||
|
});
|
||||||
|
it('rejects garbage', () => {
|
||||||
|
expect(parsePlanExported('hello world')).toBeNull();
|
||||||
|
});
|
||||||
|
it('reconciles mismatched totalMin to sum of steps', () => {
|
||||||
|
const raw = '{"steps":[{"emoji":"a","text":"one","minutes":10},{"emoji":"b","text":"two","minutes":30}],"totalMin":5}';
|
||||||
|
const p = parsePlanExported(raw);
|
||||||
|
expect(p?.totalMin).toBe(40);
|
||||||
|
});
|
||||||
|
it('clamps minutes to 1..60 and caps 20 steps', () => {
|
||||||
|
const steps = Array.from({ length: 25 }, (_, i) => ({ emoji: 'x', text: `s${i}`, minutes: 999 }));
|
||||||
|
const p = parsePlanExported(JSON.stringify({ steps, totalMin: 1 }));
|
||||||
|
expect(p?.steps.length).toBe(20);
|
||||||
|
expect(p?.steps[0].minutes).toBe(60);
|
||||||
|
expect(p?.totalMin).toBe(20 * 60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--calm: #14b8a6;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* calm focus card animation */
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.015); }
|
||||||
|
}
|
||||||
|
.focus-breathe { animation: none; }
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.focus-breathe { animation: breathe 6s ease-in-out infinite; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-ring { transition: stroke-dashoffset 0.5s linear; }
|
||||||
|
|
||||||
|
/* thin scrollbars for step lists */
|
||||||
|
.nice-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.nice-scroll::-webkit-scrollbar-thumb { background: rgba(20,184,166,.4); border-radius: 999px; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid #0f766e; outline-offset: 2px; }
|
||||||
|
input:focus, textarea:focus { outline: 2px solid #0f766e; outline-offset: 1px; }
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { GenPlan } from './openrouter';
|
||||||
|
|
||||||
|
// Offline heuristic fallback when no BYOK key: deterministic micro-breakdown
|
||||||
|
// so timer/library/focus can be demoed without API.
|
||||||
|
export function heuristicPlan(title: string, difficulty: 'easy' | 'medium' | 'hard'): GenPlan {
|
||||||
|
const t = title.toLowerCase();
|
||||||
|
const isKitchen = /kitchen|clean|room| dishes|laundry|apartment/.test(t);
|
||||||
|
const isFood = /cook|lunch|meal|egg|eat|dinner|breakfast/.test(t);
|
||||||
|
const isWork = /report|review|q3|marketing|write|deck|email|analy/.test(t);
|
||||||
|
|
||||||
|
if (isKitchen) {
|
||||||
|
const steps = [
|
||||||
|
{ emoji: '🎧', text: 'Put on music, gloves, grab trash bag', minutes: 2 },
|
||||||
|
{ emoji: '🗑️', text: 'Tie trash, take to door, reliner bin', minutes: 3 },
|
||||||
|
{ emoji: '👀', text: 'Perimeter walk: toss visible wrappers', minutes: 4 },
|
||||||
|
{ emoji: '☕', text: 'Group mugs and glasses by sink', minutes: 3 },
|
||||||
|
{ emoji: '🍽️', text: 'Stack plates and cutlery neatly', minutes: 3 },
|
||||||
|
{ emoji: '🧽', text: 'Scrape food, soak worst items', minutes: 4 },
|
||||||
|
{ emoji: '🫧', text: 'Wash glasses + cutlery to mat', minutes: 6 },
|
||||||
|
{ emoji: '🍳', text: 'Wash plates + big bowls to rack', minutes: 5 },
|
||||||
|
{ emoji: '✨', text: 'Spray + wipe counters and table', minutes: 4 },
|
||||||
|
{ emoji: '💧', text: 'Gloves off, water, tap complete', minutes: 3 },
|
||||||
|
];
|
||||||
|
const sliced = difficulty === 'easy' ? [steps[2], steps[6], steps[8], steps[9]] : difficulty === 'medium' ? steps.slice(1, 8) : steps;
|
||||||
|
return { steps: sliced, totalMin: sliced.reduce((a, s) => a + s.minutes, 0) };
|
||||||
|
}
|
||||||
|
if (isWork) {
|
||||||
|
const steps = [
|
||||||
|
{ emoji: '📄', text: 'Open template, save file immediately', minutes: 3 },
|
||||||
|
{ emoji: '📊', text: 'Export raw metrics to working folder', minutes: 10 },
|
||||||
|
{ emoji: '🎯', text: 'Extract top 3 metrics to notes', minutes: 12 },
|
||||||
|
{ emoji: '🏆', text: 'Draft 3 bullets: quarterly wins', minutes: 15 },
|
||||||
|
{ emoji: '🔧', text: 'Draft 2 bullets: fixes + next steps', minutes: 15 },
|
||||||
|
{ emoji: '📈', text: 'Add clean charts to slides 2-3', minutes: 10 },
|
||||||
|
{ emoji: '📤', text: 'Proof once, export PDF, draft email', minutes: 10 },
|
||||||
|
];
|
||||||
|
const sliced = difficulty === 'hard' ? steps.flatMap((s) => s.minutes > 10 ? [
|
||||||
|
{ ...s, text: s.text + ' — part 1 (gather)', minutes: Math.ceil(s.minutes / 2) },
|
||||||
|
{ ...s, text: s.text + ' — part 2 (finish)', minutes: Math.floor(s.minutes / 2) },
|
||||||
|
] : [s]) : difficulty === 'easy' ? [steps[0], { emoji: '🧱', text: 'Draft all bullets in one pass', minutes: 20 }, steps[5], steps[6]] : steps;
|
||||||
|
return { steps: sliced.slice(0, 12), totalMin: sliced.slice(0, 12).reduce((a, s) => a + s.minutes, 0) };
|
||||||
|
}
|
||||||
|
if (isFood) {
|
||||||
|
const steps = [
|
||||||
|
{ emoji: '🧹', text: 'Clear counter, set board + knife', minutes: 2 },
|
||||||
|
{ emoji: '🥚', text: 'Get eggs, butter, bread, tomatoes', minutes: 2 },
|
||||||
|
{ emoji: '🍳', text: 'Crack + whisk eggs with salt', minutes: 2 },
|
||||||
|
{ emoji: '🍅', text: 'Halve tomatoes, set aside', minutes: 2 },
|
||||||
|
{ emoji: '🧈', text: 'Pan on medium-low + butter', minutes: 2 },
|
||||||
|
{ emoji: '🍞', text: 'Start toast now', minutes: 1 },
|
||||||
|
{ emoji: '🔥', text: 'Pour eggs, fold gently till set', minutes: 4 },
|
||||||
|
{ emoji: '🍽️', text: 'Plate eggs + toast + tomatoes', minutes: 2 },
|
||||||
|
{ emoji: '🚰', text: 'Burner off, pan to soak', minutes: 2 },
|
||||||
|
{ emoji: '😌', text: 'Sit, water, eat calmly', minutes: 1 },
|
||||||
|
];
|
||||||
|
const sliced = difficulty === 'easy' ? [steps[1], { emoji: '👨🍳', text: 'Cook everything in one go', minutes: 10 }, steps[7], steps[9]] : steps;
|
||||||
|
return { steps: sliced, totalMin: sliced.reduce((a, s) => a + s.minutes, 0) };
|
||||||
|
}
|
||||||
|
// generic
|
||||||
|
const base = [
|
||||||
|
{ emoji: '🎯', text: `Set up space for: ${title.slice(0, 40)}`, minutes: 2 },
|
||||||
|
{ emoji: '🧠', text: 'Dump all sub-parts on paper quickly', minutes: 3 },
|
||||||
|
{ emoji: '✂️', text: 'Do smallest visible piece first', minutes: 5 },
|
||||||
|
{ emoji: '🧱', text: 'Do main middle chunk, no polish', minutes: 10 },
|
||||||
|
{ emoji: '🧹', text: 'Tidy + finish last 10%', minutes: 5 },
|
||||||
|
{ emoji: '💧', text: 'Water, breathe, mark complete', minutes: 2 },
|
||||||
|
];
|
||||||
|
const steps = difficulty === 'easy' ? [base[0], base[3], base[5]] : difficulty === 'medium' ? base.slice(0, 5).concat(base[5]) : [
|
||||||
|
{ emoji: '🎧', text: 'Starter: music + clear 1 surface', minutes: 2 },
|
||||||
|
...base.slice(1, 5).flatMap((s) => [{ ...s, minutes: Math.max(2, Math.ceil(s.minutes / 2)) }]),
|
||||||
|
base[5],
|
||||||
|
];
|
||||||
|
return { steps, totalMin: steps.reduce((a, s) => a + s.minutes, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speak(text: string, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
if (!enabled || !('speechSynthesis' in window)) return;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(text);
|
||||||
|
u.rate = 1.02;
|
||||||
|
window.speechSynthesis.speak(u);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beep() {
|
||||||
|
try {
|
||||||
|
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
const ctx = new Ctx();
|
||||||
|
const o = ctx.createOscillator();
|
||||||
|
const g = ctx.createGain();
|
||||||
|
o.connect(g); g.connect(ctx.destination);
|
||||||
|
o.frequency.value = 880; g.gain.value = 0.08;
|
||||||
|
o.start(); o.stop(ctx.currentTime + 0.25);
|
||||||
|
setTimeout(() => ctx.close(), 400);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash';
|
||||||
|
|
||||||
|
export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
|
||||||
|
export interface GenStep {
|
||||||
|
emoji: string;
|
||||||
|
text: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenPlan {
|
||||||
|
steps: GenStep[];
|
||||||
|
totalMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKey(): string {
|
||||||
|
return localStorage.getItem('mindlist.or.key') ?? '';
|
||||||
|
}
|
||||||
|
export function getModel(): string {
|
||||||
|
return localStorage.getItem('mindlist.or.model') || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
export function setKey(k: string) {
|
||||||
|
if (k) localStorage.setItem('mindlist.or.key', k.trim());
|
||||||
|
else localStorage.removeItem('mindlist.or.key');
|
||||||
|
}
|
||||||
|
export function setModel(m: string) {
|
||||||
|
localStorage.setItem('mindlist.or.model', m.trim() || DEFAULT_MODEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParsePlan(raw: string): GenPlan | null {
|
||||||
|
try {
|
||||||
|
// strip code fences
|
||||||
|
const cleaned = raw.replace(/```json|```/g, '').trim();
|
||||||
|
const start = cleaned.indexOf('{');
|
||||||
|
const end = cleaned.lastIndexOf('}');
|
||||||
|
if (start < 0 || end < 0) return null;
|
||||||
|
const obj = JSON.parse(cleaned.slice(start, end + 1));
|
||||||
|
if (!Array.isArray(obj.steps)) return null;
|
||||||
|
const steps: GenStep[] = obj.steps
|
||||||
|
.filter((s: unknown) => s && typeof s === 'object')
|
||||||
|
.map((s: Record<string, unknown>) => ({
|
||||||
|
emoji: String(s['emoji'] ?? '✅').slice(0, 8),
|
||||||
|
text: String(s['text'] ?? s['title'] ?? 'Step').slice(0, 160),
|
||||||
|
minutes: Math.max(1, Math.min(60, Math.round(Number(s['minutes'] ?? s['duration'] ?? 3)) || 3)),
|
||||||
|
}))
|
||||||
|
.slice(0, 20);
|
||||||
|
if (steps.length === 0) return null;
|
||||||
|
const sum = steps.reduce((a, s) => a + s.minutes, 0);
|
||||||
|
// Reconcile: trust the sum of steps (fixes AI header/sum mismatch)
|
||||||
|
const totalMin = sum;
|
||||||
|
return { steps, totalMin };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callOpenRouter(opts: {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<{ plan: GenPlan | null; raw: string }> {
|
||||||
|
const key = getKey();
|
||||||
|
if (!key) throw new Error('Missing OpenRouter API key. Add it in Settings (BYOK).');
|
||||||
|
const model = getModel();
|
||||||
|
const res = await fetch(OPENROUTER_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: opts.signal,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': window.location.origin,
|
||||||
|
'X-Title': 'Mindlist FOSS',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.system },
|
||||||
|
{ role: 'user', content: opts.user },
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
max_tokens: 2000,
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '');
|
||||||
|
throw new Error(`OpenRouter ${res.status}: ${t.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
const content: string = json?.choices?.[0]?.message?.content ?? '';
|
||||||
|
return { plan: safeParsePlan(content), raw: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlanExported(raw: string): GenPlan | null {
|
||||||
|
return safeParsePlan(raw);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
|
||||||
|
export const DIFFICULTY_HELP: Record<Difficulty, string> = {
|
||||||
|
easy: 'High-level phases. For when you know how, just need milestones.',
|
||||||
|
medium: 'Standard procedural workflow with clear boundaries.',
|
||||||
|
hard: 'Hyper-granular physical micro-moves. Zero planning needed per step.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSystemPrompt(difficulty: Difficulty, lifeContext: string): string {
|
||||||
|
const depth =
|
||||||
|
difficulty === 'easy'
|
||||||
|
? 'Generate 3-5 high-level phases.'
|
||||||
|
: difficulty === 'medium'
|
||||||
|
? 'Generate 5-8 concrete procedural steps with tight boundaries to avoid rabbit holes.'
|
||||||
|
: 'Generate 8-12 hyper-granular PHYSICAL micro-actions. Each step must be one unambiguous bodily movement with no implicit knowledge. Start with a 1-2 min sensory starter (music, gloves, clear surface). End with a reset step (water, breathe, complete).';
|
||||||
|
return `You are Mindlist, a cognitive prosthetic for ADHD executive dysfunction.
|
||||||
|
Break the user's daunting task into an executable linear playlist.
|
||||||
|
RULES:
|
||||||
|
- ${depth}
|
||||||
|
- Every step: {"emoji": single emoji, "text": imperative micro-action <=14 words, "minutes": 1-15 integer}
|
||||||
|
- Total time realistic. Sum of minutes = totalMin.
|
||||||
|
- Calm, shame-free tone. No jargon.
|
||||||
|
- Respect Life Context constraints strictly (space, tools, energy, distractions).
|
||||||
|
- Output ONLY valid JSON: {"steps":[...], "totalMin": number}
|
||||||
|
Life Context: ${lifeContext?.trim() ? lifeContext.slice(0, 800) : '(none provided)'}
|
||||||
|
Difficulty: ${difficulty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrainDumpSystem(lifeContext: string): string {
|
||||||
|
return `You are Mindlist AI Import. Extract distinct actionable tasks from chaotic brain-dump text.
|
||||||
|
Rules: split into 1-8 tasks, each {"emoji": emoji, "text": clear task title <=10 words, "minutes": rough guess}.
|
||||||
|
Respect Life Context: ${lifeContext?.slice(0, 500) || '(none)'}
|
||||||
|
Output ONLY JSON: {"tasks": [...]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAiEditSystem(difficulty: Difficulty, lifeContext: string): string {
|
||||||
|
return `You are Mindlist AI Edit. User gives a prior plan JSON + natural-language change request (missing tool, shorter phases, expand a hard step, less energy, etc).
|
||||||
|
Return a FULL updated plan JSON only: {"steps":[{"emoji","text","minutes"}], "totalMin": number}.
|
||||||
|
Keep difficulty=${difficulty} granularity unless asked otherwise. Respect Life Context: ${lifeContext?.slice(0, 600) || '(none)'}.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Dexie, { type Table } from 'dexie';
|
||||||
|
import type { Difficulty } from './prompts';
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
lifeContext: string;
|
||||||
|
totalMin: number;
|
||||||
|
status: 'planned' | 'active' | 'done';
|
||||||
|
createdAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
order: number;
|
||||||
|
emoji: string;
|
||||||
|
text: string;
|
||||||
|
minutes: number;
|
||||||
|
secondsLeft: number;
|
||||||
|
status: 'todo' | 'active' | 'done' | 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
class DB extends Dexie {
|
||||||
|
tasks!: Table<Task, string>;
|
||||||
|
steps!: Table<Step, string>;
|
||||||
|
constructor() {
|
||||||
|
super('mindlist');
|
||||||
|
this.version(1).stores({ tasks: 'id, status, createdAt', steps: 'id, taskId, order' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const db = new DB();
|
||||||
|
|
||||||
|
export const uid = () =>
|
||||||
|
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
export function getLifeContext(): string {
|
||||||
|
return localStorage.getItem('mindlist.context') ?? '';
|
||||||
|
}
|
||||||
|
export function setLifeContext(v: string) {
|
||||||
|
localStorage.setItem('mindlist.context', v);
|
||||||
|
}
|
||||||
|
export function getVoiceOn(): boolean {
|
||||||
|
return (localStorage.getItem('mindlist.voice') ?? 'on') === 'on';
|
||||||
|
}
|
||||||
|
export function setVoiceOn(v: boolean) {
|
||||||
|
localStorage.setItem('mindlist.voice', v ? 'on' : 'off');
|
||||||
|
}
|
||||||
|
export function getDark(): boolean {
|
||||||
|
if (typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||||
|
return true;
|
||||||
|
return localStorage.getItem('mindlist.dark') === 'on';
|
||||||
|
}
|
||||||
|
export function setDark(v: boolean) {
|
||||||
|
localStorage.setItem('mindlist.dark', v ? 'on' : 'off');
|
||||||
|
document.documentElement.classList.toggle('dark', v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function icsEscape(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadICS(task: Task, steps: Step[]) {
|
||||||
|
const dt = new Date();
|
||||||
|
const fmt = (d: Date) =>
|
||||||
|
d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
let cursor = new Date(dt.getTime() + 5 * 60000);
|
||||||
|
const live = steps.filter((s) => s.status !== 'skipped');
|
||||||
|
const events = live
|
||||||
|
.map((s) => {
|
||||||
|
const mins = Math.max(1, Math.ceil(s.secondsLeft / 60) || s.minutes);
|
||||||
|
const start = new Date(cursor);
|
||||||
|
const end = new Date(cursor.getTime() + mins * 60000);
|
||||||
|
cursor = end;
|
||||||
|
const summary = icsEscape(`${s.emoji} ${s.text.replace(/\n/g, ' ')}`);
|
||||||
|
return `BEGIN:VEVENT\nUID:${s.id}@mindlist\nDTSTAMP:${fmt(dt)}\nDTSTART:${fmt(start)}\nDTEND:${fmt(end)}\nSUMMARY:${summary}\nDESCRIPTION:${icsEscape(`Part of Mindlist task: ${task.title}`)}\nBEGIN:VALARM\nTRIGGER:-PT2M\nACTION:DISPLAY\nDESCRIPTION:${summary}\nEND:VALARM\nEND:VEVENT`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
const ics = `BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Mindlist//FOSS//EN\n${events}\nEND:VCALENDAR`;
|
||||||
|
const blob = new Blob([ics], { type: 'text/calendar' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `mindlist-${task.title.slice(0, 30).replace(/\s+/g, '-')}.ics`;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Mindlist — Free ADHD Task Breaker (BYOK)',
|
||||||
|
short_name: 'Mindlist',
|
||||||
|
description: 'Free open-source Neurolist alternative. AI breaks tasks into micro-steps with focus timer. BYOK OpenRouter.',
|
||||||
|
theme_color: '#0f766e',
|
||||||
|
background_color: '#15181e',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
start_url: '.',
|
||||||
|
icons: [
|
||||||
|
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/openrouter\.ai\/.*/i,
|
||||||
|
handler: 'NetworkOnly',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||