From 4b87b3546703af76e42d8c447cdf0fd53eafbcf8 Mon Sep 17 00:00:00 2001 From: yaclty Date: Sun, 3 Oct 2021 09:58:17 +0800 Subject: [PATCH] first commit --- .editorconfig | 15 + .eslintrc | 22 + .gitignore | 289 ++ .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/jsLibraryMappings.xml | 6 + .idea/jsLinters/eslint.xml | 6 + .idea/modules.xml | 8 + .idea/timestamp.iml | 10 + .idea/vcs.xml | 6 + CHANGELOG.md | 15 + LICENSE | 21 + README.md | 45 + app/assets/backgrounds/empty.svg | 1 + app/assets/backgrounds/gradient.svg | 55 + app/assets/icons/bell.svg | 1 + app/assets/icons/calendar.svg | 1 + app/assets/icons/command.svg | 1 + app/assets/icons/settings.svg | 1 + app/assets/logo.svg | 43 + app/components/Calendar.js | 154 + app/components/Clock.js | 39 + app/components/Locale.js | 40 + app/components/Logger.js | 63 + app/components/Preferences.js | 151 + app/components/SystemTray.js | 55 + app/components/Updater.js | 83 + app/components/Window.js | 120 + app/index.js | 156 + app/ipc.js | 28 + app/locales/de.js | 50 + app/locales/en.js | 50 + app/locales/index.js | 7 + app/paths.js | 11 + app/styles/components/button-primary.css | 13 + app/styles/components/container-alert.css | 18 + app/styles/components/form-group.css | 43 + app/styles/components/is-native.css | 21 + app/styles/components/list-shortcuts.css | 23 + app/styles/meta/colors.css | 27 + app/styles/meta/fx.css | 4 + app/styles/meta/grid.css | 3 + app/styles/meta/typography.css | 9 + app/styles/shared/base.css | 16 + app/styles/shared/typography.css | 6 + app/styles/styles.css | 7 + .../calendar-background.css | 22 + .../calendar-background.js | 54 + .../calendar-legend/calendar-legend.css | 28 + .../calendar-legend/calendar-legend.js | 27 + .../calendar-month/calendar-month.css | 72 + .../calendar/calendar-month/calendar-month.js | 49 + .../calendar-navigation.css | 55 + .../calendar-navigation.js | 127 + .../calendar-show-preferences.css | 48 + .../calendar-show-preferences.js | 26 + .../calendar-today/calendar-today.css | 12 + .../calendar/calendar-today/calendar-today.js | 38 + .../calendar/calendar-view/calendar-view.css | 47 + .../calendar/calendar-view/calendar-view.js | 118 + app/views/calendar/calendar.html | 12 + app/views/calendar/ipc.js | 27 + app/views/calendar/preload.js | 9 + app/views/calendar/renderer.js | 15 + .../common/translation-key/translation-key.js | 24 + app/views/preferences/ipc.js | 32 + .../preferences-view/preferences-view.css | 126 + .../preferences-view/preferences-view.js | 196 + app/views/preferences/preferences.html | 12 + app/views/preferences/preload.js | 7 + app/views/preferences/renderer.js | 5 + build/_icon.icns | Bin 0 -> 285658 bytes build/entitlements.mac.plist | 10 + build/icon.icns | Bin 0 -> 32965 bytes package-lock.json | 4155 +++++++++++++++++ package.json | 65 + scripts/notarize.js | 28 + yarn.lock | 2820 +++++++++++ 77 files changed, 10015 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jsLibraryMappings.xml create mode 100644 .idea/jsLinters/eslint.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/timestamp.iml create mode 100644 .idea/vcs.xml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/assets/backgrounds/empty.svg create mode 100644 app/assets/backgrounds/gradient.svg create mode 100644 app/assets/icons/bell.svg create mode 100644 app/assets/icons/calendar.svg create mode 100644 app/assets/icons/command.svg create mode 100644 app/assets/icons/settings.svg create mode 100644 app/assets/logo.svg create mode 100644 app/components/Calendar.js create mode 100644 app/components/Clock.js create mode 100644 app/components/Locale.js create mode 100644 app/components/Logger.js create mode 100644 app/components/Preferences.js create mode 100644 app/components/SystemTray.js create mode 100644 app/components/Updater.js create mode 100644 app/components/Window.js create mode 100644 app/index.js create mode 100644 app/ipc.js create mode 100644 app/locales/de.js create mode 100644 app/locales/en.js create mode 100644 app/locales/index.js create mode 100644 app/paths.js create mode 100644 app/styles/components/button-primary.css create mode 100644 app/styles/components/container-alert.css create mode 100644 app/styles/components/form-group.css create mode 100644 app/styles/components/is-native.css create mode 100644 app/styles/components/list-shortcuts.css create mode 100644 app/styles/meta/colors.css create mode 100644 app/styles/meta/fx.css create mode 100644 app/styles/meta/grid.css create mode 100644 app/styles/meta/typography.css create mode 100644 app/styles/shared/base.css create mode 100644 app/styles/shared/typography.css create mode 100644 app/styles/styles.css create mode 100644 app/views/calendar/calendar-background/calendar-background.css create mode 100644 app/views/calendar/calendar-background/calendar-background.js create mode 100644 app/views/calendar/calendar-legend/calendar-legend.css create mode 100644 app/views/calendar/calendar-legend/calendar-legend.js create mode 100644 app/views/calendar/calendar-month/calendar-month.css create mode 100644 app/views/calendar/calendar-month/calendar-month.js create mode 100644 app/views/calendar/calendar-navigation/calendar-navigation.css create mode 100644 app/views/calendar/calendar-navigation/calendar-navigation.js create mode 100644 app/views/calendar/calendar-show-preferences/calendar-show-preferences.css create mode 100644 app/views/calendar/calendar-show-preferences/calendar-show-preferences.js create mode 100644 app/views/calendar/calendar-today/calendar-today.css create mode 100644 app/views/calendar/calendar-today/calendar-today.js create mode 100644 app/views/calendar/calendar-view/calendar-view.css create mode 100644 app/views/calendar/calendar-view/calendar-view.js create mode 100644 app/views/calendar/calendar.html create mode 100644 app/views/calendar/ipc.js create mode 100644 app/views/calendar/preload.js create mode 100644 app/views/calendar/renderer.js create mode 100644 app/views/common/translation-key/translation-key.js create mode 100644 app/views/preferences/ipc.js create mode 100644 app/views/preferences/preferences-view/preferences-view.css create mode 100644 app/views/preferences/preferences-view/preferences-view.js create mode 100644 app/views/preferences/preferences.html create mode 100644 app/views/preferences/preload.js create mode 100644 app/views/preferences/renderer.js create mode 100644 build/_icon.icns create mode 100644 build/entitlements.mac.plist create mode 100644 build/icon.icns create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/notarize.js create mode 100644 yarn.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f208c2f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +# editorconfig-tools is unable to ignore longs strings or urls +max_line_length = off + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5028cbf --- /dev/null +++ b/.eslintrc @@ -0,0 +1,22 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "airbnb-base" + ], + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "rules": { + "import/extensions": "off", + "no-console": "off", + "max-len": "off" + }, + "settings": { + "import/core-modules": ["electron"] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8bfa7e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,289 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/node,macos,windows,intellij +# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,windows,intellij + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env*.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Storybook build outputs +.out +.storybook-out +storybook-static + +# rollup.js default build output +dist/ + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/node,macos,windows,intellij diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..d23208f --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jsLinters/eslint.xml b/.idea/jsLinters/eslint.xml new file mode 100644 index 0000000..541945b --- /dev/null +++ b/.idea/jsLinters/eslint.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d53e611 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/timestamp.iml b/.idea/timestamp.iml new file mode 100644 index 0000000..94b5823 --- /dev/null +++ b/.idea/timestamp.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a6484b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# [1.0.1] +###### 2016-09-09 + +A tiny patch release which eliminates two bugs. 🐞 + +###### Fixed +- Calendar window position being offset on multiple monitors +- Preferences and about window being opened multiple times + +# 1.0.0 +###### 2016-09-04 + +First public release! 🎉 + +[1.0.1]: https://github.com/mzdr/timestamp/compare/1.0.0...1.0.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae6ac8b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Sebastian Prein + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab815d5 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +

+ Logo of Timestamp +

Timestamp

+ Latest Timestamp release +

