first commit

This commit is contained in:
LittleBoy 2021-10-03 09:58:17 +08:00
commit 4b87b35467
77 changed files with 10015 additions and 0 deletions

15
.editorconfig Normal file
View File

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

22
.eslintrc Normal file
View File

@ -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"]
}
}

289
.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

6
.idea/jsLinters/eslint.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/timestamp.iml" filepath="$PROJECT_DIR$/.idea/timestamp.iml" />
</modules>
</component>
</project>

10
.idea/timestamp.iml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/dist" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

15
CHANGELOG.md Normal file
View File

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

21
LICENSE Normal file
View File

@ -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.

45
README.md Normal file
View File

@ -0,0 +1,45 @@
<h1 align="center">
<img src="https://mzdr.github.io/timestamp/icon.svg" width="128" alt="Logo of Timestamp">
<p>Timestamp</p>
<a href="https://github.com/mzdr/timestamp/releases/latest"><img src="https://img.shields.io/github/release/mzdr/timestamp.svg?maxAge=3600" alt="Latest Timestamp release"></a>
</h1>
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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"></svg>

After

Width:  |  Height:  |  Size: 47 B

View File

@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 196" preserveAspectRatio="xMidYMax meet">
<style>
/*
Thanks to Claudio Guglieri and Peter Bork.
https://codepen.io/bork/details/wJhEm
https://dribbble.com/shots/2644219-Colorscape-Color-study
*/
.wave { fill: var(--color-background-primary, #fff); }
.wave-start { stop-color: var(--color-background-primary, #fff); }
.wave-stop { stop-color: var(--color-background-primary, #fff); }
:host {
background-image: linear-gradient(var(--deg), var(--start), var(--stop));
color: var(--palette-white, #fff);
}
:host([data-hour="0"]) { --deg: 180deg; --start: #001322; --stop: #012459; }
:host([data-hour="1"]) { --deg: 195deg; --start: #001322; --stop: #012459; }
:host([data-hour="2"]) { --deg: 210deg; --start: #001322; --stop: #012459; }
:host([data-hour="3"]) { --deg: 225deg; --start: #001322; --stop: #012459; }
:host([data-hour="4"]) { --deg: 240deg; --start: #001322; --stop: #003972; }
:host([data-hour="5"]) { --deg: 255deg; --start: #00182b; --stop: #016792; }
:host([data-hour="6"]) { --deg: 270deg; --start: #042c47; --stop: #07729f; }
:host([data-hour="7"]) { --deg: 285deg; --start: #07506e; --stop: #12a1c0; }
:host([data-hour="8"]) { --deg: 300deg; --start: #1386a6; --stop: #74d4cc; }
:host([data-hour="9"]) { --deg: 315deg; --start: #efeebc; --stop: #61d0cf; color: var(--palette-black); }
:host([data-hour="10"]) { --deg: 330deg; --start: #fee154; --stop: #a3dec6; color: var(--palette-black); }
:host([data-hour="11"]) { --deg: 345deg; --start: #fdc352; --stop: #e8ed92; color: var(--palette-black); }
:host([data-hour="12"]) { --deg: 0deg; --start: #ff8d27; --stop: #ffe577; }
:host([data-hour="13"]) { --deg: 15deg; --start: #ff7e20; --stop: #ffda35; }
:host([data-hour="14"]) { --deg: 30deg; --start: #ff8d27; --stop: #ffe577; }
:host([data-hour="15"]) { --deg: 45deg; --start: #f18448; --stop: #ffd364; }
:host([data-hour="16"]) { --deg: 60deg; --start: #f06b7e; --stop: #f9a856; }
:host([data-hour="17"]) { --deg: 75deg; --start: #ca5a92; --stop: #f4896b; }
:host([data-hour="18"]) { --deg: 90deg; --start: #5b2c83; --stop: #d1628b; }
:host([data-hour="19"]) { --deg: 105deg; --start: #371a79; --stop: #713684; }
:host([data-hour="20"]) { --deg: 120deg; --start: #28166b; --stop: #45217c; }
:host([data-hour="21"]) { --deg: 135deg; --start: #192861; --stop: #372074; }
:host([data-hour="22"]) { --deg: 150deg; --start: #040b3c; --stop: #233072; }
:host([data-hour="23"]) { --deg: 165deg; --start: #040b3c; --stop: #012459; }
</style>
<defs>
<linearGradient id="wave" gradientTransform="rotate(90)">
<stop class="wave-start" />
<stop class="wave-stop" stop-opacity="0.3" offset="1" />
</linearGradient>
</defs>
<path fill="url(#gradient)" d="M0 0h256v196H0z" />
<path fill="url(#wave)" d="M0 168.5c66.5-9 174.582 28.729 256 11.907V196H0v-27.5z" />
<path class="wave" d="M0 183.5c67.5 18.5 153-43 256 8.5v4H0v-12.5z" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"></path></svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>

After

Width:  |  Height:  |  Size: 992 B

43
app/assets/logo.svg Normal file
View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient x1="22.447823%" y1="0%" x2="22.447823%" y2="100%" id="linearGradient-1">
<stop stop-color="#000000" stop-opacity="0" offset="0%"></stop>
<stop stop-color="#000000" offset="100%"></stop>
</linearGradient>
<linearGradient x1="0%" y1="50%" x2="75.4839487%" y2="50%" id="linearGradient-2">
<stop stop-color="#000000" offset="0%"></stop>
<stop stop-color="#008BED" stop-opacity="0" offset="100%"></stop>
</linearGradient>
<polygon id="path-3" points="358.999994 895.999994 179 895.999994 179 716 538.999963 715.999994"></polygon>
<linearGradient x1="0%" y1="50%" x2="74.7274382%" y2="50%" id="linearGradient-4">
<stop stop-color="#000000" offset="0%"></stop>
<stop stop-color="#008BED" stop-opacity="0" offset="100%"></stop>
</linearGradient>
<polygon id="path-5" points="359.199994 538.399994 179.2 538.399994 179.2 358.4 539.199963 358.399994"></polygon>
<linearGradient x1="0%" y1="0%" x2="100%" y2="100%" id="linearGradient-6">
<stop stop-color="#FFFFFF" offset="0%"></stop>
<stop stop-color="#2195F2" stop-opacity="0" offset="100%"></stop>
</linearGradient>
<polygon id="path-7" points="179.2 2.67351757e-14 179.200012 896 0 716.799988 0 0"></polygon>
</defs>
<g id="Timestamp" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="#3">
<polygon id="shadow" fill-opacity="0.300000012" fill="url(#linearGradient-1)" points="932.977759 845 602.264373 959.791757 422.200012 960 447.833499 845.178208"></polygon>
<g id="t" transform="translate(243.000000, 64.000000)">
<g id="bottom-line">
<use fill="#2195F2" xlink:href="#path-3"></use>
<use fill="url(#linearGradient-2)" style="mix-blend-mode: soft-light;" xlink:href="#path-3"></use>
</g>
<g id="upper-line">
<use fill="#2195F2" xlink:href="#path-5"></use>
<use fill="url(#linearGradient-4)" style="mix-blend-mode: soft-light;" xlink:href="#path-5"></use>
</g>
<g id="body">
<use fill="#2195F2" xlink:href="#path-7"></use>
<use fill="url(#linearGradient-6)" style="mix-blend-mode: soft-light;" xlink:href="#path-7"></use>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

154
app/components/Calendar.js Normal file
View File

@ -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;

39
app/components/Clock.js Normal file
View File

@ -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;

40
app/components/Locale.js Normal file
View File

@ -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;

63
app/components/Logger.js Normal file
View File

@ -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;

View File

@ -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 its 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(`Couldnt 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;

View File

@ -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;

83
app/components/Updater.js Normal file
View File

@ -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('Couldnt 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;

120
app/components/Window.js Normal file
View File

@ -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;

156
app/index.js Normal file
View File

@ -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);
}
}();
})();

28
app/ipc.js Normal file
View File

@ -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),
},
};

50
app/locales/de.js Normal file
View File

@ -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'],
],
},
},
};

50
app/locales/en.js Normal file
View File

@ -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'],
],
},
},
};

7
app/locales/index.js Normal file
View File

@ -0,0 +1,7 @@
const de = require('./de');
const en = require('./en');
module.exports = {
de,
en,
};

11
app/paths.js Normal file
View File

@ -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'),
};

View File

@ -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);
}

View File

@ -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; }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

4
app/styles/meta/fx.css Normal file
View File

@ -0,0 +1,4 @@
:root {
--fx-duration: 300ms;
--fx-radius: 15px;
}

3
app/styles/meta/grid.css Normal file
View File

@ -0,0 +1,3 @@
:root {
--grid-gap: 22px;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

7
app/styles/styles.css Normal file
View File

@ -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";

View File

@ -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;
}

View File

@ -0,0 +1,54 @@
import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarBackground extends HTMLElement {
constructor() {
super();
upgrade(this, `
<template>
<link rel="stylesheet" href="calendar-background/calendar-background.css">
<div @postupdate.window="onPostUpdate" class="calendar-background">
<figure class="image" #$content></figure>
<slot class="slot"></slot>
</div>
</template>
`);
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;
}
}

View File

@ -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);
}

View File

@ -0,0 +1,27 @@
import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarLegend extends HTMLElement {
constructor() {
super();
upgrade(this, `
<template>
<link rel="stylesheet" href="calendar-legend/calendar-legend.css">
<span class="calendar-legend" #$content @postupdate.window="onPostUpdate">&nbsp;</span>
</template>
`);
}
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');
}
}

View File

@ -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);
}

View File

@ -0,0 +1,49 @@
import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarMonth extends HTMLElement {
constructor() {
super();
upgrade(this, `
<template>
<link rel="stylesheet" href="calendar-month/calendar-month.css">
<div class="calendar-month" #$content @postupdate.window="onPostUpdate"></div>
</template>
`);
}
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) => `<span class="weekday">${weekday}</span>`).join('');
const $weeks = weeks.map((week) => `<span class="week">${week}</span>`).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 `<span class="${cssClass}">${day.getDate()}</span>`;
}).join('');
$content.innerHTML = `
<div class="no">#</div>
<div class="weekdays">${$weekdays}</div>
<div class="weeks">${$weeks}</div>
<div class="days">${$days}</div>
`;
dispatch(this, 'postrender');
}
}

View File

@ -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;
}

View File

@ -0,0 +1,127 @@
import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarNavigation extends HTMLElement {
constructor() {
super();
upgrade(this, `
<template>
<link rel="stylesheet" href="calendar-navigation/calendar-navigation.css">
<div
class="calendar-navigation"
@keydown.window="onKeyDown"
@postupdate.window="onPostUpdate"
>
<div class="year">
<button class="go-to" @click="goPreviousYear">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" class="icon-chevron">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 010 .708L5.707 8l5.647 5.646a.5.5 0 01-.708.708l-6-6a.5.5 0 010-.708l6-6a.5.5 0 01.708 0z"/>
</svg>
</button>
<strong class="go-to -year" #$year></strong>
<button class="go-to" @click="goNextYear">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" class="icon-chevron">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 01.708 0l6 6a.5.5 0 010 .708l-6 6a.5.5 0 01-.708-.708L10.293 8 4.646 2.354a.5.5 0 010-.708z"/>
</svg>
</button>
</div>
<div class="month">
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
<button #$month class="go-to" @click="goMonth"></button>
</div>
</div>
</template>
`);
}
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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,26 @@
import { createShadowRoot } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarShowPreferences extends HTMLElement {
constructor() {
super();
createShadowRoot(this, `
<template>
<link rel="stylesheet" href="calendar-show-preferences/calendar-show-preferences.css">
<svg class="icon-dots" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 6">
<circle class="dot" cx="21" cy="3" r="3" />
<circle class="dot" cx="12" cy="3" r="3" />
<circle class="dot" cx="3" cy="3" r="3" />
</svg>
</template>
`);
const { app } = window;
app.on('update-downloaded', this.onUpdateDownloaded.bind(this));
}
onUpdateDownloaded() {
this.classList.add('update-downloaded');
}
}

View File

@ -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;
}

View File

@ -0,0 +1,38 @@
import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarToday extends HTMLElement {
constructor() {
super();
upgrade(this, `
<template>
<link rel="stylesheet" href="calendar-today/calendar-today.css">
<slot class="calendar-today">&nbsp;</slot>
</template>
`);
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, '\'<br>\''),
});
dispatch(this, 'postrender');
}
}

View File

@ -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;
}

View File

@ -0,0 +1,118 @@
import { dispatch, upgrade } from '../../../../node_modules/@browserkids/dom/index.js';
export default class CalendarView extends HTMLElement {
constructor() {
super();
upgrade(this, `
<template>
<link rel="stylesheet" href="calendar-view/calendar-view.css" @keydown.window="onKeyDown" />
<calendar-background @postrender="onPostRender">
<calendar-today @postrender="onPostRender" @click="onTodayClicked"></calendar-today>
<calendar-show-preferences @click="onShowPreferences"></calendar-show-preferences>
</calendar-background>
<calendar-legend @click="onLegendClicked" @postrender="onPostRender"></calendar-legend>
<calendar-month #$month @postrender="onPostRender"></calendar-month>
<calendar-navigation #$navigation @change="onChange" hidden></calendar-navigation>
</template>
`);
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;
}
}
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'">
<link rel="stylesheet" href="../../styles/styles.css">
</head>
<body>
<calendar-view></calendar-view>
<script src="./renderer.js" type="module"></script>
</body>
</html>

27
app/views/calendar/ipc.js Normal file
View File

@ -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),
},
};

View File

@ -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);

View File

@ -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);

View File

@ -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');
}
}

View File

@ -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),
},
};

View File

@ -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);
}

View File

@ -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, `
<template>
<link rel="stylesheet" href="../../styles/components/button-primary.css">
<link rel="stylesheet" href="../../styles/components/container-alert.css">
<link rel="stylesheet" href="../../styles/components/form-group.css">
<link rel="stylesheet" href="../../styles/components/list-shortcuts.css">
<link rel="stylesheet" href="preferences-view/preferences-view.css">
<div class="preferences-view">
<aside class="preferences-side side">
<img class="logo" src="../../assets/logo.svg" alt="">
<h1 class="name">${app.productName}</h1>
<nav class="navigation preferences-navigation">
<button #$tab class="item" @click="onCategoryClicked">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<translation-key class="title">preferences.category.general</translation-key>
</button>
<button #$tab class="item" @click="onCategoryClicked">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<translation-key class="title">preferences.category.tray</translation-key>
</button>
<button #$tab class="item" @click="onCategoryClicked">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<translation-key class="title">preferences.category.calendar</translation-key>
</button>
<button #$tab class="item" @click="onCategoryClicked">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"></path></svg>
<translation-key class="title">preferences.category.shortcuts</translation-key>
</button>
</nav>
<section class="about preferences-about">v${app.version}</section>
</aside>
<section #$alert class="alert -hidden container-alert">
<span class="icon">🎉</span>
<translation-key>app.updateDownloaded</translation-key>
<div class="actions">
<button @click="onRestartClicked" class="button-primary">
<translation-key>app.restart</translation-key>
</button>
</div>
</section>
<form
#$form
@input="onInput"
@postrender="onPostRender"
@keydown.window="onKeyDown"
class="contents preferences-contents"
>
<section class="content" #$content>
<label class="form-group">
<input type="checkbox" name="openAtLogin" class="action -toggle">
<translation-key class="label">preferences.openAtLogin.label</translation-key>
<translation-key class="description">preferences.openAtLogin.description</translation-key>
</label>
</section>
<section class="content" #$content>
<label class="form-group">
<input type="text" name="clockFormat" class="action -text" size="12">
<translation-key class="label">preferences.clockFormat.label</translation-key>
<translation-key class="description" markdown>preferences.clockFormat.description</translation-key>
</label>
</section>
<section class="content" #$content>
<label class="form-group">
<select #$backgrounds name="calendarBackground" class="action -select"></select>
<translation-key class="label">preferences.calendarBackground.label</translation-key>
<translation-key class="description">preferences.calendarBackground.description</translation-key>
</label>
<label class="form-group">
<input type="text" name="calendarLegendFormat" class="action -text" size="8">
<translation-key class="label">preferences.calendarLegendFormat.label</translation-key>
<translation-key class="description" markdown>preferences.calendarLegendFormat.description</translation-key>
</label>
<label class="form-group">
<textarea name="calendarTodayFormat" class="action -text" cols="10"></textarea>
<translation-key class="label">preferences.calendarTodayFormat.label</translation-key>
<translation-key class="description" markdown>preferences.calendarTodayFormat.description</translation-key>
</label>
</section>
<section class="content" #$content>
<translation-key class="description">preferences.shortcuts.description</translation-key>
<dl class="list-shortcuts" #$keys></dl>
</section>
</form>
</div>
</template>
`);
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]) => `<option value="${value}">${name}</option>`)
.forEach((background) => $backgrounds.insertAdjacentHTML('beforeend', background));
shortcuts
.map(([keys, label]) => ([keys.split('+').map((key) => `<span class="key">${key}</span>`).join('+'), label]))
.map(([keys, label]) => `<dt class="keys">${keys}</dt><dd class="label">${label}</dd>`)
.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,
});
}
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html class="is-native -transparent">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<link rel="stylesheet" href="../../styles/styles.css">
</head>
<body>
<preferences-view></preferences-view>
<script src="./renderer.js" type="module"></script>
</body>
</html>

View File

@ -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);

View File

@ -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);

BIN
build/_icon.icns Normal file

Binary file not shown.

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>

BIN
build/icon.icns Normal file

Binary file not shown.

4155
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View File

@ -0,0 +1,65 @@
{
"name": "timestamp",
"productName": "Timestamp",
"description": "A better macOS menu bar clock with a customizable date/time display and a calendar.",
"version": "1.1.0",
"author": "Sebastian Prein <hi@sebastianprein.com>",
"copyright": "© 2021 Sebastian Prein",
"homepage": "https://github.com/mzdr/timestamp",
"license": "MIT",
"main": "app/index.js",
"keywords": [
"calendar",
"clock",
"customizable",
"date",
"electron",
"macos",
"menubar",
"time",
"timestamp"
],
"build": {
"appId": "com.mzdr.timestamp",
"files": [
"app/**/*",
"node_modules/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.utilities",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"darkModeSupport": true
},
"afterSign": "scripts/notarize.js"
},
"private": true,
"scripts": {
"build": "electron-builder",
"clean": "rimraf dist",
"lint": "eslint ./app",
"lint:fix": "npm run lint -- --fix",
"prebuild": "npm run lint && npm run clean",
"start": "electron ./app --enable-logging",
"test": "npm run lint"
},
"dependencies": {
"@browserkids/dom": "^0.5.0",
"date-fns": "^2.23.0",
"marked": "^2.1.3",
"semver": "^7.3.5"
},
"devDependencies": {
"dotenv": "^10.0.0",
"electron": "^13.1.7",
"electron-builder": "^22.11.7",
"electron-notarize": "^1.0.1",
"eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-plugin-import": "^2.23.4",
"rimraf": "^3.0.2"
}
}

28
scripts/notarize.js Normal file
View File

@ -0,0 +1,28 @@
// eslint-disable-next-line import/no-extraneous-dependencies
require('dotenv').config();
// eslint-disable-next-line import/no-extraneous-dependencies
const { notarize } = require('electron-notarize');
const { build } = require('../package.json');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir, packager } = context;
const isUnpacked = process.argv.includes('--dir');
const isMacOs = electronPlatformName === 'darwin';
if (isUnpacked || isMacOs === false) {
return;
}
const appBundleId = build.appId;
const appPath = `${appOutDir}/${packager.appInfo.productFilename}.app`;
const appleId = process.env.APPLE_ID;
const appleIdPassword = process.env.APPLE_ID_PASSWORD;
await notarize({
appBundleId,
appPath,
appleId,
appleIdPassword,
});
};

2820
yarn.lock Normal file

File diff suppressed because it is too large Load Diff