+ +A better macOS menu bar clock with a customizable date/time display and a calendar. Inspired by [Day-O]. + +Built with [Electron] and [date-fns]. + +## Screenshots + +![Screenshot of Timestamp app](https://mzdr.github.io/timestamp/screenshot.jpg) + +## Install + +### Manual +**[Download]**, unzip, and move `Timestamp.app` to the `/Applications` directory. + +### Homebrew +Simply run `brew install --cask timestamp` in your terminal. + +## Support + +**Bugs and requests**: Please use the project's [issue tracker]. +[![Issues](http://img.shields.io/github/issues/mzdr/timestamp.svg)](https://github.com/mzdr/timestamp/issues) + +**Want to contribute?** Please fork this repository and open a pull request with your new shiny stuff. 🌟 +[![GitHub pull requests](https://img.shields.io/github/issues-pr/mzdr/timestamp.svg?maxAge=3600)](https://github.com/mzdr/timestamp/pulls) + +**Do you like it?** Support the project by starring the repository or [tweet] about it. + +## Thanks + +**Timestamp** © 2021, Sebastian Prein. Released under the [MIT License]. + +[Day-O]: http://shauninman.com/archive/2011/10/20/day_o_mac_menu_bar_clock +[Electron]: http://electron.atom.io/ +[date-fns]: https://date-fns.org/ +[MIT License]: https://mit-license.org/ +[issue tracker]: https://github.com/mzdr/timestamp/issues/new +[tweet]: https://twitter.com/intent/tweet?url=https://github.com/mzdr/timestamp&text=Timestamp,%20a%20better%20macOS%20menu%20bar%20clock%20with%20a%20customizable%20date/time%20display%20and%20a%20calendar.%20%E2%80%94 +[customizable]: https://date-fns.org/docs/format +[Download]: https://github.com/mzdr/timestamp/releases/latest +[support]: #support diff --git a/app/assets/backgrounds/empty.svg b/app/assets/backgrounds/empty.svg new file mode 100644 index 0000000..714efc7 --- /dev/null +++ b/app/assets/backgrounds/empty.svg @@ -0,0 +1 @@ + diff --git a/app/assets/backgrounds/gradient.svg b/app/assets/backgrounds/gradient.svg new file mode 100644 index 0000000..f34c7be --- /dev/null +++ b/app/assets/backgrounds/gradient.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + diff --git a/app/assets/icons/bell.svg b/app/assets/icons/bell.svg new file mode 100644 index 0000000..8cd4c77 --- /dev/null +++ b/app/assets/icons/bell.svg @@ -0,0 +1 @@ + diff --git a/app/assets/icons/calendar.svg b/app/assets/icons/calendar.svg new file mode 100644 index 0000000..976b62b --- /dev/null +++ b/app/assets/icons/calendar.svg @@ -0,0 +1 @@ + diff --git a/app/assets/icons/command.svg b/app/assets/icons/command.svg new file mode 100644 index 0000000..8fdff82 --- /dev/null +++ b/app/assets/icons/command.svg @@ -0,0 +1 @@ + diff --git a/app/assets/icons/settings.svg b/app/assets/icons/settings.svg new file mode 100644 index 0000000..a71944c --- /dev/null +++ b/app/assets/icons/settings.svg @@ -0,0 +1 @@ + diff --git a/app/assets/logo.svg b/app/assets/logo.svg new file mode 100644 index 0000000..1fa9653 --- /dev/null +++ b/app/assets/logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/components/Calendar.js b/app/components/Calendar.js new file mode 100644 index 0000000..1260b58 --- /dev/null +++ b/app/components/Calendar.js @@ -0,0 +1,154 @@ +const { ipcMain } = require('electron'); +const { resolve } = require('path'); +const datefns = require('date-fns'); + +const Window = require('./Window'); + +const { + CALENDAR_GET_CALENDAR, + CALENDAR_GET_DATE, + CALENDAR_GET_MONTHS, + CALENDAR_HIDE, + CALENDAR_IS_SAME_HOUR, + CALENDAR_SHOW, +} = require('../views/calendar/ipc'); + +class Calendar { + constructor({ locale, logger }) { + this.logger = logger; + this.locale = locale.getObject(); + + ipcMain.handle(CALENDAR_GET_CALENDAR, this.getCalendar.bind(this)); + ipcMain.handle(CALENDAR_GET_DATE, this.getDate.bind(this)); + ipcMain.handle(CALENDAR_GET_MONTHS, this.getMonths.bind(this)); + ipcMain.handle(CALENDAR_IS_SAME_HOUR, (event, ...dates) => datefns.isSameHour(...dates)); + + ipcMain.on(CALENDAR_HIDE, () => this.window.hide()); + ipcMain.on(CALENDAR_SHOW, () => this.window.show()); + + this.window = new Window({ + name: 'calendar', + sourceFile: resolve(__dirname, '../views/calendar/calendar.html'), + webPreferences: { + preload: resolve(__dirname, '../views/calendar/preload.js'), + backgroundThrottling: false, + }, + }); + + this.logger.debug('Calendar module created.'); + } + + getDate(event, payload = {}) { + const { locale } = this; + + const { + date, format, set, diff, + } = payload; + + let final = date || new Date(); + + if (diff) { + final = datefns.add(final, diff); // date-fns.add() supports negative numbers as well + } + + if (set) { + final = datefns.set(final, set); + } + + if (format) { + try { + return datefns.format(final, format, { locale }); + } catch (e) { + return '#invalid format#'; + } + } + + return final; + } + + getCalendar(event, { date }) { + const { locale } = this; + const weekdays = []; + const weeks = []; + const days = []; + + const totalDays = datefns.getDaysInMonth(date); + const lastDayOfMonth = datefns.lastDayOfMonth(date); + const startOfMonth = datefns.startOfMonth(date); + const firstWeek = datefns.startOfWeek(startOfMonth, { locale }); + const lastWeek = datefns.endOfWeek(lastDayOfMonth, { locale }); + const previousMonthDays = datefns.differenceInCalendarDays(startOfMonth, firstWeek); + const nextMonthDays = datefns.differenceInCalendarDays(lastWeek, lastDayOfMonth); + + for (let i = 0; i < previousMonthDays; i += 1) { + const day = datefns.addDays(firstWeek, i); + const isToday = datefns.isToday(day); + const isThisWeek = datefns.isThisWeek(day, { locale }); + const previousMonth = true; + + days.push({ + day, isToday, isThisWeek, previousMonth, + }); + } + + for (let i = 0; i < totalDays; i += 1) { + const day = datefns.addDays(startOfMonth, i); + const isToday = datefns.isToday(day); + const isThisWeek = datefns.isThisWeek(day, { locale }); + + days.push({ day, isToday, isThisWeek }); + } + + for (let i = 1; i <= nextMonthDays; i += 1) { + const day = datefns.addDays(lastDayOfMonth, i); + const isToday = datefns.isToday(day); + const isThisWeek = datefns.isThisWeek(day, { locale }); + const nextMonth = true; + + days.push({ + day, isToday, isThisWeek, nextMonth, + }); + } + + for (let i = 0; i < 7; i += 1) { + weekdays.push(datefns.format(days[i].day, 'EEE', { locale })); + } + + for (let i = 0; i < days.length; i += 7) { + weeks.push(datefns.getWeek(days[i].day, { locale })); + } + + return { + totalDays, + lastDayOfMonth, + startOfMonth, + firstWeek, + lastWeek, + previousMonthDays, + nextMonthDays, + weekdays, + weeks, + days, + }; + } + + getMonths() { + const { locale } = this; + const start = datefns.startOfYear(new Date()); + const months = []; + + for (let i = 0; i < 12; i += 1) { + months.push( + datefns.format( + datefns.addMonths(start, i), + 'MMM', + { locale }, + ), + ); + } + + return months; + } +} + +module.exports = Calendar; diff --git a/app/components/Clock.js b/app/components/Clock.js new file mode 100644 index 0000000..5ef457a --- /dev/null +++ b/app/components/Clock.js @@ -0,0 +1,39 @@ +const datefns = require('date-fns'); + +class Clock { + constructor(options = {}) { + const { onTick, locale, format } = options; + + this.locale = locale.getObject(); + this.setFormat(format); + + if (typeof onTick === 'function') { + setInterval(() => onTick(this.toString()), 1000); + } + } + + getFormat() { + return this.format; + } + + setFormat(value) { + if (typeof value !== 'string') { + throw new Error(`Clock.format is supposed to be a string, ${typeof value} given.`); + } + + // @see https://date-fns.org/docs/format + this.format = value; + + return this; + } + + toString() { + try { + return datefns.format(new Date(), this.getFormat(), { locale: this.locale }); + } catch (e) { + return '#invalid format#'; + } + } +} + +module.exports = Clock; diff --git a/app/components/Locale.js b/app/components/Locale.js new file mode 100644 index 0000000..c53f158 --- /dev/null +++ b/app/components/Locale.js @@ -0,0 +1,40 @@ +/* eslint-disable global-require */ +const locales = { + date: require('date-fns/locale'), + app: require('../locales'), +}; + +class Locale { + constructor(options = {}) { + const { preferred, logger } = options; + const [language, extension] = String(preferred).split('-'); + + logger.debug(`Preferred locale is “${preferred}”.`); + + const fullSupport = `${language}${extension}`; + const partialSupport = language; + const fallback = 'en-US'; + + this.locale = [fullSupport, partialSupport, fallback].find((k) => locales.date[k]); + this.localeObject = locales.date[this.locale]; + this.translations = locales.app[partialSupport] || locales.app.en; + + logger.debug(`Using “${this.translations.locale}” as application locale and “${this.locale}” as clock/calendar locale.`); + } + + get() { + return this.locale; + } + + getObject() { + return this.localeObject; + } + + translate(key) { + return key + .split('.') + .reduce((o, i) => (o || {})[i], this.translations) || key; + } +} + +module.exports = Locale; diff --git a/app/components/Logger.js b/app/components/Logger.js new file mode 100644 index 0000000..2c2ae7c --- /dev/null +++ b/app/components/Logger.js @@ -0,0 +1,63 @@ +const { writeFile } = require('fs').promises; +const { logFile } = require('../paths'); + +class Logger { + constructor() { + this.filePath = logFile; + this.levels = { + emergency: 0, + alert: 1, + critical: 2, + error: 3, + warning: 4, + notice: 5, + informational: 6, + debug: 7, + }; + + this.cleanUp = writeFile(this.filePath, '\n'); + } + + async log(level, message) { + const severity = Object.keys(this.levels).find((key) => this.levels[key] === level); + const entry = `[${severity}]: ${message}`; + + await this.cleanUp; + + writeFile(this.filePath, `${entry}\n`, { flag: 'a' }); + } + + emergency(message) { + return this.log(this.levels.emergency, message); + } + + alert(message) { + return this.log(this.levels.alert, message); + } + + critical(message) { + return this.log(this.levels.critical, message); + } + + error(message) { + return this.log(this.levels.error, message); + } + + warning(message) { + return this.log(this.levels.warning, message); + } + + notice(message) { + return this.log(this.levels.notice, message); + } + + informational(message) { + return this.log(this.levels.informational, message); + } + + debug(message) { + return this.log(this.levels.debug, message); + } +} + +module.exports = Logger; diff --git a/app/components/Preferences.js b/app/components/Preferences.js new file mode 100644 index 0000000..f6dbb6e --- /dev/null +++ b/app/components/Preferences.js @@ -0,0 +1,151 @@ +const { ipcMain } = require('electron'); +const { resolve } = require('path'); + +const { + readFile, + writeFile, + readdir, + mkdir, +} = require('fs').promises; + +const { + preferencesFile, + customBackgroundsDirectory, + integratedBackgroundsDirectory, +} = require('../paths'); + +const Window = require('./Window'); + +const { + PREFERENCES_GET, + PREFERENCES_GET_ALL, + PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, + PREFERENCES_GET_BACKGROUNDS, + PREFERENCES_HIDE, + PREFERENCES_SET, + PREFERENCES_SHOW, + +} = require('../views/preferences/ipc'); + +class Preferences { + constructor(options = {}) { + const { + onChange, + defaults, + logger, + } = options; + + this.logger = logger; + this.filePath = preferencesFile; + this.onChange = onChange || (() => {}); + this.data = new Map(Object.entries(defaults)); + + ipcMain.handle(PREFERENCES_GET, (event, key) => this.get(key)); + ipcMain.handle(PREFERENCES_GET_ALL, () => this.getAll()); + ipcMain.handle(PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, this.getBackgroundFileContents.bind(this)); + ipcMain.handle(PREFERENCES_GET_BACKGROUNDS, this.getBackgrounds.bind(this)); + + ipcMain.on(PREFERENCES_SET, (event, key, value) => this.set(key, value)); + ipcMain.on(PREFERENCES_HIDE, () => this.window.hide()); + ipcMain.on(PREFERENCES_SHOW, () => this.window.show()); + + this.window = new Window({ + name: 'preferences', + titleBarStyle: 'hidden', + transparent: true, + vibrancy: 'sidebar', + trafficLightPosition: { x: 20, y: 20 }, + sourceFile: resolve(__dirname, '../views/preferences/preferences.html'), + webPreferences: { + preload: resolve(__dirname, '../views/preferences/preload.js'), + }, + }); + + this.logger.debug('Preferences module created.'); + this.load(); + } + + async load() { + try { + this.logger.debug(`Trying to load user preferences from “${this.filePath}”.`); + + Object + .entries(JSON.parse(await readFile(this.filePath, 'utf8'))) + .forEach((item) => this.set(...item, false)); + + await mkdir(customBackgroundsDirectory); + } catch ({ message }) { + if (/enoent/i.test(message)) { + this.logger.debug('Looks like it’s the first time starting Timestamp. No user preferences found.'); + } else if (/eexist/i.test(message)) { + this.logger.debug('Directory for custom backgrounds has already been created.'); + } else { + this.logger.error(message); + } + } + + return this; + } + + async save() { + await writeFile(this.filePath, JSON.stringify(Object.fromEntries(this.data))); + + return this; + } + + getAll() { + return new Map(this.data); + } + + async getBackgroundFileContents(event, filePath) { + try { + return await readFile(filePath, { encoding: 'utf-8' }); + } catch ({ message }) { + if (/enoent/i.test(message)) { + this.logger.warning(`Couldn’t find background file “${filePath}”.`); + } else { + this.logger.error(message); + } + } + + return ''; + } + + async getBackgrounds() { + const backgrounds = []; + const directories = [integratedBackgroundsDirectory, customBackgroundsDirectory]; + + await Promise.all( + directories.map(async (directory) => { + try { + (await readdir(directory)).forEach( + (background) => backgrounds.push(resolve(directory, background)), + ); + } catch ({ message }) { + this.logger.warn(message); + } + }), + ); + + return backgrounds; + } + + get(key) { + return this.data.get(key); + } + + set(key, value, persist = true) { + this.logger.debug(`Setting value for preference with key ”${key}” to “${value}”.`); + + this.data.set(key, value); + this.onChange(key, value); + + if (persist) { + this.save(); + } + + return this; + } +} + +module.exports = Preferences; diff --git a/app/components/SystemTray.js b/app/components/SystemTray.js new file mode 100644 index 0000000..0dbd1ad --- /dev/null +++ b/app/components/SystemTray.js @@ -0,0 +1,55 @@ +const { Tray, nativeImage } = require('electron'); + +class SystemTray { + constructor(options = {}) { + const { onClick, logger } = options; + + this.logger = logger; + this.prefix = ''; + this.tray = new Tray( + nativeImage.createEmpty(), + ); + + this.logger.debug('System tray created.'); + + if (typeof onClick === 'function') { + this.tray.on('click', onClick); + } + } + + getBounds() { + return this.tray.getBounds(); + } + + getPrefix() { + return this.prefix; + } + + setPrefix(value) { + this.prefix = value; + + return this; + } + + getLabel() { + return this.tray.getTitle(); + } + + setLabel(label) { + const { tray } = this; + + if (tray.isDestroyed()) { + this.logger.error('Unable to set label since tray is destroyed.'); + + return this; + } + + tray.setTitle(`${this.getPrefix()}${this.label = label}`, { + fontType: 'monospacedDigit', + }); + + return this; + } +} + +module.exports = SystemTray; diff --git a/app/components/Updater.js b/app/components/Updater.js new file mode 100644 index 0000000..5278e95 --- /dev/null +++ b/app/components/Updater.js @@ -0,0 +1,83 @@ +const { lt } = require('semver'); +const { get } = require('https'); +const { autoUpdater } = require('electron'); + +class Updater { + constructor(options = {}) { + const { + checkEvery = 1000 * 60 * 60, + currentVersion, + feedUrl, + logger, + onUpdateDownloaded, + } = options; + + this.feedUrl = feedUrl; + this.logger = logger; + + autoUpdater.on('error', this.onError.bind(this)); + autoUpdater.on('update-downloaded', onUpdateDownloaded); + + setInterval(this.onTick.bind(this, currentVersion), checkEvery); + + this.logger.debug('Updater module created.'); + this.logger.debug(`Checking “${feedUrl}” every ${checkEvery / 1000} seconds for updates.`); + + this.onTick(currentVersion); + } + + async fetchJson() { + return new Promise((resolve, reject) => { + const request = get(this.feedUrl, (response) => { + const { statusCode } = response; + const json = []; + + if (statusCode !== 200) { + reject(new Error(`Feed url is not reachable. Response status code is ${statusCode}.`)); + } else { + response.on('data', json.push.bind(json)); + response.on('end', () => { + try { + resolve(JSON.parse(json.join())); + } catch (e) { + this.logger.error('Couldn’t parse feed response.'); + } + }); + } + }); + + request.on('error', reject); + }); + } + + quitAndInstall() { + autoUpdater.quitAndInstall(); + + return this; + } + + async onTick(currentVersion) { + try { + const { version } = await this.fetchJson(); + + if (lt(currentVersion, version) === false) { + return; + } + + autoUpdater.setFeedURL(this.feedUrl); + autoUpdater.checkForUpdates(); + + this.logger.debug(`Update available. (${currentVersion} -> ${version})`); + } catch ({ message }) { + this.logger.warning(message); + } + } + + onError({ message }) { + this.logger.warning(message); + + return this; + } +} + +module.exports = Updater; diff --git a/app/components/Window.js b/app/components/Window.js new file mode 100644 index 0000000..9384b48 --- /dev/null +++ b/app/components/Window.js @@ -0,0 +1,120 @@ +const { BrowserWindow, shell } = require('electron'); + +class Window { + constructor(options = {}) { + const defaults = { + alwaysOnTop: true, + frame: false, + minimizable: false, + resizable: false, + show: false, + }; + + const { + sourceFile, + name, + onReady, + ...rest + } = options; + + this.name = name; + this.browserWindow = new BrowserWindow({ ...defaults, ...rest }); + + if (typeof onReady === 'function') { + this.browserWindow.on('ready-to-show', onReady); + } + + // @see https://www.electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation + this.browserWindow.webContents.on('will-navigate', (event, navigationUrl) => { + event.preventDefault(); + + if (/^https?:\/\//.test(navigationUrl)) { + shell.openExternal(navigationUrl); + } + }); + + // @see https://www.electronjs.org/docs/tutorial/security#13-disable-or-limit-creation-of-new-windows + this.browserWindow.webContents.on('new-window', (event) => event.preventDefault()); + + this + .browserWindow + .on('close', this.onClose.bind(this)) + .loadFile(sourceFile); + } + + isSame(window) { + return window === this.browserWindow; + } + + destroy() { + this.browserWindow.destroy(); + + return this; + } + + show() { + this.browserWindow.webContents.send(`${this.name}.show`); + this.browserWindow.show(); + + return this; + } + + hide() { + this.browserWindow.webContents.send(`${this.name}.hide`); + this.browserWindow.hide(); + + return this; + } + + toggleVisibility() { + return this.isVisible() ? this.hide() : this.show(); + } + + isVisible() { + return this.browserWindow.isVisible(); + } + + onClose(event) { + this.hide(); + + // By default all windows in Timestamp are hidden and not closed + event.preventDefault(); + } + + getBrowserWindow() { + return this.browserWindow; + } + + getWebContents() { + return this.browserWindow.webContents; + } + + getContentSize() { + return this.browserWindow.getContentSize(); + } + + setContentSize(width, height) { + if (typeof width !== 'number' || typeof height !== 'number') { + throw new Error('Window.setContentSize has been call with non-numeric arguments.'); + } + + this.browserWindow.setContentSize(width, height, true); + + return this; + } + + getPosition() { + return this.browserWindow.getPosition(); + } + + setPosition(x, y, centerToX = true) { + this.browserWindow.setPosition( + centerToX ? Math.round(x - (this.browserWindow.getSize()[0] / 2)) : x, + y, + ); + + return this; + } +} + +module.exports = Window; diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..e4d3fd4 --- /dev/null +++ b/app/index.js @@ -0,0 +1,156 @@ +const { + app, screen, ipcMain, BrowserWindow, +} = require('electron'); + +const { arch, platform, release } = require('os'); +const { resolve } = require('path'); +const { parseInline } = require('marked'); + +const Calendar = require('./components/Calendar'); +const Clock = require('./components/Clock'); +const Locale = require('./components/Locale'); +const Logger = require('./components/Logger'); +const Preferences = require('./components/Preferences'); +const SystemTray = require('./components/SystemTray'); +const Updater = require('./components/Updater'); +const { PREFERENCES_CHANGED } = require('./views/preferences/ipc'); +const { integratedBackgroundsDirectory } = require('./paths'); + +const { + APP_QUIT, + APP_RESIZE_WINDOW, + APP_RESTART, + APP_TRANSLATE, + APP_UPDATE_DOWNLOADED, +} = require('./ipc'); + +const defaultPreferences = { + calendarBackground: resolve(integratedBackgroundsDirectory, 'empty.svg'), + calendarLegendFormat: 'MMMM y', + calendarTodayFormat: 'EEEE,\ndo MMMM', + clockFormat: 'PPPP', + openAtLogin: false, +}; + +(async () => { + await app.whenReady(); + + app.dock.hide(); + + return new class { + constructor() { + const currentVersion = app.getVersion(); + + this.logger = new Logger(); + + this.logger.debug(`Starting Timestamp v${currentVersion} on “${platform()}-${arch()} v${release()}”.`); + this.logger.debug(`Running in ${app.isPackaged ? 'production' : 'development'} mode.`); + + if (app.isPackaged) { + this.updater = new Updater({ + currentVersion, + feedUrl: 'https://mzdr.github.io/timestamp/update.json', + logger: this.logger, + onUpdateDownloaded: this.onUpdateDownloaded.bind(this), + }); + } + + this.locale = new Locale({ + logger: this.logger, + preferred: app.getLocale(), + }); + + this.tray = new SystemTray({ + logger: this.logger, + onClick: this.onTrayClicked.bind(this), + }); + + this.clock = new Clock({ + format: defaultPreferences.clockFormat, + locale: this.locale, + onTick: this.tray.setLabel.bind(this.tray), + }); + + this.calendar = new Calendar({ + locale: this.locale, + logger: this.logger, + }); + + this.preferences = new Preferences({ + defaults: defaultPreferences, + logger: this.logger, + onChange: this.onPreferencesChanged.bind(this), + }); + + ipcMain.on(APP_QUIT, () => app.exit()); + ipcMain.on(APP_RESTART, this.onRestart.bind(this)); + ipcMain.on(APP_RESIZE_WINDOW, this.onResizeWindow.bind(this)); + ipcMain.handle(APP_TRANSLATE, this.onTranslate.bind(this)); + } + + onRestart() { + if (this.updater === undefined) { + return this; + } + + this.calendar.window.destroy(); + this.preferences.window.destroy(); + this.updater.quitAndInstall(); + + return this; + } + + onResizeWindow({ sender }, { width, height }) { + const { calendar, preferences } = this; + const window = BrowserWindow.fromWebContents(sender); + + [calendar, preferences] + .find((view) => view.window.isSame(window)) + .window + .setContentSize(width, height); + } + + onPreferencesChanged(key, value) { + if (key === 'openAtLogin') { + app.setLoginItemSettings({ openAtLogin: value }); + } else if (key === 'clockFormat') { + this.clock.setFormat(value); + } else if (/^calendar/.test(key)) { + this.calendar.window.getWebContents().send(PREFERENCES_CHANGED, key, value); + } + + return this; + } + + onTranslate(event, key, options = {}) { + const { markdown = false } = options; + const translation = this.locale.translate(key); + + if (markdown) { + return parseInline(translation); + } + + return translation; + } + + onTrayClicked() { + const { calendar, tray } = this; + const bounds = tray.getBounds(); + const currentMousePosition = screen.getCursorScreenPoint(); + const currentDisplay = screen.getDisplayNearestPoint(currentMousePosition); + const yOffset = 6; + + // Always center calendar window relative to tray icon + calendar + .window + .setPosition(bounds.x + (bounds.width / 2), currentDisplay.workArea.y + yOffset) + .toggleVisibility(); + } + + onUpdateDownloaded() { + this.tray.setPrefix('→ '); + this.preferences.window.getWebContents().send(APP_UPDATE_DOWNLOADED); + this.calendar.window.getWebContents().send(APP_UPDATE_DOWNLOADED); + } + }(); +})(); diff --git a/app/ipc.js b/app/ipc.js new file mode 100644 index 0000000..6fc67bc --- /dev/null +++ b/app/ipc.js @@ -0,0 +1,28 @@ +const { ipcRenderer } = require('electron'); +const { productName, version, copyright } = require('../package.json'); + +const APP_QUIT = 'app.quit'; +const APP_RESIZE_WINDOW = 'app.resize-window'; +const APP_RESTART = 'app.restart'; +const APP_TRANSLATE = 'app.translate'; +const APP_UPDATE_DOWNLOADED = 'app.update-downloaded'; + +module.exports = { + APP_QUIT, + APP_RESIZE_WINDOW, + APP_RESTART, + APP_TRANSLATE, + APP_UPDATE_DOWNLOADED, + + api: { + productName, + version, + copyright, + + on: (channel, fn) => ipcRenderer.on(`app.${channel}`, fn), + quit: () => ipcRenderer.send(APP_QUIT), + resizeWindow: (payload) => ipcRenderer.send(APP_RESIZE_WINDOW, payload), + restart: () => ipcRenderer.send(APP_RESTART), + translate: (key, options) => ipcRenderer.invoke(APP_TRANSLATE, key, options), + }, +}; diff --git a/app/locales/de.js b/app/locales/de.js new file mode 100644 index 0000000..9cc80c9 --- /dev/null +++ b/app/locales/de.js @@ -0,0 +1,50 @@ +module.exports = { + name: 'Deutsch', + locale: 'de', + app: { + restart: 'Neustarten', + updateDownloaded: 'Eine neue Version von Timestamp wurde heruntergeladen. Bitte starten Sie die App neu, um sie zu aktualisieren.', + }, + preferences: { + category: { + general: 'Generell', + tray: 'System Tray', + calendar: 'Kalender', + shortcuts: 'Tastaturkürzel', + }, + openAtLogin: { + label: 'Autostart', + description: 'Aktivieren Sie diese Option, wenn Sie möchten, dass Timestamp automatisch beim Starten des Computers gestartet werden soll.', + }, + clockFormat: { + label: 'Format der Uhr', + description: 'Das [Format](https://date-fns.org/docs/format) der Uhrzeitanzeige im System-Tray.', + }, + calendarBackground: { + label: 'Hintergrund', + description: 'Wählen Sie einen Kalenderhintergrund aus der Ihrem Geschmack entspricht.', + }, + calendarLegendFormat: { + label: 'Format der Legende', + description: 'Das [Format](https://date-fns.org/docs/format) der Legende über dem Monat.', + }, + calendarTodayFormat: { + label: 'Format des aktuellen Tages', + description: 'Das [Format](https://date-fns.org/docs/format) des aktuellen Tages welches im Kalenderkopf angezeigt wird.', + }, + shortcuts: { + description: 'Im Folgenden finden Sie eine vollständige Liste der Tastenkombinationen, die Sie im Kalenderfenster verwenden können.', + keys: [ + ['W', 'Wochennummern anzeigen'], + ['↑', 'Nächstes Jahr'], + ['↓', 'Vorheriges Jahr'], + ['→', 'Nächster Monat'], + ['←', 'Vorheriger Monat'], + ['⌘+,', 'Einstellungen anzeigen'], + ['⌘+Q', 'Timestamp beenden'], + ['Esc', 'Fenster schließen'], + ['Leertaste', 'Aktuellen Tag anzeigen'], + ], + }, + }, +}; diff --git a/app/locales/en.js b/app/locales/en.js new file mode 100644 index 0000000..ead36ab --- /dev/null +++ b/app/locales/en.js @@ -0,0 +1,50 @@ +module.exports = { + name: 'English', + locale: 'en', + app: { + restart: 'Restart', + updateDownloaded: 'A new version of Timestamp has been downloaded. Please restart the app in order to update it.', + }, + preferences: { + category: { + general: 'General', + tray: 'System tray', + calendar: 'Calendar', + shortcuts: 'Shortcuts', + }, + openAtLogin: { + label: 'Open at login', + description: 'Enable this option if you want Timestamp to start automatically when you start your computer.', + }, + clockFormat: { + label: 'Clock format', + description: 'The format [pattern](https://date-fns.org/docs/format) of the system tray clock.', + }, + calendarBackground: { + label: 'Background', + description: 'Choose a calendar background that suits your personal liking.', + }, + calendarLegendFormat: { + label: 'Legend format', + description: 'The format [pattern](https://date-fns.org/docs/format) of the legend above the month.', + }, + calendarTodayFormat: { + label: 'Today format', + description: 'The format [pattern](https://date-fns.org/docs/format) of the today display in the calendar head.', + }, + shortcuts: { + description: 'See below for a complete list of keyboard shortcuts that you can use in the calendar window.', + keys: [ + ['W', 'Toggle week numbers'], + ['↑', 'Next year'], + ['↓', 'Previous year'], + ['→', 'Next month'], + ['←', 'Previous month'], + ['⌘+,', 'Show preferences'], + ['⌘+Q', 'Quit timestamp'], + ['Esc', 'Close window'], + ['Space', 'Go to today'], + ], + }, + }, +}; diff --git a/app/locales/index.js b/app/locales/index.js new file mode 100644 index 0000000..ceafd86 --- /dev/null +++ b/app/locales/index.js @@ -0,0 +1,7 @@ +const de = require('./de'); +const en = require('./en'); + +module.exports = { + de, + en, +}; diff --git a/app/paths.js b/app/paths.js new file mode 100644 index 0000000..9c32f64 --- /dev/null +++ b/app/paths.js @@ -0,0 +1,11 @@ +const { app } = require('electron'); +const { resolve } = require('path'); + +const storagePath = app.getPath('userData'); + +module.exports = { + integratedBackgroundsDirectory: resolve(__dirname, 'assets/backgrounds'), + customBackgroundsDirectory: resolve(storagePath, 'Backgrounds'), + logFile: resolve(storagePath, 'Output.log'), + preferencesFile: resolve(storagePath, 'UserPreferences.json'), +}; diff --git a/app/styles/components/button-primary.css b/app/styles/components/button-primary.css new file mode 100644 index 0000000..a6ce743 --- /dev/null +++ b/app/styles/components/button-primary.css @@ -0,0 +1,13 @@ +.button-primary { + background-blend-mode: color-burn; + background-color: var(--color-brand); + background-image: linear-gradient(180deg, #fff, #ccc); + border-radius: 3px; + border: 0; + color: var(--palette-white); + font-family: inherit; + font-size: inherit; + outline: 0; + padding: 6px 12px; + text-shadow: -1px 0 rgba(0, 0, 0, 0.1); +} diff --git a/app/styles/components/container-alert.css b/app/styles/components/container-alert.css new file mode 100644 index 0000000..323144c --- /dev/null +++ b/app/styles/components/container-alert.css @@ -0,0 +1,18 @@ +.container-alert { + align-items: center; + display: grid; + grid-gap: var(--grid-gap); + grid-template-areas: "icon message actions"; + overflow: hidden; + padding: var(--grid-gap); +} + +.container-alert > .icon { + align-items: center; + display: flex; + font-size: calc(var(--type-size) * 2); + grid-area: icon; + justify-content: center; +} +.container-alert > .message { grid-area: message; } +.container-alert > .actions { grid-area: actions; } diff --git a/app/styles/components/form-group.css b/app/styles/components/form-group.css new file mode 100644 index 0000000..dca1457 --- /dev/null +++ b/app/styles/components/form-group.css @@ -0,0 +1,43 @@ +.form-group { + display: grid; + grid-gap: calc(var(--grid-gap) / 3) calc(var(--grid-gap) * 3); + grid-template-areas: "label action" "description action"; + grid-template-columns: 1fr min-content; +} + +.form-group > .label { + align-self: center; + grid-area: label; +} + +.form-group > .description { + color: var(--color-text-shy); + font-size: var(--shy-size); + grid-area: description; +} + +.form-group > .action { + align-self: flex-start; + grid-area: action; + margin: 0; + outline: 0; +} + +.form-group > .action.-text { + color: var(--palette-black); + font: inherit; + outline: 0; + padding: 2px 4px; + resize: none; + text-align: center; +} + +.form-group > .action.-toggle { + height: 20px; + width: 20px; +} + +.form-group > .action.-select { + padding: 5px; + text-transform: capitalize; +} diff --git a/app/styles/components/is-native.css b/app/styles/components/is-native.css new file mode 100644 index 0000000..f0fcd72 --- /dev/null +++ b/app/styles/components/is-native.css @@ -0,0 +1,21 @@ +.is-native { + --palette-black: #272727; + --palette-white: #f5f2f2; + + --color-line: #dcd9da; +} + +@media (prefers-color-scheme: dark) { + .is-native { + --palette-black: #322d2c; + --palette-white: #e0e0df; + + --color-background-primary: var(--palette-black); + --color-text-primary: var(--palette-white); + --color-line: #434041; + } +} + +.is-native.-transparent { + background-color: transparent; +} diff --git a/app/styles/components/list-shortcuts.css b/app/styles/components/list-shortcuts.css new file mode 100644 index 0000000..387c44f --- /dev/null +++ b/app/styles/components/list-shortcuts.css @@ -0,0 +1,23 @@ +.list-shortcuts { + display: grid; + grid-template-columns: 1fr 2fr; + grid-gap: calc(var(--grid-gap) / 2) var(--grid-gap); +} + +.list-shortcuts > .keys { + align-items: center; + display: grid; + font-size: var(--shy-size); + grid-auto-flow: column; + grid-column-gap: calc(var(--grid-gap) / 2); + justify-content: flex-start; +} + +.list-shortcuts > .keys > .key { + background-color: var(--palette-white); + background-image: linear-gradient(to top, #e6e6e6, transparent); + border-radius: 3px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); + color: var(--palette-black); + padding: 6px 12px; +} diff --git a/app/styles/meta/colors.css b/app/styles/meta/colors.css new file mode 100644 index 0000000..b86a885 --- /dev/null +++ b/app/styles/meta/colors.css @@ -0,0 +1,27 @@ +:root { + --palette-blue: #008bed; + --palette-green: #0cbd00; + --palette-black: #111; + --palette-gray-20: #333; + --palette-gray-40: #666; + --palette-gray-60: #999; + --palette-gray-80: #ccc; + --palette-white: #fff; + + --color-brand: var(--palette-blue); + --color-background-primary: var(--palette-white); + --color-line: var(--palette-gray-80); + --color-text-primary: var(--palette-black); + --color-text-shy: var(--palette-gray-60); +} + +@media (prefers-color-scheme: dark) { + :root { + --palette-white: #eee; + + --color-background-primary: var(--palette-black); + --color-text-primary: var(--palette-white); + --color-text-shy: var(--palette-gray-40); + --color-line: var(--palette-gray-20); + } +} diff --git a/app/styles/meta/fx.css b/app/styles/meta/fx.css new file mode 100644 index 0000000..dee11d9 --- /dev/null +++ b/app/styles/meta/fx.css @@ -0,0 +1,4 @@ +:root { + --fx-duration: 300ms; + --fx-radius: 15px; +} diff --git a/app/styles/meta/grid.css b/app/styles/meta/grid.css new file mode 100644 index 0000000..83fbf1c --- /dev/null +++ b/app/styles/meta/grid.css @@ -0,0 +1,3 @@ +:root { + --grid-gap: 22px; +} diff --git a/app/styles/meta/typography.css b/app/styles/meta/typography.css new file mode 100644 index 0000000..430a84c --- /dev/null +++ b/app/styles/meta/typography.css @@ -0,0 +1,9 @@ +:root { + --type-family: system-ui; + --type-rendering: optimizeLegibility; + --type-rhythm: 1.5; + --type-sentence: 60ch; + --type-size: 14px; + + --shy-size: 12px; +} diff --git a/app/styles/shared/base.css b/app/styles/shared/base.css new file mode 100644 index 0000000..f15ff9c --- /dev/null +++ b/app/styles/shared/base.css @@ -0,0 +1,16 @@ +:root { + background-color: var(--color-background-primary); + box-sizing: border-box; + color: var(--color-text-primary); + cursor: default; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +body { + margin: 0; +} diff --git a/app/styles/shared/typography.css b/app/styles/shared/typography.css new file mode 100644 index 0000000..1338cbc --- /dev/null +++ b/app/styles/shared/typography.css @@ -0,0 +1,6 @@ +:root { + font-family: var(--type-family); + font-size: var(--type-size); + line-height: var(--type-rhythm); + text-rendering: var(--type-rendering); +} diff --git a/app/styles/styles.css b/app/styles/styles.css new file mode 100644 index 0000000..718e75e --- /dev/null +++ b/app/styles/styles.css @@ -0,0 +1,7 @@ +@import "meta/colors.css"; +@import "meta/fx.css"; +@import "meta/grid.css"; +@import "meta/typography.css"; +@import "shared/base.css"; +@import "shared/typography.css"; +@import "components/is-native.css"; diff --git a/app/views/calendar/calendar-background/calendar-background.css b/app/views/calendar/calendar-background/calendar-background.css new file mode 100644 index 0000000..d258e6d --- /dev/null +++ b/app/views/calendar/calendar-background/calendar-background.css @@ -0,0 +1,22 @@ +:host { + display: block; +} + +.calendar-background { + display: grid; + grid-template-areas: "main"; +} + +.calendar-background > .image, +.calendar-background > .slot { + grid-area: main; +} + +.calendar-background > .image { + display: flex; + margin: 0; +} + +.calendar-background > .slot { + display: block; +} diff --git a/app/views/calendar/calendar-background/calendar-background.js b/app/views/calendar/calendar-background/calendar-background.js new file mode 100644 index 0000000..424e80c --- /dev/null +++ b/app/views/calendar/calendar-background/calendar-background.js @@ -0,0 +1,54 @@ +import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarBackground extends HTMLElement { + constructor() { + super(); + + upgrade(this, ` + + `); + + const { preferences } = window; + + preferences.on('changed', this.onPreferencesChanged.bind(this)); + + this.render(); + } + + onPreferencesChanged(event, key, value) { + if (key !== 'calendarBackground') { + return; + } + + this.render(value); + } + + async render(background) { + const { preferences } = window; + const { $content } = this.$refs; + + $content.innerHTML = await preferences.getBackgroundFileContents( + background || await preferences.get('calendarBackground'), + ); + + dispatch(this, 'postrender'); + } + + async onPostUpdate() { + const { calendar } = window; + const now = await calendar.getDate(); + + Object.assign(this.dataset, { + hour: now.getHours(), + month: now.getMonth(), + }); + + return this; + } +} diff --git a/app/views/calendar/calendar-legend/calendar-legend.css b/app/views/calendar/calendar-legend/calendar-legend.css new file mode 100644 index 0000000..c597fbe --- /dev/null +++ b/app/views/calendar/calendar-legend/calendar-legend.css @@ -0,0 +1,28 @@ +:host { + display: block; +} + +.calendar-legend { + align-items: center; + color: var(--color-text-shy); + display: grid; + grid-column-gap: 20px; + grid-template-columns: 50px minmax(60px, max-content) 50px; + justify-content: center; + text-align: center; + white-space: pre; +} + +.calendar-legend::before, +.calendar-legend::after { + content: ""; + height: 1px; +} + +.calendar-legend::before { + background-image: linear-gradient(to left, var(--color-line), transparent); +} + +.calendar-legend::after { + background-image: linear-gradient(to right, var(--color-line), transparent); +} diff --git a/app/views/calendar/calendar-legend/calendar-legend.js b/app/views/calendar/calendar-legend/calendar-legend.js new file mode 100644 index 0000000..a37a91e --- /dev/null +++ b/app/views/calendar/calendar-legend/calendar-legend.js @@ -0,0 +1,27 @@ +import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarLegend extends HTMLElement { + constructor() { + super(); + + upgrade(this, ` + + `); + } + + async onPostUpdate({ detail }) { + const { selectedMonth } = detail; + const { calendar, preferences } = window; + const { $content } = this.$refs; + + $content.textContent = await calendar.getDate({ + date: selectedMonth, + format: await preferences.get('calendarLegendFormat'), + }); + + dispatch(this, 'postrender'); + } +} diff --git a/app/views/calendar/calendar-month/calendar-month.css b/app/views/calendar/calendar-month/calendar-month.css new file mode 100644 index 0000000..a8ef656 --- /dev/null +++ b/app/views/calendar/calendar-month/calendar-month.css @@ -0,0 +1,72 @@ +:host { + display: block; +} + +:host(.show-weeks) > .calendar-month { + grid-template-areas: "no weekdays" "weeks days"; + grid-template-columns: 1fr 7fr; +} + +:host(:not(.show-weeks)) > .calendar-month > .no, +:host(:not(.show-weeks)) > .calendar-month > .weeks { + display: none; +} + +.calendar-month { + display: grid; + grid-row-gap: 10px; + grid-template-areas: "weekdays" "days"; + grid-template-columns: 1fr; + min-height: 200px; +} + +.calendar-month > .weekdays { + display: grid; + grid-area: weekdays; + grid-template-columns: repeat(7, 1fr); + text-align: center; +} + +.calendar-month > .no { + color: var(--color-line); + text-align: center; +} + +.calendar-month > .weeks { + color: var(--color-text-shy); + grid-area: weeks; +} + +.calendar-month > .weeks, +.calendar-month > .days { + display: grid; + grid-row-gap: 6px; +} + +.calendar-month > .weeks > .week, +.calendar-month > .days > .day { + align-items: center; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + text-align: center; +} + +.calendar-month > .days { + grid-area: days; + grid-auto-flow: row; + grid-template-columns: repeat(7, 1fr); + width: 100%; +} + +.calendar-month > .days > .day.-previous, +.calendar-month > .days > .day.-next { + color: var(--color-text-shy); +} + +.calendar-month > .days > .day.-today { + background-color: var(--color-brand); + background-image: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.1)); + border-radius: 50%; + color: var(--palette-white); +} diff --git a/app/views/calendar/calendar-month/calendar-month.js b/app/views/calendar/calendar-month/calendar-month.js new file mode 100644 index 0000000..8785fe8 --- /dev/null +++ b/app/views/calendar/calendar-month/calendar-month.js @@ -0,0 +1,49 @@ +import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarMonth extends HTMLElement { + constructor() { + super(); + + upgrade(this, ` + + `); + } + + async onPostUpdate({ detail }) { + const { selectedMonth } = detail; + const { calendar } = window; + const { $content } = this.$refs; + const { weekdays, weeks, days } = await calendar.getCalendar({ date: selectedMonth }); + + const $weekdays = weekdays.map((weekday) => `${weekday}`).join(''); + const $weeks = weeks.map((week) => `${week}`).join(''); + + const $days = days.map(({ + day, isToday, isThisWeek, previousMonth, nextMonth, + }) => { + const cssClass = [ + 'day', + isToday ? '-today' : null, + isThisWeek ? '-week' : null, + previousMonth ? '-previous' : null, + nextMonth ? '-next' : null, + ] + .filter(Boolean) + .join(' '); + + return `${day.getDate()}`; + }).join(''); + + $content.innerHTML = ` +
#
+
${$weekdays}
+
${$weeks}
+
${$days}
+ `; + + dispatch(this, 'postrender'); + } +} diff --git a/app/views/calendar/calendar-navigation/calendar-navigation.css b/app/views/calendar/calendar-navigation/calendar-navigation.css new file mode 100644 index 0000000..ac26a95 --- /dev/null +++ b/app/views/calendar/calendar-navigation/calendar-navigation.css @@ -0,0 +1,55 @@ +:host { + display: flex; +} + +.calendar-navigation { + align-content: center; + backdrop-filter: blur(20px); + background-color: rgba(255, 255, 255, 0.7); + display: grid; + grid-row-gap: calc(var(--grid-gap) * 3); + padding: var(--grid-gap); + width: 100%; +} + +.calendar-navigation > .year, +.calendar-navigation > .month { + display: grid; + grid-row-gap: var(--grid-gap); + grid-template-columns: repeat(3, 1fr); +} + +.go-to { + align-items: center; + background-color: transparent; + border-radius: 1em; + border: 0; + color: currentColor; + display: flex; + font-family: var(--type-family); + font-size: calc(var(--type-size) * 1.5); + font-weight: 200; + justify-content: center; + line-height: calc(var(--type-rhythm) * 1.5); + padding: 0; +} + +.go-to.-current, +.go-to.-year { + font-weight: 400; +} + +.go-to.-current { + box-shadow: inset 0 0 0 1px currentColor; +} + +@media (prefers-color-scheme: dark) { + .calendar-navigation { + background-color: rgba(17, 17, 17, 0.5); + } +} + +.icon-chevron { + opacity: 0.6; + width: 16px; +} diff --git a/app/views/calendar/calendar-navigation/calendar-navigation.js b/app/views/calendar/calendar-navigation/calendar-navigation.js new file mode 100644 index 0000000..1df520e --- /dev/null +++ b/app/views/calendar/calendar-navigation/calendar-navigation.js @@ -0,0 +1,127 @@ +import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarNavigation extends HTMLElement { + constructor() { + super(); + + upgrade(this, ` + + `); + } + + onKeyDown(e) { + const { key } = e; + + if (key === 'ArrowRight') { + this.goNextMonth(); + } else if (key === 'ArrowLeft') { + this.goPreviousMonth(); + } else if (key === 'ArrowUp') { + this.goNextYear(); + } else if (key === 'ArrowDown') { + this.goPreviousYear(); + } else if (key === ' ') { + this.goToday(); + + // Hitting space might as well trigger a focused button (month), + // avoid that behaviour. + e.preventDefault(); + } + } + + async onPostUpdate({ detail }) { + const { calendar } = window; + const { selectedMonth } = detail; + const { $year, $month } = this.$refs; + const months = await calendar.getMonths(); + + $year.textContent = selectedMonth.getFullYear(); + + $month.forEach(($el, month) => { + $el.classList.toggle( + '-current', + month === selectedMonth.getMonth(), + ); + + // eslint-disable-next-line no-param-reassign + $el.textContent = months[month]; + }); + + return this; + } + + goToday() { + dispatch(this, 'change'); + + return this; + } + + goMonth({ currentTarget }) { + dispatch(this, 'change', { set: { month: this.$refs.$month.indexOf(currentTarget) } }); + + return this; + } + + goPreviousYear() { + dispatch(this, 'change', { diff: { years: -1 } }); + + return this; + } + + goNextYear() { + dispatch(this, 'change', { diff: { years: 1 } }); + + return this; + } + + goPreviousMonth() { + dispatch(this, 'change', { diff: { months: -1 } }); + + return this; + } + + goNextMonth() { + dispatch(this, 'change', { diff: { months: 1 } }); + + return this; + } +} diff --git a/app/views/calendar/calendar-show-preferences/calendar-show-preferences.css b/app/views/calendar/calendar-show-preferences/calendar-show-preferences.css new file mode 100644 index 0000000..129ab8c --- /dev/null +++ b/app/views/calendar/calendar-show-preferences/calendar-show-preferences.css @@ -0,0 +1,48 @@ +@keyframes bounce { + 0%, 20%, 53%, 80%, 100% { + animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + transform: translate3d(0, 0, 0); + } + + 40%, 43% { + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + transform: translate3d(0, -10px, 0); + } + + 70% { + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + transform: translate3d(0, -6px, 0); + } + + 90% { + transform: translate3d(0, -2px, 0); + } +} + +:host { + display: block; +} + +.icon-dots { + display: block; + height: 6px; + overflow: visible; +} + +.icon-dots > .dot { + fill: currentColor; + opacity: 0.3; +} + +.icon-dots > .dot:nth-of-type(2) { + animation-duration: 1.25s; + animation-fill-mode: both; + animation-iteration-count: infinite; + animation-name: bounce; + animation-play-state: paused; + transform-origin: center bottom; +} + +:host(.update-downloaded) .icon-dots > .dot:nth-of-type(2) { + animation-play-state: running; +} diff --git a/app/views/calendar/calendar-show-preferences/calendar-show-preferences.js b/app/views/calendar/calendar-show-preferences/calendar-show-preferences.js new file mode 100644 index 0000000..6ef8159 --- /dev/null +++ b/app/views/calendar/calendar-show-preferences/calendar-show-preferences.js @@ -0,0 +1,26 @@ +import { createShadowRoot } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarShowPreferences extends HTMLElement { + constructor() { + super(); + + createShadowRoot(this, ` + + `); + + const { app } = window; + + app.on('update-downloaded', this.onUpdateDownloaded.bind(this)); + } + + onUpdateDownloaded() { + this.classList.add('update-downloaded'); + } +} diff --git a/app/views/calendar/calendar-today/calendar-today.css b/app/views/calendar/calendar-today/calendar-today.css new file mode 100644 index 0000000..dad74bf --- /dev/null +++ b/app/views/calendar/calendar-today/calendar-today.css @@ -0,0 +1,12 @@ +:host { + display: block; + text-align: center; +} + +.calendar-today { + display: block; + font-size: calc(var(--type-size) * 2.5); + font-weight: 100; + padding: calc(var(--grid-gap) * 2.5) var(--grid-gap); + white-space: pre; +} diff --git a/app/views/calendar/calendar-today/calendar-today.js b/app/views/calendar/calendar-today/calendar-today.js new file mode 100644 index 0000000..a65d1ef --- /dev/null +++ b/app/views/calendar/calendar-today/calendar-today.js @@ -0,0 +1,38 @@ +import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarToday extends HTMLElement { + constructor() { + super(); + + upgrade(this, ` + + `); + + const { preferences } = window; + + preferences.on('changed', this.onPreferencesChanged.bind(this)); + + this.render(); + } + + onPreferencesChanged(event, key, value) { + if (key !== 'calendarTodayFormat') { + return; + } + + this.render(value); + } + + async render(format) { + const { calendar, preferences } = window; + + this.innerHTML = await calendar.getDate({ + format: (format || await preferences.get('calendarTodayFormat')).replace(/\n/g, '\'
\''), + }); + + dispatch(this, 'postrender'); + } +} diff --git a/app/views/calendar/calendar-view/calendar-view.css b/app/views/calendar/calendar-view/calendar-view.css new file mode 100644 index 0000000..bf42dce --- /dev/null +++ b/app/views/calendar/calendar-view/calendar-view.css @@ -0,0 +1,47 @@ +:host { + display: grid; + grid-template-areas: "background" "legend" "month"; + padding-bottom: var(--grid-gap); + position: relative; + user-select: none; + width: 340px; +} + +calendar-background { + -webkit-app-region: drag; +} + +calendar-legend { + padding: 14px var(--grid-gap) 22px; +} + +calendar-month { + padding: 0 var(--grid-gap); +} + +calendar-background { grid-area: background; } +calendar-legend { grid-area: legend; } +calendar-month { grid-area: month; } + +calendar-show-preferences { + padding: 18px 12px; + position: absolute; + right: 12px; + top: 6px; + + -webkit-app-region: no-drag; +} + +calendar-navigation { + inset: 0; + position: absolute; +} + +calendar-navigation[hidden] { + opacity: 0; + pointer-events: none; +} + +calendar-navigation:not([hidden]) { + -webkit-app-region: no-drag; +} diff --git a/app/views/calendar/calendar-view/calendar-view.js b/app/views/calendar/calendar-view/calendar-view.js new file mode 100644 index 0000000..d4c0f14 --- /dev/null +++ b/app/views/calendar/calendar-view/calendar-view.js @@ -0,0 +1,118 @@ +import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class CalendarView extends HTMLElement { + constructor() { + super(); + + upgrade(this, ` + + `); + + const { calendar, preferences } = window; + + calendar.on('hide', this.onTodayClicked.bind(this)); + preferences.on('changed', this.onPreferencesChanged.bind(this)); + + setInterval(this.update.bind(this), 1000); + + this.selectedMonth = null; + this.lastUpdate = null; + + this.onTodayClicked(); + } + + async update(force = false) { + const { selectedMonth } = this; + const { calendar } = window; + const now = await calendar.getDate(); + const isFirstTime = this.lastUpdate === null; + const isSameHour = await calendar.isSameHour(this.lastUpdate || now, now); + + if (force === false && isFirstTime === false && isSameHour) { + return; + } + + this.lastUpdate = now; + + dispatch(window, 'postupdate', { selectedMonth }); + } + + onKeyDown(e) { + const { key, metaKey } = e; + const { app, calendar, preferences } = window; + const { $navigation } = this.$refs; + + if (key === 'Escape') { + if ($navigation.hidden) { + calendar.hide(); + } else { + $navigation.hidden = true; + } + } else if (key === 'w' && metaKey === false) { + this.onToggle({ weeks: true }); + } else if (key === ',' && metaKey) { + preferences.show(); + } else if (key === 'q' && metaKey) { + app.quit(); + } + } + + onPreferencesChanged() { + this.update(true); + } + + onToggle({ weeks }) { + const { $month } = this.$refs; + + if (weeks) { + $month.classList.toggle('show-weeks'); + } + + this.onPostRender(); + } + + onPostRender() { + const { app } = window; + + app.resizeWindow({ + width: this.offsetWidth, + height: this.offsetHeight, + }); + } + + onShowPreferences() { + const { preferences } = window; + + preferences.show(); + + return this; + } + + onTodayClicked() { + this.onChange(); + } + + onLegendClicked() { + this.$refs.$navigation.hidden = false; + } + + async onChange({ detail } = {}) { + const { calendar } = window; + + this.selectedMonth = await calendar.getDate(detail ? { date: this.selectedMonth, ...detail } : {}); + this.update(true); + + if (detail?.diff?.years === undefined) { + this.$refs.$navigation.hidden = true; + } + } +} diff --git a/app/views/calendar/calendar.html b/app/views/calendar/calendar.html new file mode 100644 index 0000000..322cbe8 --- /dev/null +++ b/app/views/calendar/calendar.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/views/calendar/ipc.js b/app/views/calendar/ipc.js new file mode 100644 index 0000000..e511f23 --- /dev/null +++ b/app/views/calendar/ipc.js @@ -0,0 +1,27 @@ +const { ipcRenderer } = require('electron'); + +const CALENDAR_GET_CALENDAR = 'calendar.get-calendar'; +const CALENDAR_GET_DATE = 'calendar.get-date'; +const CALENDAR_GET_MONTHS = 'calendar.get-months'; +const CALENDAR_HIDE = 'calendar.hide'; +const CALENDAR_IS_SAME_HOUR = 'calendar.is-same-hour'; +const CALENDAR_SHOW = 'calendar.show'; + +module.exports = { + CALENDAR_GET_CALENDAR, + CALENDAR_GET_DATE, + CALENDAR_GET_MONTHS, + CALENDAR_HIDE, + CALENDAR_IS_SAME_HOUR, + CALENDAR_SHOW, + + api: { + getCalendar: (payload) => ipcRenderer.invoke(CALENDAR_GET_CALENDAR, payload), + getDate: (payload) => ipcRenderer.invoke(CALENDAR_GET_DATE, payload), + getMonths: () => ipcRenderer.invoke(CALENDAR_GET_MONTHS), + hide: () => ipcRenderer.send(CALENDAR_HIDE), + isSameHour: (...payload) => ipcRenderer.invoke(CALENDAR_IS_SAME_HOUR, ...payload), + on: (channel, fn) => ipcRenderer.on(`calendar.${channel}`, fn), + show: () => ipcRenderer.send(CALENDAR_SHOW), + }, +}; diff --git a/app/views/calendar/preload.js b/app/views/calendar/preload.js new file mode 100644 index 0000000..d5164a9 --- /dev/null +++ b/app/views/calendar/preload.js @@ -0,0 +1,9 @@ +const { contextBridge } = require('electron'); + +const { api: app } = require('../../ipc'); +const { api: preferences } = require('../preferences/ipc'); +const { api: calendar } = require('./ipc'); + +contextBridge.exposeInMainWorld('app', app); +contextBridge.exposeInMainWorld('calendar', calendar); +contextBridge.exposeInMainWorld('preferences', preferences); diff --git a/app/views/calendar/renderer.js b/app/views/calendar/renderer.js new file mode 100644 index 0000000..1c42239 --- /dev/null +++ b/app/views/calendar/renderer.js @@ -0,0 +1,15 @@ +import CalendarBackground from './calendar-background/calendar-background.js'; +import CalendarLegend from './calendar-legend/calendar-legend.js'; +import CalendarMonth from './calendar-month/calendar-month.js'; +import CalendarNavigation from './calendar-navigation/calendar-navigation.js'; +import CalendarShowPreferences from './calendar-show-preferences/calendar-show-preferences.js'; +import CalendarToday from './calendar-today/calendar-today.js'; +import CalendarView from './calendar-view/calendar-view.js'; + +customElements.define('calendar-background', CalendarBackground); +customElements.define('calendar-legend', CalendarLegend); +customElements.define('calendar-month', CalendarMonth); +customElements.define('calendar-navigation', CalendarNavigation); +customElements.define('calendar-show-preferences', CalendarShowPreferences); +customElements.define('calendar-today', CalendarToday); +customElements.define('calendar-view', CalendarView); diff --git a/app/views/common/translation-key/translation-key.js b/app/views/common/translation-key/translation-key.js new file mode 100644 index 0000000..5670cc7 --- /dev/null +++ b/app/views/common/translation-key/translation-key.js @@ -0,0 +1,24 @@ +import { dispatch } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class TranslationKey extends HTMLElement { + constructor() { + super(); + + this.originalKey = this.textContent; + this.onPostUpdate(); + + window.addEventListener('postupdate', this.onPostUpdate.bind(this)); + } + + async onPostUpdate() { + const { app } = window; + + if (this.hasAttribute('markdown')) { + this.innerHTML = await app.translate(this.originalKey, { markdown: true }); + } else { + this.textContent = await app.translate(this.originalKey); + } + + dispatch(this, 'postrender'); + } +} diff --git a/app/views/preferences/ipc.js b/app/views/preferences/ipc.js new file mode 100644 index 0000000..16f8bf8 --- /dev/null +++ b/app/views/preferences/ipc.js @@ -0,0 +1,32 @@ +const { ipcRenderer } = require('electron'); + +const PREFERENCES_CHANGED = 'preferences.changed'; +const PREFERENCES_GET = 'preferences.get'; +const PREFERENCES_GET_ALL = 'preferences.getAll'; +const PREFERENCES_GET_BACKGROUND_FILE_CONTENTS = 'preferences.get-background-file-contents'; +const PREFERENCES_GET_BACKGROUNDS = 'preferences.get-backgrounds'; +const PREFERENCES_HIDE = 'preferences.hide'; +const PREFERENCES_SET = 'preferences.set'; +const PREFERENCES_SHOW = 'preferences.show'; + +module.exports = { + PREFERENCES_CHANGED, + PREFERENCES_GET, + PREFERENCES_GET_ALL, + PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, + PREFERENCES_GET_BACKGROUNDS, + PREFERENCES_HIDE, + PREFERENCES_SET, + PREFERENCES_SHOW, + + api: { + get: (key) => ipcRenderer.invoke(PREFERENCES_GET, key), + getAll: () => ipcRenderer.invoke(PREFERENCES_GET_ALL), + getBackgroundFileContents: (payload) => ipcRenderer.invoke(PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, payload), + getBackgrounds: () => ipcRenderer.invoke(PREFERENCES_GET_BACKGROUNDS), + hide: () => ipcRenderer.send(PREFERENCES_HIDE), + on: (channel, fn) => ipcRenderer.on(`preferences.${channel}`, fn), + set: (key, value) => ipcRenderer.send(PREFERENCES_SET, key, value), + show: () => ipcRenderer.send(PREFERENCES_SHOW), + }, +}; diff --git a/app/views/preferences/preferences-view/preferences-view.css b/app/views/preferences/preferences-view/preferences-view.css new file mode 100644 index 0000000..853855a --- /dev/null +++ b/app/views/preferences/preferences-view/preferences-view.css @@ -0,0 +1,126 @@ +:host { + display: block; + user-select: none; + width: 720px; +} + +translation-key a { + color: var(--color-brand); +} + +.preferences-view { + display: grid; + grid-template-areas: "side alert" "side contents"; + grid-template-columns: 192px 1fr; + grid-template-rows: min-content 1fr; +} + +.preferences-view > .side { grid-area: side; } +.preferences-view > .alert { grid-area: alert; } +.preferences-view > .contents { grid-area: contents; } + +.preferences-view > .alert, +.preferences-view > .contents { + background-color: var(--color-background-primary); +} + +.preferences-view > .alert { + border-bottom: 1px solid var(--color-line); +} + +.preferences-view > .alert.-hidden { + display: none; +} + +.preferences-side { + box-shadow: inset -1px 0 0 var(--color-line); + display: grid; + grid-template-rows: repeat(3, min-content); + padding: var(--grid-gap); + + -webkit-app-region: drag; +} + +.preferences-side > .logo { + margin: calc(var(--grid-gap) * 2) auto var(--grid-gap); + width: 128px; +} + +.preferences-side > .name { + font-size: calc(var(--type-size) * 1.5); + font-weight: 300; + margin: 0; + text-align: center; +} + +.preferences-side > .navigation { + margin: calc(var(--grid-gap) * 2) 0; + + -webkit-app-region: no-drag; +} + +.preferences-side > .about { + align-self: flex-end; + color: var(--color-text-shy); + font-size: var(--shy-size); +} + +.preferences-navigation { + display: grid; + grid-row-gap: calc(var(--grid-gap) / 3); +} + +.preferences-navigation > .item { + align-items: center; + appearance: none; + background: transparent; + border: 0; + color: currentColor; + display: grid; + font: inherit; + grid-column-gap: calc(var(--grid-gap) / 3); + grid-template-columns: min-content 1fr; + outline: 0; + padding: 4px 8px; + position: relative; + text-align: left; +} + +.preferences-navigation > .item.-active::before { + background-color: var(--color-text-primary); + border-radius: 5px; + content: ""; + height: 100%; + left: 0; + opacity: 0.14; + position: absolute; + top: 0; + width: 100%; + z-index: -1; +} + +.preferences-navigation > .item > .icon { + height: 14px; + stroke-width: 1.5; + width: 14px; +} + +.preferences-contents { + display: grid; +} + +.preferences-contents > .content { + align-content: flex-start; + display: grid; + grid-area: 1 / 1 / -1 / -1; + grid-row-gap: var(--grid-gap); + padding: calc(var(--grid-gap) * 2); +} + +.preferences-contents > .content:not(.-active) { + display: none; +} + +.preferences-contents > .content > .description { + color: var(--color-text-shy); +} diff --git a/app/views/preferences/preferences-view/preferences-view.js b/app/views/preferences/preferences-view/preferences-view.js new file mode 100644 index 0000000..3415576 --- /dev/null +++ b/app/views/preferences/preferences-view/preferences-view.js @@ -0,0 +1,196 @@ +import { upgrade } from '../../../../node_modules/@browserkids/dom/index.js'; + +export default class PreferencesView extends HTMLElement { + constructor() { + super(); + + const { app } = window; + + upgrade(this, ` + + `); + + this.render(); + + app.on('update-downloaded', this.onUpdateDownloaded.bind(this)); + } + + async render() { + const { app, preferences } = window; + + const { + $form, + $backgrounds, + $keys, + $tab, + } = this.$refs; + + const all = await preferences.getAll(); + const backgrounds = await preferences.getBackgrounds(); + const shortcuts = await app.translate('preferences.shortcuts.keys'); + + backgrounds + .map((background) => ([background, background.split('/').pop().split('.').shift()])) + .map(([value, name]) => ``) + .forEach((background) => $backgrounds.insertAdjacentHTML('beforeend', background)); + + shortcuts + .map(([keys, label]) => ([keys.split('+').map((key) => `${key}`).join('+'), label])) + .map(([keys, label]) => `
${keys}
${label}
`) + .forEach((shortcut) => $keys.insertAdjacentHTML('beforeend', shortcut)); + + Array + .from(all) + .filter(([key]) => $form[key]) + .forEach(([key, value]) => { + $form[key][typeof value === 'boolean' ? 'checked' : 'value'] = value; + }); + + this.onCategoryClicked({ currentTarget: $tab[0] }); + } + + onInput({ target }) { + const { + name, + value, + type, + checked, + } = target; + + const { preferences } = window; + const isBoolean = ['on', 'off'].indexOf(value) >= 0 && ['checkbox', 'radio'].indexOf(type) >= 0; + + preferences.set(name, isBoolean ? checked : value); + + return this; + } + + onCategoryClicked({ currentTarget }) { + const { $content, $tab } = this.$refs; + const index = $tab.indexOf(currentTarget); + const toggleActive = ($el, position) => $el.classList.toggle('-active', index === position); + + [$tab, $content].forEach(($el) => $el.forEach(toggleActive)); + + this.onPostRender(); + } + + onKeyDown({ key }) { + const { preferences } = window; + + if (key === 'Escape') { + preferences.hide(); + } + + return this; + } + + onRestartClicked() { + const { app } = window; + + app.restart(); + + return this; + } + + onUpdateDownloaded() { + this.$refs.$alert.classList.remove('-hidden'); + this.onPostRender(); + } + + onPostRender() { + const { app } = window; + + app.resizeWindow({ + width: this.offsetWidth, + height: this.offsetHeight, + }); + } +} diff --git a/app/views/preferences/preferences.html b/app/views/preferences/preferences.html new file mode 100644 index 0000000..944a4cb --- /dev/null +++ b/app/views/preferences/preferences.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/views/preferences/preload.js b/app/views/preferences/preload.js new file mode 100644 index 0000000..bdee323 --- /dev/null +++ b/app/views/preferences/preload.js @@ -0,0 +1,7 @@ +const { contextBridge } = require('electron'); + +const { api: app } = require('../../ipc'); +const { api: preferences } = require('./ipc'); + +contextBridge.exposeInMainWorld('app', app); +contextBridge.exposeInMainWorld('preferences', preferences); diff --git a/app/views/preferences/renderer.js b/app/views/preferences/renderer.js new file mode 100644 index 0000000..f963ffb --- /dev/null +++ b/app/views/preferences/renderer.js @@ -0,0 +1,5 @@ +import PreferencesView from './preferences-view/preferences-view.js'; +import TranslationKey from '../common/translation-key/translation-key.js'; + +customElements.define('preferences-view', PreferencesView); +customElements.define('translation-key', TranslationKey); diff --git a/build/_icon.icns b/build/_icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..02ce72c4418f763e91419c2ad251344902cebe0c GIT binary patch literal 285658 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