├── src ├── hooks │ ├── index.ts │ └── useIsOnline.tsx ├── sql │ ├── migration.0-to-1.sql │ ├── schema.0.sqlite.sql │ ├── schema.1.sqlite.sql │ ├── schema.2.sqlite.sql │ └── migration.1-to-2.sql ├── types │ ├── prompts.ts │ ├── index.ts │ ├── entries.ts │ ├── tags.ts │ └── subscriptions.ts ├── context │ └── index.ts ├── config │ ├── autoformat │ │ ├── index.ts │ │ ├── autoformatConstants.ts │ │ ├── autoformatRules.ts │ │ ├── autoformatOperation.ts │ │ ├── autoformatUtils.ts │ │ ├── autoformatLists.ts │ │ ├── autoformatMarks.ts │ │ └── autoformatBlocks.ts │ ├── index.ts │ ├── resetBlockTypePlugin.ts │ └── UserPreferences.ts ├── utils │ ├── breakpoints.ts │ ├── logger.ts │ ├── index.ts │ ├── dates.ts │ ├── supabaseClient.ts │ ├── crypto.ts │ └── misc.ts ├── constants │ └── index.ts ├── index.html ├── components │ ├── Menu │ │ ├── Settings │ │ │ ├── Billing │ │ │ │ ├── types.ts │ │ │ │ ├── Balance.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── Receipts.tsx │ │ │ │ ├── PaymentMethod.tsx │ │ │ │ └── styled.tsx │ │ │ ├── Earn │ │ │ │ └── index.tsx │ │ │ ├── Subscribe │ │ │ │ ├── LeftPanel.tsx │ │ │ │ ├── styled.tsx │ │ │ │ ├── Success.tsx │ │ │ │ └── index.tsx │ │ │ ├── Upgrade │ │ │ │ ├── index.tsx │ │ │ │ └── Features.tsx │ │ │ ├── ChangeCycle │ │ │ │ ├── LeftPanel.tsx │ │ │ │ ├── Success.tsx │ │ │ │ ├── styled.tsx │ │ │ │ └── index.tsx │ │ │ ├── ImportExport │ │ │ │ ├── nodeTypes.ts │ │ │ │ └── index.tsx │ │ │ ├── AddCard │ │ │ │ ├── styled.tsx │ │ │ │ └── index.tsx │ │ │ ├── CancelImmediately │ │ │ │ └── styled.tsx │ │ │ └── CancelOrResume │ │ │ │ └── styled.tsx │ │ └── AppearanceToolbar │ │ │ └── styled.tsx │ ├── Icon │ │ ├── Plus.tsx │ │ ├── Menu.tsx │ │ ├── FormatMark.tsx │ │ ├── Trash.tsx │ │ ├── Edit.tsx │ │ ├── TrafficLightOutline.tsx │ │ ├── FormatItalic.tsx │ │ ├── Offline.tsx │ │ ├── FormatCode.tsx │ │ ├── BlockH1.tsx │ │ ├── FormatUnderline.tsx │ │ ├── UpdateNow.tsx │ │ ├── BlockBulletList.tsx │ │ ├── FormatBold.tsx │ │ ├── BlockH2.tsx │ │ ├── BlockText.tsx │ │ ├── TrafficLightCalendar.tsx │ │ ├── FormatStriketrough.tsx │ │ ├── Cross.tsx │ │ ├── BlockH3.tsx │ │ ├── BlockNumList.tsx │ │ ├── Exit.tsx │ │ ├── Settings.tsx │ │ ├── FormatHandStriketrough.tsx │ │ ├── Bucket.tsx │ │ ├── Check.tsx │ │ ├── Chevron.tsx │ │ └── index.tsx │ ├── index.ts │ ├── EntryList │ │ ├── styled.ts │ │ └── index.tsx │ ├── Splash.tsx │ ├── Entry │ │ ├── LimitReached.tsx │ │ ├── styled.ts │ │ ├── EntryMenu │ │ │ ├── Modal.tsx │ │ │ └── styled.tsx │ │ └── EntryAside.tsx │ ├── FadeOut.tsx │ ├── TrafficLightMenu.tsx │ ├── FormatToolbar │ │ └── BlockTypeSelectItem.tsx │ ├── ScrollToToday.tsx │ ├── FeedbackWidget │ │ └── RatingEmojiControl.tsx │ ├── EntryTags │ │ └── ListItemTagColorPicker.tsx │ ├── ConfirmationModal.tsx │ └── Login.tsx ├── renderer.tsx ├── services │ ├── mdx.ts │ ├── saveFile.ts │ ├── autoUpdater.ts │ └── analytics.ts ├── themes │ └── index.ts └── index.css ├── decs.d.ts ├── .vscode └── settings.json ├── assets ├── background.tiff ├── dmg-background.png ├── dmg-background@2x.png ├── fonts │ ├── Inter.var.woff2 │ ├── novela-bold-webfont.woff2 │ ├── novela-regular-webfont.woff2 │ ├── novela-bolditalic-webfont.woff2 │ └── novela-regularitalic-webfont.woff2 ├── icons │ ├── journaldo-logo@2x.png │ └── journal-macos-icon.icns └── images │ ├── beginning-dark@2x.png │ ├── beginning-forest@2x.png │ ├── beginning-light@2x.png │ └── beginning-cappuccino@2x.png ├── webpack.plugins.js ├── dist └── main_window │ └── index.html ├── playwright.config.ts ├── entitlements.plist ├── tests └── helpers │ ├── utils.ts │ ├── sqlite.ts │ └── supabase.ts ├── README.md ├── webpack.main.config.js ├── .eslintrc.json ├── webpack.renderer.config.js ├── tsconfig.json ├── webpack.rules.js ├── .gitignore ├── schemas ├── schema.postgresql.sql └── migration2.postgres.sql └── updates └── darwin ├── x64 └── update.json └── arm64 └── update.json /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useIsOnline' 2 | -------------------------------------------------------------------------------- /decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' 2 | declare module '*.sql' 3 | -------------------------------------------------------------------------------- /src/sql/migration.0-to-1.sql: -------------------------------------------------------------------------------- 1 | alter table users 2 | add secret_key blob; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.semi": false, 3 | "editor.formatOnSave": true 4 | } -------------------------------------------------------------------------------- /assets/background.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/background.tiff -------------------------------------------------------------------------------- /assets/dmg-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/dmg-background.png -------------------------------------------------------------------------------- /assets/dmg-background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/dmg-background@2x.png -------------------------------------------------------------------------------- /assets/fonts/Inter.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/fonts/Inter.var.woff2 -------------------------------------------------------------------------------- /assets/icons/journaldo-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/icons/journaldo-logo@2x.png -------------------------------------------------------------------------------- /assets/icons/journal-macos-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/icons/journal-macos-icon.icns -------------------------------------------------------------------------------- /assets/images/beginning-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/images/beginning-dark@2x.png -------------------------------------------------------------------------------- /assets/images/beginning-forest@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/images/beginning-forest@2x.png -------------------------------------------------------------------------------- /assets/images/beginning-light@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/images/beginning-light@2x.png -------------------------------------------------------------------------------- /assets/fonts/novela-bold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/fonts/novela-bold-webfont.woff2 -------------------------------------------------------------------------------- /src/types/prompts.ts: -------------------------------------------------------------------------------- 1 | type Prompt = { 2 | id: number 3 | title: string 4 | content: any 5 | } 6 | 7 | export { Prompt } 8 | -------------------------------------------------------------------------------- /assets/fonts/novela-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/fonts/novela-regular-webfont.woff2 -------------------------------------------------------------------------------- /assets/images/beginning-cappuccino@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/images/beginning-cappuccino@2x.png -------------------------------------------------------------------------------- /src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AppearanceContext' 2 | export * from './EntriesContext/index' 3 | export * from './UserContext' 4 | -------------------------------------------------------------------------------- /assets/fonts/novela-bolditalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/fonts/novela-bolditalic-webfont.woff2 -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './subscriptions' 2 | export * from './tags' 3 | export * from './entries' 4 | export * from './prompts' 5 | -------------------------------------------------------------------------------- /assets/fonts/novela-regularitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JournalApp/Journal/HEAD/assets/fonts/novela-regularitalic-webfont.woff2 -------------------------------------------------------------------------------- /src/config/autoformat/index.ts: -------------------------------------------------------------------------------- 1 | export * from './autoformatBlocks' 2 | export * from './autoformatUtils' 3 | export * from './autoformatRules' 4 | -------------------------------------------------------------------------------- /webpack.plugins.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 2 | 3 | module.exports = [ 4 | new ForkTsCheckerWebpackPlugin() 5 | ]; 6 | -------------------------------------------------------------------------------- /src/utils/breakpoints.ts: -------------------------------------------------------------------------------- 1 | const breakpoints = { 2 | s: '(max-width: 600px)', 3 | xs: '(max-width: 480px)', 4 | xl: '(min-width: 1560px)', 5 | } 6 | 7 | export { breakpoints } 8 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from './misc' 2 | 3 | const logger = (data: any) => { 4 | if (isDev()) { 5 | console.log(data) 6 | } 7 | } 8 | 9 | export { logger } 10 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatConstants.ts: -------------------------------------------------------------------------------- 1 | export const DIGITS: string[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'] 2 | 3 | export const DIGITS_WITH_SPACE: string[] = DIGITS.map((digit) => ` ${digit}`) 4 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './misc' 2 | export * from './dates' 3 | export * from './supabaseClient' 4 | export * from './breakpoints' 5 | export * from './crypto' 6 | export * from './logger' 7 | export * from './zipCodes' 8 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from '../utils' 2 | 3 | export const productWriterId = isDev() ? 'prod_MaCerQCQryctSx' : 'prod_Mz9pjbqvffW1in' 4 | export const entriesLimit = 30 5 | export const betaEndDate = isDev() ? '2021-10-05' : '2022-12-15' 6 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Journal 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Billing/types.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | import { BillingInfo } from 'types' 3 | 4 | interface PaymentMethodProps { 5 | billingInfo: BillingInfo 6 | isLoading: boolean 7 | showCardOnly?: boolean 8 | } 9 | 10 | export { PaymentMethodProps } 11 | -------------------------------------------------------------------------------- /src/renderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import './index.css' 4 | 5 | import { App } from './App' 6 | 7 | function renderApp() { 8 | render(, document.getElementById('app')) 9 | } 10 | 11 | document.fonts.load('12px "Inter var"').then(() => renderApp()) 12 | -------------------------------------------------------------------------------- /dist/main_window/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello World! 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig } from '@playwright/test' 2 | 3 | const config: PlaywrightTestConfig = { 4 | testDir: './tests', 5 | reporter: [['list', { printSteps: true }]], 6 | timeout: 60000, 7 | 8 | expect: { 9 | toMatchSnapshot: { threshold: 0.2 }, 10 | }, 11 | } 12 | 13 | export default config 14 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.debugger 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Earn/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled from 'styled-components' 3 | import { theme } from 'themes' 4 | import { SectionTitleStyled } from '../styled' 5 | 6 | const EarnTabContent = () => { 7 | return Earn credit 8 | } 9 | 10 | export { EarnTabContent } 11 | -------------------------------------------------------------------------------- /tests/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | export function getRecordingPath(platform: string, fileName?: string) { 4 | const paths = ['test-output', platform, 'screenshots'] 5 | if (fileName) { 6 | paths.push(fileName) 7 | } 8 | return path.join(...paths) 9 | } 10 | 11 | export async function pause(ms: number) { 12 | return new Promise((f) => setTimeout(f, ms)) 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🙋 The Journal app is looking for a maintainer who will help in transforming it into an open-source app 2 | 3 | What needs to be done: 4 | - Remove Supabase and maintain local SQLite only 5 | - Remove Stripe payments 6 | - Migrate users' encryption keys 7 | - Create a release 8 | - Make users happy 9 | 10 | Contact me at [support@journal.do](mailto:support@journal.do) or [@jarekceborski](https://x.com/jarekceborski) 11 | -------------------------------------------------------------------------------- /src/services/mdx.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import { logger, isDev } from '../utils' 3 | import { serialize } from 'next-mdx-remote/serialize' 4 | 5 | ipcMain.handle('mdx-serialize', async (event, source: string) => { 6 | logger('mdx-serialize') 7 | try { 8 | return await serialize(source) 9 | } catch (error) { 10 | logger(`error`) 11 | logger(error) 12 | return await serialize('') 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/types/entries.ts: -------------------------------------------------------------------------------- 1 | type SyncStatus = 'synced' | 'pending_insert' | 'pending_update' | 'pending_delete' 2 | 3 | type Day = `${number}-${number}-${number}` 4 | 5 | type Entry = { 6 | user_id: string 7 | day: Day 8 | journal_id?: number 9 | created_at: string 10 | modified_at: string 11 | content: any[] | string 12 | iv?: string 13 | revision?: number 14 | sync_status?: SyncStatus 15 | } 16 | 17 | export { Day, Entry } 18 | -------------------------------------------------------------------------------- /webpack.main.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * This is the main entry point for your application, it's the first file 4 | * that runs in the main process. 5 | */ 6 | entry: './src/index.ts', 7 | // Put your normal webpack config below here 8 | target: 'electron-main', 9 | module: { 10 | rules: require('./webpack.rules'), 11 | }, 12 | resolve: { 13 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json', '.sql'], 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Icon/Plus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Plus({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "semi": "off", 9 | "@typescript-eslint/semi": ["error"] 10 | }, 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:import/recommended", 16 | "plugin:import/electron", 17 | "plugin:import/typescript" 18 | ], 19 | "parser": "@typescript-eslint/parser" 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Icon/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Menu({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Icon/FormatMark.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatMark({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Entry' 2 | export * from './EntryTags' 3 | export * from './Icon' 4 | export * from './Calendar' 5 | export * from './FormatToolbar' 6 | export * from './EntryList' 7 | export * from './ContextMenu' 8 | export * from './Menu' 9 | export * from './TrafficLightMenu' 10 | export * from './FadeOut' 11 | export * from './ScrollToToday' 12 | export * from './Login' 13 | export * from './FeedbackWidget' 14 | export * from './Splash' 15 | export * from './Menu/Settings' 16 | export * from './ConfirmationModal' 17 | export * from './Prompts' 18 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { AutoformatPlugin, PlatePlugin } from '@udecode/plate' 2 | import { autoformatRules } from './autoformat/autoformatRules' 3 | 4 | interface Config { 5 | autoformat: Partial> 6 | } 7 | 8 | export const defaultContent = [ 9 | { 10 | children: [ 11 | { 12 | text: '', 13 | }, 14 | ], 15 | }, 16 | ] 17 | 18 | export const CONFIG: Config = { 19 | autoformat: { 20 | options: { 21 | rules: autoformatRules, 22 | }, 23 | }, 24 | } 25 | 26 | export * from './UserPreferences' 27 | -------------------------------------------------------------------------------- /src/components/Icon/Trash.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Trash({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const rules = require('./webpack.rules') 2 | const plugins = require('./webpack.plugins') 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin') 4 | 5 | rules.push({ 6 | test: /\.css$/, 7 | use: [{ loader: 'style-loader' }, { loader: 'css-loader' }], 8 | }) 9 | 10 | module.exports = { 11 | module: { 12 | rules, 13 | }, 14 | // target: 'electron-renderer', 15 | plugins: plugins, 16 | resolve: { 17 | plugins: [new TsconfigPathsPlugin()], 18 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Icon/Edit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Edit({ tintColor, size, ...props }: any) { 5 | switch (size) { 6 | default: 7 | return ( 8 | 9 | 15 | 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Icon/TrafficLightOutline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function TrafficLightOutline({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useIsOnline.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | function useIsOnline() { 4 | const [isOnline, setIsOnline] = useState(navigator.onLine) 5 | 6 | const updateIsOnline = () => { 7 | setIsOnline(navigator.onLine) 8 | } 9 | 10 | useEffect(() => { 11 | window.addEventListener('online', updateIsOnline) 12 | window.addEventListener('offline', updateIsOnline) 13 | return () => { 14 | window.removeEventListener('online', updateIsOnline) 15 | window.removeEventListener('offline', updateIsOnline) 16 | } 17 | }) 18 | 19 | return isOnline 20 | } 21 | 22 | export { useIsOnline } 23 | -------------------------------------------------------------------------------- /src/components/Icon/FormatItalic.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatItalic({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Icon/Offline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Offline({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Icon/FormatCode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatCode({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Icon/BlockH1.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function BlockH1({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/EntryList/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { theme } from 'themes' 3 | 4 | const BeforeEntries = styled.div` 5 | text-align: center; 6 | margin: 48px 0 24px 0; 7 | height: 70px; 8 | background: ${theme('style.beginningImage')} no-repeat top center; 9 | background-size: 300px 70px; 10 | ` 11 | 12 | const PostEntries = styled.div` 13 | min-height: calc(100vh - 150px); 14 | ` 15 | 16 | const Wrapper = styled.div` 17 | width: 100vw; 18 | margin-left: ${theme('appearance.entriesOffset')}; 19 | transition: margin-left ${theme('animation.time.normal')}; 20 | display: flex; 21 | flex-flow: column; 22 | flex-direction: column-reverse; 23 | ` 24 | 25 | export { BeforeEntries, PostEntries, Wrapper } 26 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Subscribe/LeftPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | 5 | const LeftPanelStyled = styled.div` 6 | width: 260px; 7 | color: ${theme('color.primary.main')}; 8 | font-weight: 500; 9 | font-size: 21px; 10 | line-height: 26px; 11 | & em { 12 | opacity: 0.6; 13 | font-style: normal; 14 | } 15 | ` 16 | 17 | const LeftPanel = () => { 18 | return ( 19 | 20 | Upgrade, 21 | 22 |
23 | write 24 |
25 | without 26 |
27 | limits 28 |
29 |
30 | ) 31 | } 32 | 33 | export { LeftPanel } 34 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatRules.ts: -------------------------------------------------------------------------------- 1 | import { 2 | autoformatArrow, 3 | autoformatLegal, 4 | autoformatLegalHtml, 5 | autoformatMath, 6 | autoformatPunctuation, 7 | autoformatSmartQuotes, 8 | } from '@udecode/plate' 9 | import { autoformatBlocks } from './autoformatBlocks' 10 | import { autoformatLists } from './autoformatLists' 11 | import { autoformatMarks } from './autoformatMarks' 12 | import { autoformatMultiplication } from './autoformatOperation' 13 | 14 | export const autoformatRules = [ 15 | ...autoformatBlocks, 16 | ...autoformatLists, 17 | ...autoformatMarks, 18 | ...autoformatMultiplication, 19 | ...autoformatSmartQuotes, 20 | ...autoformatPunctuation, 21 | ...autoformatLegal, 22 | ...autoformatLegalHtml, 23 | ...autoformatArrow, 24 | ...autoformatMath, 25 | ] 26 | -------------------------------------------------------------------------------- /src/components/Icon/FormatUnderline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatUnderline({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Icon/UpdateNow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function UpdateNow({ tintColor, ...props }: any) { 4 | return ( 5 | 6 | 10 | 11 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/config/resetBlockTypePlugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ELEMENT_BLOCKQUOTE, 3 | ELEMENT_PARAGRAPH, 4 | ELEMENT_TODO_LI, 5 | isBlockAboveEmpty, 6 | isSelectionAtBlockStart, 7 | ResetNodePlugin, 8 | } from '@udecode/plate' 9 | import { PlatePlugin } from '@udecode/plate' 10 | 11 | const resetBlockTypesCommonRule = { 12 | types: [ELEMENT_BLOCKQUOTE, ELEMENT_TODO_LI], 13 | defaultType: ELEMENT_PARAGRAPH, 14 | } 15 | 16 | export const resetBlockTypePlugin: Partial> = { 17 | options: { 18 | rules: [ 19 | { 20 | ...resetBlockTypesCommonRule, 21 | hotkey: 'Enter', 22 | predicate: isBlockAboveEmpty, 23 | }, 24 | { 25 | ...resetBlockTypesCommonRule, 26 | hotkey: 'Backspace', 27 | predicate: isSelectionAtBlockStart, 28 | }, 29 | ], 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Splash.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | 5 | const hide = keyframes` 6 | 0% { 7 | opacity: 1; 8 | } 9 | 90% { 10 | opacity: 0; 11 | } 12 | 100% { 13 | opacity: 0; 14 | visibility: hidden; 15 | } 16 | ` 17 | 18 | const Splash = styled.div` 19 | position: fixed; 20 | top: 0; 21 | left: 0; 22 | right: 0; 23 | bottom: 0; 24 | z-index: 9999; 25 | pointer-events: none; 26 | background-color: ${theme('color.primary.surface')}; 27 | animation-name: ${hide}; 28 | animation-duration: ${theme('animation.time.long')}; 29 | animation-timing-function: cubic-bezier(0.17, 0.18, 0.41, 0.99); 30 | animation-fill-mode: forwards; 31 | animation-delay: ${theme('animation.time.long')}; 32 | ` 33 | 34 | export { Splash } 35 | -------------------------------------------------------------------------------- /src/utils/dates.ts: -------------------------------------------------------------------------------- 1 | function createDays(year: number, month: number) { 2 | let mon = month - 1 // months in JS are 0..11, not 1..12 3 | let d = new Date(year, mon) 4 | 5 | let days = [] 6 | 7 | while (d.getMonth() == mon) { 8 | // days.push(`${year}${month}${d.getDate()}`) 9 | days.push(d.getDate()) 10 | d.setDate(d.getDate() + 1) 11 | } 12 | 13 | return days 14 | } 15 | 16 | function getYearsSince(year: number) { 17 | let years = [] 18 | let currentYear = new Date().getFullYear() 19 | while (year <= currentYear) { 20 | years.push(year) 21 | year++ 22 | } 23 | return years 24 | } 25 | 26 | const stripeEpochToDate = (secs: number) => { 27 | var t = new Date('1970-01-01T00:30:00Z') // Unix epoch start. 28 | t.setSeconds(secs) 29 | return t 30 | } 31 | 32 | export { createDays, getYearsSince, stripeEpochToDate } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "es6", 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "noImplicitAny": true, 8 | "jsx": "react-jsx", 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "outDir": "dist", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "paths": { 15 | "*": ["*"], 16 | "components": ["./src/components/"], 17 | "hooks": ["./src/hooks/"], 18 | "utils": ["./src/utils/"], 19 | "themes": ["./src/themes/"], 20 | "types": ["./src/types/"], 21 | "consts": ["./src/constants/"], 22 | "config": ["./src/config/"], 23 | "context": ["./src/context/"] 24 | }, 25 | "lib": ["es2021", "dom"], 26 | "target": "es2021" 27 | }, 28 | "include": ["src/**/*", "decs.d.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Billing/Balance.tsx: -------------------------------------------------------------------------------- 1 | import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' 2 | import { HeaderStyled, TextStyled } from './styled' 3 | import { displayAmount } from 'utils' 4 | import type { PaymentMethodProps } from './types' 5 | import dayjs from 'dayjs' 6 | import { theme } from 'themes' 7 | import relativeTime from 'dayjs/plugin/relativeTime' 8 | dayjs.extend(relativeTime) 9 | 10 | const Balance = ({ billingInfo, isLoading }: PaymentMethodProps) => { 11 | return ( 12 | 13 | 14 | {isLoading ? : displayAmount(-billingInfo.customer.balance)} 15 | 16 | Balance 17 | 18 | ) 19 | } 20 | 21 | export { Balance } 22 | -------------------------------------------------------------------------------- /src/components/Icon/BlockBulletList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function BlockBulletList({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 12 | 13 | 14 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Upgrade/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { SectionTitleStyled } from '../styled' 3 | import { Features } from './Features' 4 | import { Products } from './Products' 5 | import { useUserContext } from 'context' 6 | 7 | const UpgradeTabContent = () => { 8 | const { session, subscription } = useUserContext() 9 | 10 | useEffect(() => { 11 | window.electronAPI.capture({ 12 | distinctId: session.user.id, 13 | event: 'settings view-tab', 14 | properties: { tab: 'upgrade' }, 15 | }) 16 | }, []) 17 | 18 | return ( 19 | <> 20 | 21 | {subscription == null ? 'Upgrade your plan' : 'Plans'} 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | export { UpgradeTabContent } 30 | -------------------------------------------------------------------------------- /src/services/saveFile.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, dialog } from 'electron' 2 | import { logger, isDev } from '../utils' 3 | import fs from 'fs' 4 | 5 | ipcMain.handle('journal-export', async (event, data: string, format: 'txt' | 'json') => { 6 | logger('journal-export') 7 | const options = { 8 | title: 'Save file', 9 | defaultPath: 'Journal', 10 | buttonLabel: 'Save', 11 | 12 | filters: [ 13 | { name: format, extensions: [format] }, 14 | { name: 'All Files', extensions: ['*'] }, 15 | ], 16 | } 17 | 18 | dialog.showSaveDialog(null, options).then(({ filePath }) => { 19 | if (filePath) { 20 | try { 21 | fs.writeFileSync(filePath, data, 'utf8') 22 | } catch (error) { 23 | dialog.showMessageBox({ 24 | message: 'Failed to save the file!', 25 | type: 'error', 26 | }) 27 | } 28 | } 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/components/Entry/LimitReached.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { useUserContext } from 'context' 3 | import { UpgradeButtonStyled, LimitReachedTextStyled, LimitReachedWrapperStyled } from './styled' 4 | 5 | const LimitReached = () => { 6 | const { invokeOpenSettings } = useUserContext() 7 | const { session } = useUserContext() 8 | 9 | const onClickHandler = () => { 10 | invokeOpenSettings.current(true) 11 | window.electronAPI.capture({ 12 | distinctId: session.user.id, 13 | event: 'entry upgrade-cta', 14 | }) 15 | } 16 | 17 | return ( 18 | 19 | Free plan limit reached... 20 | Upgrade 21 | 22 | ) 23 | } 24 | 25 | export { LimitReached } 26 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/ChangeCycle/LeftPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import { 5 | getCustomer, 6 | fetchCountries, 7 | calcYearlyPlanSavings, 8 | } from '../../../../context/UserContext/subscriptions' 9 | 10 | const LeftPanelStyled = styled.div` 11 | width: 260px; 12 | color: ${theme('color.primary.main')}; 13 | font-weight: 500; 14 | font-size: 21px; 15 | line-height: 26px; 16 | & em { 17 | opacity: 0.6; 18 | font-style: normal; 19 | } 20 | ` 21 | interface LeftPanelProps { 22 | saving: string 23 | } 24 | 25 | const LeftPanel = ({ saving }: LeftPanelProps) => { 26 | return ( 27 | 28 | Give yourself 29 |
30 | whole year 31 |
32 | of writing, 33 |
34 | {`save ${saving}`} 35 |
36 | ) 37 | } 38 | 39 | export { LeftPanel } 40 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatOperation.ts: -------------------------------------------------------------------------------- 1 | import { AutoformatRule, ELEMENT_DEFAULT } from '@udecode/plate' 2 | import { DIGITS, DIGITS_WITH_SPACE } from './autoformatConstants' 3 | import { formatText } from './autoformatUtils' 4 | 5 | const multiplicationWithoutSpace: AutoformatRule[] = DIGITS.map((digit) => ({ 6 | type: ELEMENT_DEFAULT, 7 | mode: 'block', 8 | match: [`${digit}*`, `${digit}x`], 9 | trigger: [...DIGITS, ...DIGITS_WITH_SPACE], 10 | insertTrigger: true, 11 | format: (editor) => formatText(editor, `${digit}×`), 12 | })) 13 | 14 | const multiplicationWithSpace: AutoformatRule[] = DIGITS.map((digit) => ({ 15 | type: ELEMENT_DEFAULT, 16 | mode: 'block', 17 | match: [`${digit} *`, `${digit} x`], 18 | trigger: [...DIGITS, ...DIGITS_WITH_SPACE], 19 | insertTrigger: true, 20 | format: (editor) => formatText(editor, `${digit} ×`), 21 | })) 22 | 23 | export const autoformatMultiplication: AutoformatRule[] = [ 24 | ...multiplicationWithoutSpace, 25 | ...multiplicationWithSpace, 26 | ] 27 | -------------------------------------------------------------------------------- /src/components/Icon/FormatBold.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatBold({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/sql/schema.0.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- Create a table for Users 2 | create table if not exists users ( 3 | id text, 4 | full_name text, 5 | 6 | PRIMARY KEY (id) 7 | ); 8 | 9 | -- Create a table for Journals 10 | create table if not exists journals ( 11 | user_id text, 12 | day date, 13 | created_at datetime, 14 | modified_at datetime, 15 | content text, 16 | deleted boolean not null default false, 17 | needs_saving_to_server boolean not null default false, 18 | 19 | PRIMARY KEY (user_id, day), 20 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE NO ACTION 21 | ); 22 | 23 | -- Create a table for user Preferences 24 | create table if not exists preferences ( 25 | user_id text, 26 | item text, 27 | value text, 28 | 29 | PRIMARY KEY (user_id, item), 30 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE NO ACTION 31 | ); 32 | 33 | -- Create a table for app 34 | create table if not exists app ( 35 | key text not null, 36 | value text, 37 | 38 | PRIMARY KEY (key) 39 | ); -------------------------------------------------------------------------------- /webpack.rules.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | // Add support for native node modules 3 | { 4 | // We're specifying native_modules in the test because the asset relocator loader generates a 5 | // "fake" .node file which is really a cjs file. 6 | test: /native_modules\/.+\.node$/, 7 | use: 'node-loader', 8 | }, 9 | { 10 | test: /\.(m?js|node)$/, 11 | parser: { amd: false }, 12 | use: { 13 | loader: '@vercel/webpack-asset-relocator-loader', 14 | options: { 15 | outputAssetBase: 'native_modules', 16 | }, 17 | }, 18 | }, 19 | { 20 | test: /\.tsx?$/, 21 | exclude: /(node_modules|\.webpack)/, 22 | use: { 23 | loader: 'ts-loader', 24 | options: { 25 | transpileOnly: true, 26 | }, 27 | }, 28 | }, 29 | { 30 | test: /\.png/, 31 | type: 'asset/resource', 32 | }, 33 | { 34 | test: /\.sql/, 35 | type: 'asset/source', 36 | }, 37 | { 38 | test: /\.m?js/, 39 | resolve: { 40 | fullySpecified: false, 41 | }, 42 | }, 43 | ] 44 | -------------------------------------------------------------------------------- /src/sql/schema.1.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- Create a table for Users 2 | create table if not exists users ( 3 | id text, 4 | full_name text, 5 | secret_key blob, 6 | 7 | PRIMARY KEY (id) 8 | ); 9 | 10 | -- Create a table for Journals 11 | create table if not exists journals ( 12 | user_id text, 13 | day date, 14 | created_at datetime, 15 | modified_at datetime, 16 | content text, 17 | deleted boolean not null default false, 18 | needs_saving_to_server boolean not null default false, 19 | 20 | PRIMARY KEY (user_id, day), 21 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE NO ACTION 22 | ); 23 | 24 | -- Create a table for user Preferences 25 | create table if not exists preferences ( 26 | user_id text, 27 | item text, 28 | value text, 29 | 30 | PRIMARY KEY (user_id, item), 31 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE NO ACTION 32 | ); 33 | 34 | -- Create a table for app 35 | create table if not exists app ( 36 | key text not null, 37 | value text, 38 | 39 | PRIMARY KEY (key) 40 | ); -------------------------------------------------------------------------------- /src/services/autoUpdater.ts: -------------------------------------------------------------------------------- 1 | import { isDev, logger } from '../utils' 2 | import { autoUpdater, ipcMain } from 'electron' 3 | import log from 'electron-log' 4 | 5 | if (!isDev()) { 6 | const server = 'https://desktop.journal.do' 7 | const updateUrl = `${server}/${process.platform}/${process.arch}/update.json` 8 | logger(`updateUrl: ${updateUrl}`) 9 | log.info(`updateUrl: ${updateUrl}`) 10 | autoUpdater.setFeedURL({ url: updateUrl, serverType: 'json' }) 11 | 12 | autoUpdater.on('update-available', () => { 13 | logger('update-available') 14 | log.info('update-available') 15 | }) 16 | 17 | autoUpdater.on('update-not-available', () => { 18 | logger('update-not-available') 19 | log.info('update-not-available') 20 | }) 21 | 22 | setInterval(() => { 23 | logger('autoUpdater.checkForUpdates()') 24 | log.info('autoUpdater.checkForUpdates()') 25 | autoUpdater.checkForUpdates() 26 | }, 5 * 60 * 1000) 27 | 28 | autoUpdater.checkForUpdates() 29 | 30 | ipcMain.on('electron-quit-and-install', () => { 31 | autoUpdater.quitAndInstall() 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /tests/helpers/sqlite.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'sqlite3' 2 | import { open } from 'sqlite' 3 | import * as fs from 'fs' 4 | 5 | const dbFile = 'cache-test.db' 6 | 7 | export async function sqliteGetDB(userDataPath: string, user_id: string, day: string) { 8 | const db = await open({ 9 | filename: userDataPath + '/' + dbFile, 10 | driver: sqlite3.Database, 11 | }) 12 | const result = await db.get( 13 | `select * from journals where user_id = '${user_id}' and day = '${day}'` 14 | ) 15 | return result 16 | } 17 | 18 | export async function sqliteDeleteDB(userDataPath: string) { 19 | await fs.promises.unlink(userDataPath + '/' + dbFile) 20 | } 21 | 22 | export async function sqliteRenameDB(userDataPath: string, name: string) { 23 | await fs.promises.rename( 24 | userDataPath + '/' + dbFile, 25 | userDataPath + '/' + `cache-test-${name}.db` 26 | ) 27 | } 28 | 29 | export async function sqliteCreateCopyDB(userDataPath: string, name: string) { 30 | await fs.promises.copyFile( 31 | userDataPath + '/' + dbFile, 32 | userDataPath + '/' + `cache-test-${name}.db` 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/components/FadeOut.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { theme } from 'themes' 4 | 5 | const FadeTop = styled.div` 6 | position: fixed; 7 | pointer-events: none; 8 | top: 0; 9 | left: 0; 10 | right: 0; 11 | height: 56px; 12 | z-index: 10; 13 | background: linear-gradient( 14 | 180deg, 15 | ${theme('color.primary.surface')} 0%, 16 | ${theme('color.primary.surface')} 30%, 17 | ${theme('color.primary.surface', 0)} 100% 18 | ); 19 | ` 20 | // background: linear-gradient(180deg, #e0e0e0 0%, #e0e0e0 35%, rgba(224, 224, 224, 0) 100%); 21 | 22 | const FadeDown = styled.div` 23 | position: fixed; 24 | pointer-events: none; 25 | bottom: 0; 26 | left: 0; 27 | right: 0; 28 | height: 56px; 29 | z-index: 10; 30 | background: linear-gradient( 31 | 0deg, 32 | ${theme('color.primary.surface')} 0%, 33 | ${theme('color.primary.surface')} 30%, 34 | ${theme('color.primary.surface', 0)} 100% 35 | ); 36 | ` 37 | 38 | function FadeOut() { 39 | return ( 40 | <> 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | export { FadeOut } 48 | -------------------------------------------------------------------------------- /src/components/Icon/BlockH2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function BlockH2({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/TrafficLightMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled from 'styled-components' 3 | import { theme } from 'themes' 4 | import { useAppearanceContext, AppearanceContextInterface } from 'context' 5 | import { Icon } from 'components' 6 | 7 | const Container = styled.div` 8 | position: fixed; 9 | display: flex; 10 | gap: 8px; 11 | top: 16px; 12 | left: 15px; 13 | z-index: 9999; 14 | opacity: 0.3; 15 | transition: opacity ${theme('animation.time.normal')}; 16 | &:hover { 17 | opacity: 0.7; 18 | } 19 | ` 20 | 21 | const ToggleButton = styled.button` 22 | border: 0; 23 | outline: 0; 24 | padding: 0; 25 | background-color: transparent; 26 | ` 27 | 28 | const TrafficLightMenu = () => { 29 | const { isCalendarOpen, toggleIsCalendarOpen } = useAppearanceContext() 30 | return ( 31 | 32 | 33 | toggleIsCalendarOpen()}> 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export { TrafficLightMenu } 41 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutoformatBlockRule, 3 | ELEMENT_CODE_BLOCK, 4 | ELEMENT_CODE_LINE, 5 | getParentNode, 6 | isElement, 7 | isType, 8 | PlateEditor, 9 | TEditor, 10 | toggleList, 11 | unwrapList, 12 | } from '@udecode/plate' 13 | 14 | export const clearBlockFormat: AutoformatBlockRule['preFormat'] = (editor) => 15 | unwrapList(editor as PlateEditor) 16 | 17 | export const format = (editor: TEditor, customFormatting: any) => { 18 | if (editor.selection) { 19 | const parentEntry = getParentNode(editor, editor.selection) 20 | if (!parentEntry) return 21 | const [node] = parentEntry 22 | if ( 23 | isElement(node) && 24 | !isType(editor as PlateEditor, node, ELEMENT_CODE_BLOCK) && 25 | !isType(editor as PlateEditor, node, ELEMENT_CODE_LINE) 26 | ) { 27 | customFormatting() 28 | } 29 | } 30 | } 31 | 32 | export const formatList = (editor: TEditor, elementType: string) => { 33 | format(editor, () => 34 | toggleList(editor as PlateEditor, { 35 | type: elementType, 36 | }) 37 | ) 38 | } 39 | 40 | export const formatText = (editor: TEditor, text: string) => { 41 | format(editor, () => editor.insertText(text)) 42 | } 43 | -------------------------------------------------------------------------------- /src/types/tags.ts: -------------------------------------------------------------------------------- 1 | import { lightTheme, theme } from 'themes' 2 | 3 | type SyncStatus = 'synced' | 'pending_insert' | 'pending_update' | 'pending_delete' 4 | 5 | type Tag = { 6 | id: string 7 | user_id?: string 8 | name: string 9 | color: keyof typeof lightTheme.color.tags 10 | created_at?: string 11 | modified_at?: string 12 | revision?: number 13 | sync_status?: SyncStatus 14 | } 15 | 16 | type EntryTag = { 17 | user_id?: string 18 | day: string 19 | journal_id?: number 20 | tag_id: string 21 | order_no: number 22 | created_at?: string 23 | modified_at?: string 24 | revision?: number 25 | sync_status?: SyncStatus 26 | } 27 | 28 | type EntryTagProperty = 29 | | { user_id: string } 30 | | { day: string } 31 | | { journal_id: number } 32 | | { tag_id: string } 33 | | { order_no: number } 34 | | { created_at: string } 35 | | { modified_at: string } 36 | | { revision: number } 37 | | { sync_status: SyncStatus } 38 | 39 | type ListItemActionType = { 40 | type: 'action' 41 | value: 'CREATE' 42 | } 43 | 44 | type ListItemTagType = { 45 | type: 'tag' 46 | value: Tag 47 | } 48 | 49 | type ListItemType = ListItemActionType | ListItemTagType 50 | 51 | export { Tag, EntryTag, EntryTagProperty, ListItemType } 52 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatLists.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutoformatRule, 3 | ELEMENT_LI, 4 | ELEMENT_OL, 5 | ELEMENT_TODO_LI, 6 | ELEMENT_UL, 7 | setNodes, 8 | TElement, 9 | TTodoListItemElement, 10 | } from '@udecode/plate' 11 | import { clearBlockFormat, formatList } from './autoformatUtils' 12 | 13 | export const autoformatLists: AutoformatRule[] = [ 14 | { 15 | mode: 'block', 16 | type: ELEMENT_LI, 17 | match: ['* ', '- '], 18 | preFormat: clearBlockFormat, 19 | format: (editor) => formatList(editor, ELEMENT_UL), 20 | }, 21 | { 22 | mode: 'block', 23 | type: ELEMENT_LI, 24 | match: ['1. ', '1) '], 25 | preFormat: clearBlockFormat, 26 | format: (editor) => formatList(editor, ELEMENT_OL), 27 | }, 28 | // { 29 | // mode: 'block', 30 | // type: ELEMENT_TODO_LI, 31 | // match: '[] ', 32 | // }, 33 | // { 34 | // mode: 'block', 35 | // type: ELEMENT_TODO_LI, 36 | // match: '[x] ', 37 | // format: (editor) => 38 | // setNodes>( 39 | // editor, 40 | // { type: ELEMENT_TODO_LI, checked: true }, 41 | // { 42 | // match: (n) => Editor.isBlock(editor, n), 43 | // } 44 | // ), 45 | // }, 46 | ] 47 | -------------------------------------------------------------------------------- /src/utils/supabaseClient.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js' 2 | import { isDev } from './' 3 | 4 | const supabaseEnv = { 5 | local: { 6 | supabaseUrl: 'https://supabase.journal.local:8443', 7 | supabaseAnonKey: 8 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRuZmRhdW9vd3lycHhxb2RvbXFuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTQ1MzEzOTUsImV4cCI6MTk3MDEwNzM5NX0.XYkcWry-Eqm0-Hvq-arndEGhQn_yJvGF85-NNf9Sbvk', 9 | }, 10 | stg: { 11 | supabaseUrl: 'https://tnfdauoowyrpxqodomqn.supabase.co', 12 | supabaseAnonKey: 13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRuZmRhdW9vd3lycHhxb2RvbXFuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTQ1MzEzOTUsImV4cCI6MTk3MDEwNzM5NX0.XYkcWry-Eqm0-Hvq-arndEGhQn_yJvGF85-NNf9Sbvk', 14 | }, 15 | prod: { 16 | supabaseUrl: 'https://hsbagpjhlxzabpiitqjw.supabase.co', 17 | supabaseAnonKey: 18 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzYmFncGpobHh6YWJwaWl0cWp3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTI0MjMwOTEsImV4cCI6MTk2Nzk5OTA5MX0.O-QNy1tbJ7AvZMRhBf8i7_UDNUDhBMQ_yKJEEeS5p84', 19 | }, 20 | } 21 | 22 | const { supabaseUrl } = supabaseEnv[isDev() ? 'local' : 'prod'] 23 | const { supabaseAnonKey } = supabaseEnv[isDev() ? 'local' : 'prod'] 24 | 25 | const supabase = createClient(supabaseUrl, supabaseAnonKey) 26 | 27 | export { supabaseUrl, supabaseAnonKey, supabase } 28 | -------------------------------------------------------------------------------- /src/components/Icon/BlockText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function BlockText({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Icon/TrafficLightCalendar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function TrafficLightCalendar({ tintColor, type = 'down', ...props }: any) { 5 | switch (type) { 6 | case 'off': 7 | return ( 8 | 9 | 17 | 21 | 22 | ) 23 | case 'on': 24 | return ( 25 | 26 | 34 | 40 | 41 | ) 42 | default: 43 | return <> 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Icon/FormatStriketrough.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatStriketrough({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Subscribe/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import * as Tabs from '@radix-ui/react-tabs' 3 | import styled, { keyframes } from 'styled-components' 4 | import { theme } from 'themes' 5 | import { Icon } from 'components' 6 | import { CardElement } from '@stripe/react-stripe-js' 7 | 8 | const TextStyled = styled.div` 9 | font-weight: 500; 10 | font-size: 14px; 11 | line-height: 24px; 12 | ` 13 | 14 | const CheckoutModalStyled = styled.div` 15 | background-color: ${theme('color.popper.surface')}; 16 | display: flex; 17 | position: relative; 18 | padding: 0; 19 | padding: 40px 32px 32px 32px; 20 | margin: 48px 8px 8px 8px; 21 | border-radius: 8px; 22 | -webkit-app-region: no-drag; 23 | ` 24 | 25 | const ButtonStyled = styled.button` 26 | background-color: ${theme('color.popper.main')}; 27 | color: ${theme('color.popper.inverted')}; 28 | outline: 0; 29 | border: 0; 30 | font-weight: 500; 31 | font-size: 14px; 32 | line-height: 24px; 33 | cursor: pointer; 34 | display: flex; 35 | margin-top: 16px; 36 | padding: 8px 12px; 37 | border-radius: 6px; 38 | width: fit-content; 39 | transition: box-shadow ${theme('animation.time.normal')} ease; 40 | &:hover, 41 | &:focus { 42 | box-shadow: 0 0 0 2px ${theme('color.popper.main', 0.15)}; 43 | } 44 | &:disabled { 45 | opacity: 0.6; 46 | cursor: default; 47 | } 48 | ` 49 | 50 | export { TextStyled, CheckoutModalStyled, ButtonStyled } 51 | -------------------------------------------------------------------------------- /src/components/Icon/Cross.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Cross({ tintColor, size, ...props }: any) { 5 | switch (size) { 6 | case 12: 7 | return ( 8 | 9 | 15 | 16 | ) 17 | 18 | case 16: 19 | return ( 20 | 21 | 27 | 28 | ) 29 | 30 | default: 31 | return ( 32 | 33 | 39 | 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/ImportExport/nodeTypes.ts: -------------------------------------------------------------------------------- 1 | import { InputNodeTypes } from 'remark-slate' 2 | import { 3 | ELEMENT_BLOCKQUOTE, 4 | ELEMENT_CODE_BLOCK, 5 | ELEMENT_CODE_LINE, 6 | ELEMENT_H1, 7 | ELEMENT_H2, 8 | ELEMENT_H3, 9 | ELEMENT_H4, 10 | ELEMENT_H5, 11 | ELEMENT_H6, 12 | ELEMENT_IMAGE, 13 | ELEMENT_LI, 14 | ELEMENT_LIC, 15 | ELEMENT_LINK, 16 | ELEMENT_MEDIA_EMBED, 17 | ELEMENT_MENTION, 18 | ELEMENT_MENTION_INPUT, 19 | ELEMENT_OL, 20 | ELEMENT_PARAGRAPH, 21 | ELEMENT_TABLE, 22 | ELEMENT_TD, 23 | ELEMENT_TH, 24 | ELEMENT_TODO_LI, 25 | ELEMENT_TR, 26 | ELEMENT_UL, 27 | MARK_BOLD, 28 | MARK_CODE, 29 | MARK_ITALIC, 30 | MARK_STRIKETHROUGH, 31 | } from '@udecode/plate' 32 | // Override the default remark-slate node type names to match Plate defaults 33 | //format: :; 34 | 35 | const plateNodeTypes: InputNodeTypes = { 36 | paragraph: ELEMENT_PARAGRAPH, 37 | block_quote: ELEMENT_BLOCKQUOTE, 38 | code_block: ELEMENT_CODE_BLOCK, 39 | link: ELEMENT_LINK, 40 | ul_list: ELEMENT_UL, 41 | ol_list: ELEMENT_OL, 42 | listItem: ELEMENT_LI, 43 | heading: { 44 | 1: ELEMENT_H1, 45 | 2: ELEMENT_H2, 46 | 3: ELEMENT_H3, 47 | 4: ELEMENT_H4, 48 | 5: ELEMENT_H5, 49 | 6: ELEMENT_H6, 50 | }, 51 | emphasis_mark: MARK_ITALIC, 52 | strong_mark: MARK_BOLD, 53 | delete_mark: MARK_STRIKETHROUGH, 54 | inline_code_mark: MARK_CODE, 55 | thematic_break: 'thematic_break', 56 | image: ELEMENT_IMAGE, 57 | } 58 | 59 | export { plateNodeTypes } 60 | -------------------------------------------------------------------------------- /src/components/Icon/BlockH3.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function BlockH3({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Icon/BlockNumList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function BlockNumList({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | // declare var TextDecoder: any 2 | var ab2str = require('arraybuffer-to-string') 3 | var str2ab = require('string-to-arraybuffer') 4 | 5 | const fromHex = (h: string) => { 6 | if (h.slice(0, 2) == '\\x') { 7 | h = h.slice(2) 8 | } 9 | var s = '' 10 | for (var i = 0; i < h.length; i += 2) { 11 | s += String.fromCharCode(parseInt(h.slice(i, i + 2), 16)) 12 | } 13 | return s 14 | } 15 | 16 | const encryptEntry = async (content: any, secretKey: CryptoKey) => { 17 | let utf8Encoder = new TextEncoder() 18 | 19 | let ivBuffer = window.crypto.getRandomValues(new Uint8Array(16)) 20 | let contentBuffer = await window.crypto.subtle.encrypt( 21 | { 22 | name: 'AES-CTR', 23 | counter: ivBuffer, 24 | length: 32, 25 | }, 26 | secretKey, 27 | utf8Encoder.encode(content) 28 | ) 29 | let contentEncrypted = ab2str(contentBuffer, 'hex') 30 | let iv = ab2str(ivBuffer, 'hex') 31 | 32 | return { contentEncrypted, iv } 33 | } 34 | 35 | const decryptEntry = async (content: any, iv: string, secretKey: CryptoKey) => { 36 | let ivUtf = fromHex(iv) 37 | let ivAb = str2ab(ivUtf) 38 | 39 | let contentUtf = fromHex(content) 40 | let contentAb = str2ab(contentUtf) 41 | 42 | let contentBuffer = await window.crypto.subtle.decrypt( 43 | { 44 | name: 'AES-CTR', 45 | counter: ivAb, 46 | length: 32, 47 | }, 48 | secretKey, 49 | contentAb 50 | ) 51 | let contentDecrypted = ab2str(contentBuffer, 'utf8') as string 52 | 53 | return { contentDecrypted } 54 | } 55 | 56 | export { encryptEntry, decryptEntry } 57 | -------------------------------------------------------------------------------- /src/components/Icon/Exit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | // export function Exit({ tintColor, ...props }: any) { 5 | // return ( 6 | // 7 | // 13 | // 14 | // ) 15 | // } 16 | 17 | export function Exit({ tintColor, ...props }: any) { 18 | return ( 19 | 20 | 21 | 25 | 26 | 27 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/themes/index.ts: -------------------------------------------------------------------------------- 1 | import { lightTheme } from './lightTheme' 2 | import { darkTheme } from './darkTheme' 3 | import { forestTheme } from './forestTheme' 4 | import { cappuccinoTheme } from './cappuccinoTheme' 5 | import { baseTheme } from 'config' 6 | 7 | type RecursiveKeyOf = { 8 | [TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue 9 | }[keyof TObj & (string | number)] 10 | 11 | type RecursiveKeyOfInner = { 12 | [TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue 13 | }[keyof TObj & (string | number)] 14 | 15 | type RecursiveKeyOfHandleValue = TValue extends any[] 16 | ? Text 17 | : TValue extends object 18 | ? Text | `${Text}${RecursiveKeyOfInner}` 19 | : Text 20 | 21 | type LightThemeItemKey = RecursiveKeyOf 22 | type BaseThemeItemKey = RecursiveKeyOf 23 | 24 | const theme = (itemKey: LightThemeItemKey | BaseThemeItemKey, alpha = 1) => { 25 | const cssVar = itemKey.split('.').reduce((acc, key) => acc + '-' + key, '-') 26 | if (itemKey.split('.')[0] == 'color') { 27 | return `rgba(var(${cssVar}), ${alpha})` 28 | } 29 | return `var(${cssVar})` 30 | } 31 | 32 | const getCSSVar = (itemKey: LightThemeItemKey | BaseThemeItemKey) => { 33 | return itemKey.split('.').reduce((acc, key) => acc + '-' + key, '-') 34 | } 35 | 36 | export { 37 | theme, 38 | getCSSVar, 39 | lightTheme, 40 | darkTheme, 41 | baseTheme, 42 | forestTheme, 43 | cappuccinoTheme, 44 | LightThemeItemKey, 45 | BaseThemeItemKey, 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless/ 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | # DynamoDB Local files 83 | .dynamodb/ 84 | 85 | # Webpack 86 | .webpack/ 87 | 88 | # Electron-Forge 89 | out/ 90 | 91 | REPL 92 | 93 | test-output -------------------------------------------------------------------------------- /src/components/Icon/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Settings({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 8 | 12 | 13 | 14 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/FormatToolbar/BlockTypeSelectItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Icon } from 'components' 3 | import { theme } from 'themes' 4 | import styled from 'styled-components' 5 | 6 | const ItemWrapper = styled.button` 7 | display: flex; 8 | border: 0; 9 | gap: 8px; 10 | padding: 2px 12px 2px 4px; 11 | border-radius: 8px; 12 | cursor: pointer; 13 | width: 100%; 14 | background-color: ${theme('color.popper.surface')}; 15 | align-items: center; 16 | transition: ${theme('animation.time.normal')}; 17 | &:hover { 18 | background-color: ${theme('color.popper.hover')}; 19 | } 20 | ` 21 | type ItemCurrentProps = { 22 | current: boolean 23 | } 24 | 25 | const ItemTitle = styled.span` 26 | font-size: 14px; 27 | color: ${theme('color.popper.main')}; 28 | font-weight: ${(props) => (props.current ? '700' : 'normal')}; 29 | line-height: 20px; 30 | flex-grow: 1; 31 | text-align: left; 32 | ` 33 | 34 | const ItemCurrent = styled(({ current, ...props }) => ( 35 | 36 | ))` 37 | visibility: ${(props) => (props.current ? 'visible' : 'hidden')}; 38 | ` 39 | 40 | type ItemProps = { 41 | onMouseDown: (e: any) => void 42 | icon: string 43 | current: boolean 44 | children: string 45 | } 46 | 47 | const BlockTypeSelectItem = ({ onMouseDown, icon, current = false, children }: ItemProps) => { 48 | return ( 49 | 50 | 51 | {children} 52 | 53 | 54 | ) 55 | } 56 | 57 | export { BlockTypeSelectItem } 58 | -------------------------------------------------------------------------------- /src/components/Icon/FormatHandStriketrough.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function FormatHandStriketrough({ tintColor, ...props }: any) { 5 | return ( 6 | 7 | 11 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatMarks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutoformatRule, 3 | MARK_BOLD, 4 | MARK_CODE, 5 | MARK_HIGHLIGHT, 6 | MARK_ITALIC, 7 | MARK_STRIKETHROUGH, 8 | MARK_SUBSCRIPT, 9 | MARK_SUPERSCRIPT, 10 | MARK_UNDERLINE, 11 | } from '@udecode/plate' 12 | 13 | export const autoformatMarks: AutoformatRule[] = [ 14 | { 15 | mode: 'mark', 16 | type: [MARK_BOLD, MARK_ITALIC], 17 | match: '***', 18 | }, 19 | { 20 | mode: 'mark', 21 | type: [MARK_UNDERLINE, MARK_ITALIC], 22 | match: '__*', 23 | }, 24 | { 25 | mode: 'mark', 26 | type: [MARK_UNDERLINE, MARK_BOLD], 27 | match: '__**', 28 | }, 29 | { 30 | mode: 'mark', 31 | type: [MARK_UNDERLINE, MARK_BOLD, MARK_ITALIC], 32 | match: '___***', 33 | }, 34 | { 35 | mode: 'mark', 36 | type: MARK_BOLD, 37 | match: '**', 38 | }, 39 | { 40 | mode: 'mark', 41 | type: MARK_UNDERLINE, 42 | match: '__', 43 | }, 44 | { 45 | mode: 'mark', 46 | type: MARK_ITALIC, 47 | match: '*', 48 | }, 49 | { 50 | mode: 'mark', 51 | type: MARK_ITALIC, 52 | match: '_', 53 | }, 54 | { 55 | mode: 'mark', 56 | type: MARK_STRIKETHROUGH, 57 | match: '~~', 58 | }, 59 | // { 60 | // mode: 'mark', 61 | // type: MARK_SUPERSCRIPT, 62 | // match: '^', 63 | // }, 64 | // { 65 | // mode: 'mark', 66 | // type: MARK_SUBSCRIPT, 67 | // match: '~', 68 | // }, 69 | // { 70 | // mode: 'mark', 71 | // type: MARK_HIGHLIGHT, 72 | // match: '==', 73 | // }, 74 | // { 75 | // mode: 'mark', 76 | // type: MARK_HIGHLIGHT, 77 | // match: '≡', 78 | // }, 79 | { 80 | mode: 'mark', 81 | type: MARK_CODE, 82 | match: '`', 83 | }, 84 | ] 85 | -------------------------------------------------------------------------------- /src/components/Icon/Bucket.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | // export function Bucket({ tintColor, ...props }: any) { 5 | // return ( 6 | // 7 | // 13 | // 19 | // 20 | // ) 21 | // } 22 | 23 | export function Bucket({ tintColor, ...props }: any) { 24 | return ( 25 | 26 | 30 | 31 | 39 | 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Billing/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { logger, supabase, stripeEpochToDate, isDev } from 'utils' 3 | import { SectionTitleStyled } from '../styled' 4 | import { useQuery } from '@tanstack/react-query' 5 | import { useUserContext } from 'context' 6 | import { getCustomer } from '../../../../context/UserContext/subscriptions' 7 | import { PaymentMethod } from './PaymentMethod' 8 | import { Receipts } from './Receipts' 9 | import { Plan } from './Plan' 10 | import { Balance } from './Balance' 11 | import { Divider } from './styled' 12 | 13 | const BillingTabContent = () => { 14 | logger('BillingTabContent re-render') 15 | const { session, subscription } = useUserContext() 16 | const { 17 | isLoading, 18 | isError, 19 | data: billingInfo, 20 | } = useQuery({ 21 | queryKey: ['billingInfo'], 22 | queryFn: async () => getCustomer(session.access_token), 23 | }) 24 | 25 | useEffect(() => { 26 | window.electronAPI.capture({ 27 | distinctId: session.user.id, 28 | event: 'settings view-tab', 29 | properties: { tab: 'billing' }, 30 | }) 31 | }, []) 32 | 33 | useEffect(() => { 34 | logger(billingInfo) 35 | }, [billingInfo]) 36 | 37 | return ( 38 | <> 39 | Billing 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export { BillingTabContent } 52 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Subscribe/Success.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { Icon } from 'components' 3 | import styled, { keyframes } from 'styled-components' 4 | import { theme } from 'themes' 5 | 6 | const WrapperStyled = styled.div` 7 | padding: 16px 48px; 8 | display: flex; 9 | align-items: center; 10 | gap: 24px; 11 | flex-direction: column; 12 | ` 13 | 14 | const MessageStyled = styled.div` 15 | color: ${theme('color.popper.main')}; 16 | text-align: center; 17 | line-height: 20px; 18 | font-size: 14px; 19 | ` 20 | 21 | const ButtonStyled = styled.button` 22 | font-weight: 500; 23 | font-size: 14px; 24 | line-height: 22px; 25 | cursor: pointer; 26 | color: ${theme('color.popper.main')}; 27 | background-color: transparent; 28 | display: flex; 29 | align-items: flex-end; 30 | gap: 4px; 31 | padding: 8px 12px; 32 | border-radius: 6px; 33 | width: fit-content; 34 | border: 1px solid ${theme('color.popper.main')}; 35 | outline: 0; 36 | transition: box-shadow ${theme('animation.time.normal')} ease; 37 | opacity: 0.8; 38 | &:hover { 39 | box-shadow: 0 0 0 4px ${theme('color.popper.main', 0.15)}; 40 | } 41 | &:focus { 42 | box-shadow: 0 0 0 4px ${theme('color.popper.main', 0.15)}; 43 | } 44 | ` 45 | 46 | const Success = () => { 47 | return ( 48 | 49 | 50 | 51 | Upgrade successful, 52 |
53 | you are all set! 54 |
55 | {/* window.electronAPI.reloadWindow()}> 56 | Back to journaling 57 | */} 58 |
59 | ) 60 | } 61 | 62 | export { Success } 63 | -------------------------------------------------------------------------------- /src/components/Icon/Check.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Check({ tintColor, size = 24, ...props }: any) { 5 | switch (size) { 6 | case 48: 7 | return ( 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | case 24: 25 | return ( 26 | 27 | 33 | 34 | ) 35 | case 16: 36 | return ( 37 | 38 | 44 | 45 | ) 46 | default: 47 | return <> 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/ScrollToToday.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import dayjs from 'dayjs' 5 | import { useUserContext, useEntriesContext } from 'context' 6 | import { select, focusEditor } from '@udecode/plate' 7 | import { logger } from 'utils' 8 | 9 | const ScrollToTodayButton = styled.button` 10 | position: fixed; 11 | bottom: 8px; 12 | left: 32px; 13 | margin-bottom: -32px; 14 | margin-left: ${theme('appearance.entriesOffset')}; 15 | transition-duration: ${theme('animation.time.normal')}; 16 | transition-timing-function: ${theme('animation.timingFunction.dynamic')}; 17 | animation-duration: ${theme('animation.time.normal')}; 18 | font-size: 12px; 19 | background-color: transparent; 20 | border: 0; 21 | color: ${theme('color.primary.main')}; 22 | z-index: 100; 23 | opacity: 0.3; 24 | outline: 0; 25 | cursor: pointer; 26 | &:hover { 27 | opacity: 0.7; 28 | } 29 | ` 30 | 31 | function ScrollToToday() { 32 | const { session } = useUserContext() 33 | const { editorsRef } = useEntriesContext() 34 | const scrollToToday = () => { 35 | let today = dayjs().format('YYYY-MM-DD') 36 | let entry = document.getElementById(`${today}-entry`) 37 | if (entry) { 38 | logger('scrollToToday') 39 | entry.scrollIntoView() 40 | const editor = editorsRef.current[today] 41 | if (editor) { 42 | focusEditor(editor) 43 | select(editor, { 44 | path: [0, 0], 45 | offset: 0, 46 | }) 47 | } 48 | } 49 | window.electronAPI.capture({ 50 | distinctId: session.user.id, 51 | event: 'entry scroll-to-today', 52 | }) 53 | } 54 | 55 | return ( 56 | scrollToToday()}> 57 | ↓ Back to Today 58 | 59 | ) 60 | } 61 | 62 | export { ScrollToToday } 63 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/ChangeCycle/Success.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { Icon } from 'components' 3 | import styled, { keyframes } from 'styled-components' 4 | import { theme } from 'themes' 5 | import { useQuery } from '@tanstack/react-query' 6 | import { getCustomer } from '../../../../context/UserContext/subscriptions' 7 | import { useUserContext } from 'context' 8 | 9 | const WrapperStyled = styled.div` 10 | padding: 16px 48px; 11 | display: flex; 12 | align-items: center; 13 | gap: 24px; 14 | flex-direction: column; 15 | ` 16 | 17 | const MessageStyled = styled.div` 18 | color: ${theme('color.popper.main')}; 19 | text-align: center; 20 | line-height: 20px; 21 | font-size: 14px; 22 | ` 23 | 24 | const ButtonStyled = styled.button` 25 | font-weight: 500; 26 | font-size: 14px; 27 | line-height: 22px; 28 | cursor: pointer; 29 | color: ${theme('color.popper.main')}; 30 | background-color: transparent; 31 | display: flex; 32 | align-items: flex-end; 33 | gap: 4px; 34 | padding: 8px 12px; 35 | border-radius: 6px; 36 | width: fit-content; 37 | border: 1px solid ${theme('color.popper.main')}; 38 | outline: 0; 39 | transition: box-shadow ${theme('animation.time.normal')} ease; 40 | opacity: 0.8; 41 | &:hover { 42 | box-shadow: 0 0 0 4px ${theme('color.popper.main', 0.15)}; 43 | } 44 | &:focus { 45 | box-shadow: 0 0 0 4px ${theme('color.popper.main', 0.15)}; 46 | } 47 | ` 48 | 49 | const Success = () => { 50 | const { session } = useUserContext() 51 | useQuery({ 52 | queryKey: ['billingInfo'], 53 | queryFn: async () => getCustomer(session.access_token), 54 | }) 55 | 56 | return ( 57 | 58 | 59 | 60 | Success, 61 |
62 | you are all set! 63 |
64 |
65 | ) 66 | } 67 | 68 | export { Success } 69 | -------------------------------------------------------------------------------- /src/components/Icon/Chevron.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { theme } from 'themes' 3 | 4 | export function Chevron({ tintColor, size = 16, type = 'down', ...props }: any) { 5 | switch (type) { 6 | case 'down': 7 | switch (size) { 8 | case 16: 9 | return ( 10 | 11 | 17 | 18 | ) 19 | case 8: 20 | return ( 21 | 22 | 28 | 29 | ) 30 | } 31 | 32 | case 'up': 33 | switch (size) { 34 | case 16: 35 | return ( 36 | 37 | 43 | 44 | ) 45 | case 8: 46 | return ( 47 | 48 | 54 | 55 | ) 56 | } 57 | default: 58 | return <> 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/services/analytics.ts: -------------------------------------------------------------------------------- 1 | import { isDev, logger } from '../utils' 2 | import { ipcMain, app } from 'electron' 3 | import fetch from 'node-fetch' 4 | 5 | if (isDev()) { 6 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' 7 | app.commandLine.appendSwitch('ignore-certificate-errors') 8 | app.commandLine.appendSwitch('allow-insecure-localhost', 'true') 9 | } 10 | 11 | interface EventMessage { 12 | distinctId: string 13 | event: string 14 | type?: 'event' | 'system' | 'error' 15 | properties?: Record 16 | } 17 | 18 | const capture = async ({ distinctId, event, properties, type }: EventMessage) => { 19 | if (!isDev()) { 20 | logger(`capture ${event}${type ? ' of type ' + type : ''}`) 21 | 22 | let appInfo = { 23 | version: app.getVersion(), 24 | platform: process.platform, 25 | arch: process.arch, 26 | locale: app.getLocale(), 27 | } 28 | 29 | try { 30 | const body = JSON.stringify({ 31 | user_id: distinctId, 32 | app: appInfo, 33 | event, 34 | properties, 35 | ...(type && { type }), 36 | }) 37 | const url = isDev() ? 'https://capture.journal.local' : 'https://capture.journal.do' 38 | // const url = 'https://capture.journal.do' 39 | const headers = { 'Content-Type': 'application/json', 'x-api-key': 'o4dqm2yb' } 40 | await fetch(url, { method: 'post', body, headers }) 41 | } catch (error) { 42 | logger(`error`) 43 | logger(error) 44 | } 45 | } else { 46 | logger(`capture ${event}${type ? ' of type ' + type : ''} (not capturing)`) 47 | if (properties) logger(properties) 48 | } 49 | } 50 | 51 | ipcMain.handle( 52 | 'analytics-capture', 53 | async (e, { distinctId, event, properties, type }: EventMessage) => { 54 | capture({ distinctId, event, properties, type }) 55 | } 56 | ) 57 | 58 | ipcMain.handle( 59 | 'analytics-capture-error', 60 | async (e, { distinctId, event, properties }: EventMessage) => { 61 | capture({ distinctId, event, properties }) 62 | } 63 | ) 64 | 65 | export { capture, EventMessage } 66 | -------------------------------------------------------------------------------- /src/config/autoformat/autoformatBlocks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutoformatRule, 3 | ELEMENT_BLOCKQUOTE, 4 | ELEMENT_CODE_BLOCK, 5 | ELEMENT_DEFAULT, 6 | ELEMENT_H1, 7 | ELEMENT_H2, 8 | ELEMENT_H3, 9 | ELEMENT_H4, 10 | ELEMENT_H5, 11 | ELEMENT_H6, 12 | ELEMENT_HR, 13 | getPluginType, 14 | insertEmptyCodeBlock, 15 | insertNodes, 16 | PlateEditor, 17 | setNodes, 18 | } from '@udecode/plate' 19 | import { clearBlockFormat } from './autoformatUtils' 20 | 21 | export const autoformatBlocks: AutoformatRule[] = [ 22 | { 23 | mode: 'block', 24 | type: ELEMENT_H1, 25 | match: '# ', 26 | preFormat: clearBlockFormat, 27 | }, 28 | { 29 | mode: 'block', 30 | type: ELEMENT_H2, 31 | match: '## ', 32 | preFormat: clearBlockFormat, 33 | }, 34 | { 35 | mode: 'block', 36 | type: ELEMENT_H3, 37 | match: '### ', 38 | preFormat: clearBlockFormat, 39 | }, 40 | // { 41 | // mode: 'block', 42 | // type: ELEMENT_H4, 43 | // match: '#### ', 44 | // preFormat: clearBlockFormat, 45 | // }, 46 | // { 47 | // mode: 'block', 48 | // type: ELEMENT_H5, 49 | // match: '##### ', 50 | // preFormat: clearBlockFormat, 51 | // }, 52 | // { 53 | // mode: 'block', 54 | // type: ELEMENT_H6, 55 | // match: '###### ', 56 | // preFormat: clearBlockFormat, 57 | // }, 58 | // { 59 | // mode: 'block', 60 | // type: ELEMENT_BLOCKQUOTE, 61 | // match: '> ', 62 | // preFormat: clearBlockFormat, 63 | // }, 64 | { 65 | mode: 'block', 66 | type: ELEMENT_HR, 67 | match: ['---', '—-'], 68 | preFormat: clearBlockFormat, 69 | format: (editor) => { 70 | setNodes(editor, { type: ELEMENT_HR }) 71 | insertNodes(editor, { 72 | type: ELEMENT_DEFAULT, 73 | children: [{ text: '' }], 74 | }) 75 | }, 76 | }, 77 | // { 78 | // mode: 'block', 79 | // type: ELEMENT_CODE_BLOCK, 80 | // match: '```', 81 | // triggerAtBlockStart: false, 82 | // preFormat: clearBlockFormat, 83 | // format: (editor) => { 84 | // insertEmptyCodeBlock(editor as PlateEditor, { 85 | // defaultType: getPluginType(editor as PlateEditor, ELEMENT_DEFAULT), 86 | // insertNodesOptions: { select: true }, 87 | // }) 88 | // }, 89 | // }, 90 | ] 91 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Billing/Receipts.tsx: -------------------------------------------------------------------------------- 1 | import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' 2 | import { logger, capitalize, stripeEpochToDate, displayAmount } from 'utils' 3 | import { theme } from 'themes' 4 | import { 5 | HeaderStyled, 6 | TextStyled, 7 | ActionsStyled, 8 | ActionStyled, 9 | ContentStyled, 10 | CardStyled, 11 | ReceiptsRowStyled, 12 | ReceiptsTableStyled, 13 | ReceiptsCellStyled, 14 | DownloadStyled, 15 | } from './styled' 16 | import type { PaymentMethodProps } from './types' 17 | import dayjs from 'dayjs' 18 | import relativeTime from 'dayjs/plugin/relativeTime' 19 | dayjs.extend(relativeTime) 20 | 21 | const Receipts = ({ billingInfo, isLoading }: PaymentMethodProps) => { 22 | const Items = () => { 23 | const { invoices } = billingInfo 24 | return ( 25 | <> 26 | {invoices.filter((invoice) => invoice.status == 'paid').length == 0 ? ( 27 | 'No receipts' 28 | ) : ( 29 | 30 | {invoices 31 | .filter((invoice) => invoice.status == 'paid') 32 | .map((invoice) => { 33 | return ( 34 | 35 | 36 | {dayjs(stripeEpochToDate(invoice.period_start)).format('MMM D YYYY') + 37 | ' - ' + 38 | dayjs(stripeEpochToDate(invoice.period_end)).format('MMM D YYYY')} 39 | 40 | {displayAmount(invoice.amount_paid)} 41 | {capitalize(invoice.status)} 42 | 43 | PDF↓ 44 | 45 | 46 | ) 47 | })} 48 | 49 | )} 50 | 51 | ) 52 | } 53 | 54 | return ( 55 | 56 | Receipts 57 | {isLoading ? : } 58 | 59 | ) 60 | } 61 | 62 | export { Receipts } 63 | -------------------------------------------------------------------------------- /src/components/Entry/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { theme } from 'themes' 3 | import { breakpoints } from 'utils' 4 | 5 | const Container = styled.div` 6 | display: flex; 7 | padding: 32px 0 32px 40px; 8 | word-break: break-word; 9 | @media ${breakpoints.s} { 10 | padding: 16px 24px; 11 | } 12 | ` 13 | 14 | const MainWrapper = styled.div` 15 | width: min-content; 16 | contain: layout; 17 | flex-grow: 1; 18 | padding: 0 80px 0 0; 19 | font-size: ${theme('appearance.fontSize')}; 20 | font-family: ${theme('appearance.fontFace')}; 21 | font-weight: 500; 22 | line-height: 30px; 23 | -webkit-app-region: no-drag; 24 | & > div:nth-child(2) > h1:first-child, 25 | & > div:nth-child(2) > h2:first-child, 26 | & > div:nth-child(2) > h3:first-child, 27 | & > div:nth-child(2) > div:first-child > h1:first-child, 28 | & > div:nth-child(2) > div:first-child > h2:first-child, 29 | & > div:nth-child(2) > div:first-child > h3:first-child { 30 | margin-block-start: 0; 31 | } 32 | & > * { 33 | max-width: 75ch; 34 | color: ${theme('color.primary.main')}; 35 | } 36 | @media ${breakpoints.s} { 37 | padding: 0; 38 | } 39 | ` 40 | const MiniDate = styled.div` 41 | padding: 0 0 8px 0; 42 | margin: 0; 43 | opacity: 0.3; 44 | visibility: ${theme('appearance.miniDatesVisibility')}; 45 | color: ${theme('color.primary.main')}; 46 | font-size: 12px; 47 | font-family: 'Inter var'; 48 | line-height: 16px; 49 | ` 50 | 51 | const LimitReachedWrapperStyled = styled.div` 52 | display: flex; 53 | flex-direction: column; 54 | gap: 8px; 55 | ` 56 | 57 | const LimitReachedTextStyled = styled.div` 58 | opacity: 0.4; 59 | ` 60 | 61 | const UpgradeButtonStyled = styled.button` 62 | font-weight: 500; 63 | font-size: 14px; 64 | line-height: 24px; 65 | padding: 2px 8px; 66 | color: ${theme('color.primary.main')}; 67 | border: 1px solid ${theme('color.primary.main')}; 68 | border-radius: 6px; 69 | width: fit-content; 70 | background: transparent; 71 | cursor: pointer; 72 | outline: 0; 73 | &:hover, 74 | &:focus { 75 | box-shadow: 0 0 0 3px ${theme('color.primary.main', 0.2)}; 76 | transition: box-shadow ${theme('animation.time.normal')} ease; 77 | } 78 | &:disabled { 79 | opacity: 0.6; 80 | cursor: default; 81 | } 82 | ` 83 | 84 | export { 85 | Container, 86 | MainWrapper, 87 | MiniDate, 88 | UpgradeButtonStyled, 89 | LimitReachedTextStyled, 90 | LimitReachedWrapperStyled, 91 | } 92 | -------------------------------------------------------------------------------- /src/components/Icon/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Check } from './Check' 3 | import { Cross } from './Cross' 4 | import { FormatBold } from './FormatBold' 5 | import { FormatItalic } from './FormatItalic' 6 | import { FormatUnderline } from './FormatUnderline' 7 | import { FormatStriketrough } from './FormatStriketrough' 8 | import { FormatCode } from './FormatCode' 9 | import { BlockText } from './BlockText' 10 | import { BlockH1 } from './BlockH1' 11 | import { BlockH2 } from './BlockH2' 12 | import { BlockH3 } from './BlockH3' 13 | import { BlockNumList } from './BlockNumList' 14 | import { BlockBulletList } from './BlockBulletList' 15 | import { Chevron } from './Chevron' 16 | import { Menu } from './Menu' 17 | import { Bucket } from './Bucket' 18 | import { Exit } from './Exit' 19 | import { TrafficLightOutline } from './TrafficLightOutline' 20 | import { TrafficLightCalendar } from './TrafficLightCalendar' 21 | import { RatingEmoji } from './RatingEmoji' 22 | import { RisedHands } from './RisedHands' 23 | import { UpdateNow } from './UpdateNow' 24 | import { FormatMark } from './FormatMark' 25 | import { FormatHandStriketrough } from './FormatHandStriketrough' 26 | import { Plus } from './Plus' 27 | import { Edit } from './Edit' 28 | import { Trash } from './Trash' 29 | import { Settings } from './Settings' 30 | import { Offline } from './Offline' 31 | import { CardBrand } from './CardBrand' 32 | 33 | type IconMapType = { 34 | [key: string]: any 35 | } 36 | 37 | interface IconProps extends React.HTMLAttributes { 38 | name: keyof IconMapType 39 | size?: number 40 | scale?: number 41 | active?: boolean 42 | tintColor?: any 43 | type?: any 44 | } 45 | 46 | const IconMap: IconMapType = { 47 | Empty, 48 | Check, 49 | Cross, 50 | FormatBold, 51 | FormatItalic, 52 | FormatUnderline, 53 | FormatStriketrough, 54 | FormatHandStriketrough, 55 | FormatCode, 56 | BlockText, 57 | BlockH1, 58 | BlockH2, 59 | BlockH3, 60 | BlockNumList, 61 | BlockBulletList, 62 | Chevron, 63 | Menu, 64 | Bucket, 65 | Exit, 66 | TrafficLightOutline, 67 | TrafficLightCalendar, 68 | RatingEmoji, 69 | RisedHands, 70 | UpdateNow, 71 | FormatMark, 72 | Plus, 73 | Edit, 74 | Trash, 75 | Settings, 76 | Offline, 77 | CardBrand, 78 | } 79 | 80 | const Icon = function (props: IconProps) { 81 | const { name, size, scale, ...rest }: IconProps = props 82 | const Drawing = IconMap[name] ? IconMap[name] : IconMap['Empty'] 83 | 84 | return 85 | } 86 | 87 | function Empty() { 88 | return <> 89 | } 90 | 91 | export { Icon } 92 | -------------------------------------------------------------------------------- /src/types/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | 3 | interface Product { 4 | id: string /* primary key */ 5 | active?: boolean 6 | name?: string 7 | description?: string 8 | image?: string 9 | metadata?: Stripe.Metadata 10 | } 11 | 12 | interface ProductWithPrice extends Product { 13 | prices?: Price[] 14 | } 15 | 16 | interface UserDetails { 17 | id: string /* primary key */ 18 | first_name: string 19 | last_name: string 20 | full_name?: string 21 | avatar_url?: string 22 | billing_address?: Stripe.Address 23 | payment_method?: Stripe.PaymentMethod[Stripe.PaymentMethod.Type] 24 | } 25 | 26 | interface Price { 27 | id: string /* primary key */ 28 | product_id?: string /* foreign key to products.id */ 29 | active?: boolean 30 | description?: string 31 | unit_amount?: number 32 | currency?: string 33 | type?: Stripe.Price.Type 34 | interval?: Stripe.Price.Recurring.Interval 35 | interval_count?: number 36 | trial_period_days?: number | null 37 | metadata?: Stripe.Metadata 38 | products?: Product 39 | } 40 | 41 | interface PriceWithProduct extends Price {} 42 | 43 | interface Subscription { 44 | id: string /* primary key */ 45 | user_id: string 46 | status?: Stripe.Subscription.Status 47 | metadata?: Stripe.Metadata 48 | price_id?: string /* foreign key to prices.id */ 49 | quantity?: number 50 | cancel_at_period_end?: boolean 51 | created: string 52 | current_period_start: string 53 | current_period_end: string 54 | ended_at?: string 55 | cancel_at?: string 56 | canceled_at?: string 57 | trial_start?: string 58 | trial_end?: string 59 | prices?: Price 60 | } 61 | 62 | interface Countries { 63 | country_code: string /* primary key */ 64 | country_name: string 65 | } 66 | 67 | interface CreateSubscriptionProps { 68 | access_token: string 69 | priceId: string 70 | address?: Stripe.Address 71 | } 72 | 73 | interface AddCardProps { 74 | access_token: string 75 | address: Stripe.Address 76 | } 77 | 78 | interface UpdateSubscriptionProps { 79 | access_token: string 80 | subscriptionId: string 81 | } 82 | 83 | interface PreviewInvoiceProps { 84 | access_token: string 85 | } 86 | 87 | interface BillingInfo { 88 | customer: Stripe.Customer 89 | card: Stripe.PaymentMethod | null 90 | invoices: Stripe.Invoice[] 91 | } 92 | 93 | export { 94 | Product, 95 | Price, 96 | Subscription, 97 | Countries, 98 | CreateSubscriptionProps, 99 | UpdateSubscriptionProps, 100 | PreviewInvoiceProps, 101 | AddCardProps, 102 | BillingInfo, 103 | } 104 | -------------------------------------------------------------------------------- /src/sql/schema.2.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- Create a table for Users 2 | create table 3 | users ( 4 | id text, 5 | full_name text, 6 | secret_key blob, 7 | subscription text, 8 | primary key (id) 9 | ); 10 | 11 | -- Create a table for journals_catalog 12 | create table 13 | journals_catalog ( 14 | user_id text, 15 | journal_id int default 0, 16 | name text not null default 'Default', 17 | color text, 18 | icon_name text, 19 | icon_url text, 20 | primary key (user_id, journal_id), 21 | foreign key (user_id) references users (id) on delete cascade on update no action 22 | ); 23 | 24 | -- Create a table for Journals 25 | create table 26 | journals ( 27 | user_id text, 28 | day date, 29 | journal_id int default 0, 30 | created_at datetime, 31 | modified_at datetime, 32 | content text, 33 | revision int not null default 0, 34 | sync_status text not null default 'synced', 35 | primary key (user_id, day, journal_id), 36 | foreign key (user_id) references users (id) on delete cascade on update no action, 37 | foreign key (user_id, journal_id) references journals_catalog (user_id, journal_id) on delete cascade on update no action 38 | ); 39 | 40 | -- Create a table for Tags 41 | create table 42 | tags ( 43 | user_id text, 44 | journal_id int default 0, 45 | id text unique, 46 | name text, 47 | color text, 48 | created_at datetime, 49 | modified_at datetime, 50 | revision int, 51 | sync_status text default 'synced', 52 | primary key (user_id, journal_id, id), 53 | foreign key (user_id, journal_id) references journals_catalog (user_id, journal_id) on delete cascade on update no action 54 | ); 55 | 56 | -- Create a table for Public Journal Tags 57 | create table 58 | entries_tags ( 59 | user_id text, 60 | day date, 61 | journal_id int default 0, 62 | tag_id text, 63 | order_no int not null default 0, 64 | created_at datetime, 65 | modified_at datetime, 66 | revision int, 67 | sync_status text default 'synced', 68 | primary key (user_id, day, journal_id, tag_id), 69 | foreign key (tag_id) references tags (id) on delete cascade on update no action 70 | -- foreign key (user_id, day, journal_id) references journals (user_id, day, journal_id) on delete cascade on update no action 71 | ); 72 | 73 | -- Create a table for user Preferences 74 | create table 75 | preferences ( 76 | user_id text, 77 | item text, 78 | value text, 79 | primary key (user_id, item), 80 | foreign key (user_id) references users (id) on delete cascade on update no action 81 | ); 82 | 83 | -- Create a table for app 84 | create table 85 | app (key text not null, value text, primary key (key)); -------------------------------------------------------------------------------- /src/components/Menu/Settings/AddCard/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import { Icon } from 'components' 5 | 6 | const FormStyled = styled.form` 7 | width: 368px; 8 | display: flex; 9 | flex-direction: column; 10 | gap: 16px; 11 | ` 12 | 13 | const Title = styled.div` 14 | font-style: normal; 15 | font-weight: 500; 16 | font-size: 18px; 17 | line-height: 28px; 18 | letter-spacing: -0.03em; 19 | ` 20 | 21 | const TextStyled = styled.div` 22 | font-weight: 500; 23 | font-size: 14px; 24 | line-height: 24px; 25 | ` 26 | 27 | const CheckoutModalStyled = styled.div` 28 | background-color: ${theme('color.popper.surface')}; 29 | display: flex; 30 | gap: 16px; 31 | flex-direction: column; 32 | position: relative; 33 | padding: 0; 34 | padding: 40px 32px; 35 | margin: 48px 8px 8px 8px; 36 | border-radius: 8px; 37 | -webkit-app-region: no-drag; 38 | ` 39 | const ActionsWrapperStyled = styled.div` 40 | display: flex; 41 | gap: 16px; 42 | height: 40px; 43 | ` 44 | 45 | const ButtonStyled = styled.button` 46 | background-color: ${theme('color.popper.main')}; 47 | color: ${theme('color.popper.inverted')}; 48 | outline: 0; 49 | border: 0; 50 | font-weight: 500; 51 | font-size: 14px; 52 | line-height: 24px; 53 | cursor: pointer; 54 | flex: 1; 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | padding: 8px 12px; 59 | border-radius: 6px; 60 | width: fit-content; 61 | transition: box-shadow ${theme('animation.time.normal')} ease; 62 | &:hover, 63 | &:focus { 64 | box-shadow: 0 0 0 2px ${theme('color.popper.main', 0.15)}; 65 | } 66 | &:disabled { 67 | opacity: 0.6; 68 | cursor: default; 69 | } 70 | ` 71 | 72 | const ButtonGhostStyled = styled.button` 73 | font-weight: 400; 74 | font-size: 14px; 75 | line-height: 24px; 76 | padding: 2px 8px; 77 | color: ${theme('color.primary.main')}; 78 | border: 0; 79 | border-radius: 6px; 80 | flex: 1; 81 | background: transparent; 82 | cursor: pointer; 83 | outline: 0; 84 | transition: ${theme('animation.time.normal')} ease; 85 | &:focus { 86 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 87 | } 88 | &:hover { 89 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 90 | background-color: ${theme('color.popper.border')}; 91 | } 92 | &:disabled { 93 | opacity: 0.6; 94 | cursor: default; 95 | } 96 | ` 97 | 98 | export { 99 | TextStyled, 100 | CheckoutModalStyled, 101 | ButtonStyled, 102 | Title, 103 | ActionsWrapperStyled, 104 | ButtonGhostStyled, 105 | FormStyled, 106 | } 107 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/ChangeCycle/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import * as Tabs from '@radix-ui/react-tabs' 3 | import styled, { keyframes } from 'styled-components' 4 | import { theme } from 'themes' 5 | import { Icon } from 'components' 6 | import { CardElement } from '@stripe/react-stripe-js' 7 | 8 | const FormStyled = styled.form` 9 | width: 280px; 10 | padding-right: 16px; 11 | display: flex; 12 | flex-direction: column; 13 | gap: 24px; 14 | ` 15 | 16 | const TextStyled = styled.div` 17 | font-weight: 500; 18 | font-size: 14px; 19 | line-height: 24px; 20 | ` 21 | 22 | const CheckoutModalStyled = styled.div` 23 | background-color: ${theme('color.popper.surface')}; 24 | display: flex; 25 | position: relative; 26 | padding: 0; 27 | padding: 40px 40px 32px 32px; 28 | margin: 48px 8px 8px 8px; 29 | border-radius: 8px; 30 | -webkit-app-region: no-drag; 31 | ` 32 | 33 | const ButtonStyled = styled.button` 34 | background-color: ${theme('color.popper.main')}; 35 | color: ${theme('color.popper.inverted')}; 36 | outline: 0; 37 | border: 0; 38 | font-weight: 500; 39 | font-size: 14px; 40 | line-height: 24px; 41 | cursor: pointer; 42 | display: flex; 43 | margin-top: 8px; 44 | padding: 8px 12px; 45 | border-radius: 6px; 46 | width: fit-content; 47 | transition: box-shadow ${theme('animation.time.normal')} ease; 48 | &:hover, 49 | &:focus { 50 | box-shadow: 0 0 0 2px ${theme('color.popper.main', 0.15)}; 51 | } 52 | &:disabled { 53 | opacity: 0.6; 54 | cursor: default; 55 | } 56 | ` 57 | 58 | const TableStyled = styled.div` 59 | letter-spacing: -0.03em; 60 | font-weight: 400; 61 | font-size: 14px; 62 | line-height: 24px; 63 | color: ${theme('color.popper.main')}; 64 | & em { 65 | font-weight: 500; 66 | font-size: 16px; 67 | font-style: normal; 68 | } 69 | ` 70 | 71 | interface RowStyledProps { 72 | padding?: string 73 | } 74 | 75 | const RowStyled = styled.div` 76 | display: flex; 77 | padding: ${(props) => (props.padding ? props.padding : '0')}; 78 | ` 79 | 80 | interface CellFillStyledProps { 81 | opacity?: string 82 | } 83 | 84 | const CellFillStyled = styled.div` 85 | flex: 1; 86 | opacity: ${(props) => (props.opacity ? props.opacity : '0.8')}; 87 | ` 88 | 89 | const Divider = styled.div` 90 | height: 1px; 91 | margin: 8px 0; 92 | background-color: ${theme('color.popper.border')}; 93 | ` 94 | 95 | const CellStyled = styled.div`` 96 | 97 | export { 98 | TextStyled, 99 | CheckoutModalStyled, 100 | ButtonStyled, 101 | RowStyled, 102 | CellFillStyled, 103 | CellStyled, 104 | TableStyled, 105 | Divider, 106 | FormStyled, 107 | } 108 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-text-100: rgba(55, 55, 55, 1); 3 | --color-text-50: #666666; 4 | --color-main-surface: #e5e5e5; 5 | --prompt-opacity: 1; 6 | } 7 | 8 | @font-face { 9 | font-family: 'Inter var'; 10 | font-weight: 100 900; 11 | font-display: swap; 12 | font-style: oblique 0deg 10deg; 13 | src: url('../assets/fonts/Inter.var.woff2?v=3.19') format('woff2'); 14 | } 15 | 16 | @font-face { 17 | font-family: 'Novela'; 18 | src: url('../assets/fonts/novela-regular-webfont.woff2') format('woff2'); 19 | font-weight: normal; 20 | font-style: normal; 21 | } 22 | 23 | @font-face { 24 | font-family: 'Novela'; 25 | src: url('../assets/fonts/novela-regularitalic-webfont.woff2') format('woff2'); 26 | font-weight: normal; 27 | font-style: italic; 28 | } 29 | 30 | @font-face { 31 | font-family: 'Novela'; 32 | src: url('../assets/fonts/novela-bold-webfont.woff2') format('woff2'); 33 | font-weight: bold; 34 | font-style: normal; 35 | } 36 | 37 | @font-face { 38 | font-family: 'Novela'; 39 | src: url('../assets/fonts/novela-bolditalic-webfont.woff2') format('woff2'); 40 | font-weight: bold; 41 | font-style: italic; 42 | } 43 | 44 | body { 45 | margin: 0; 46 | padding: 0; 47 | } 48 | 49 | html { 50 | font-family: 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, 51 | sans-serif; 52 | font-weight: 400; 53 | font-size: 16px; 54 | font-style: normal; 55 | line-height: 28px; 56 | } 57 | 58 | button, 59 | textarea, 60 | input { 61 | font-family: 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, 62 | sans-serif; 63 | } 64 | 65 | strong { 66 | font-weight: 700; 67 | } 68 | 69 | @keyframes react-loading-skeleton { 70 | 100% { 71 | transform: translateX(100%); 72 | } 73 | } 74 | 75 | .react-loading-skeleton { 76 | --base-color: #ebebeb; 77 | --highlight-color: #f5f5f5; 78 | --animation-duration: 1.5s; 79 | --animation-direction: normal; 80 | --pseudo-element-display: block; /* Enable animation */ 81 | 82 | background-color: var(--base-color); 83 | 84 | width: 100%; 85 | border-radius: 0.25rem; 86 | display: inline-flex; 87 | line-height: 1; 88 | 89 | position: relative; 90 | overflow: hidden; 91 | z-index: 1; /* Necessary for overflow: hidden to work correctly in Safari */ 92 | } 93 | 94 | .react-loading-skeleton::after { 95 | content: ' '; 96 | display: var(--pseudo-element-display); 97 | position: absolute; 98 | top: 0; 99 | left: 0; 100 | right: 0; 101 | height: 100%; 102 | background-repeat: no-repeat; 103 | background-image: linear-gradient( 104 | 90deg, 105 | var(--base-color), 106 | var(--highlight-color), 107 | var(--base-color) 108 | ); 109 | transform: translateX(-100%); 110 | 111 | animation-name: react-loading-skeleton; 112 | animation-direction: var(--animation-direction); 113 | animation-duration: var(--animation-duration); 114 | animation-timing-function: ease-in-out; 115 | animation-iteration-count: infinite; 116 | } 117 | -------------------------------------------------------------------------------- /src/components/Entry/EntryMenu/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { theme } from 'themes' 3 | import { logger } from 'utils' 4 | import { 5 | useFloating, 6 | FloatingOverlay, 7 | useInteractions, 8 | useDismiss, 9 | useClick, 10 | FloatingFocusManager, 11 | useFloatingNodeId, 12 | FloatingNode, 13 | FloatingPortal, 14 | } from '@floating-ui/react-dom-interactions' 15 | import { 16 | IconCloseStyled, 17 | ModalStyled, 18 | ButtonDestructiveStyled, 19 | ButtonStyled, 20 | ButtonGhostStyled, 21 | TitleStyled, 22 | DescriptionStyled, 23 | ActionsWrapperStyled, 24 | } from './styled' 25 | 26 | interface ModalProps { 27 | setOpenModal: React.Dispatch> 28 | action: any 29 | } 30 | 31 | const Modal = ({ setOpenModal, action }: ModalProps) => { 32 | const [open, setOpen] = useState(true) 33 | const actionRef = useRef(null) 34 | const nodeId = useFloatingNodeId() 35 | 36 | const { reference, floating, context, refs } = useFloating({ 37 | open, 38 | onOpenChange: setOpen, 39 | nodeId, 40 | }) 41 | 42 | const { getReferenceProps, getFloatingProps } = useInteractions([ 43 | useClick(context), 44 | useDismiss(context, { 45 | escapeKey: false, 46 | }), 47 | ]) 48 | 49 | const handleCloseEsc = (e: any) => { 50 | if (e.key == 'Escape') { 51 | if (refs.floating.current && refs.floating.current.contains(document.activeElement)) { 52 | setOpenModal(false) 53 | } 54 | } 55 | } 56 | 57 | useEffect(() => { 58 | logger('✅ addEventListener') 59 | document.addEventListener('keydown', handleCloseEsc) 60 | setTimeout(() => { 61 | actionRef.current.focus() 62 | }, 200) 63 | 64 | return () => { 65 | logger('❌ removeEventListener') 66 | document.removeEventListener('keydown', handleCloseEsc) 67 | } 68 | }, []) 69 | 70 | return ( 71 | 72 | 73 | 82 | 83 | 84 | Remove this entry permanently? 85 | This can't be undone. 86 | 87 | action()}>Remove 88 | setOpenModal(false)}> 89 | Cancel 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | export { Modal } 101 | -------------------------------------------------------------------------------- /src/sql/migration.1-to-2.sql: -------------------------------------------------------------------------------- 1 | -- Create a table for journals_catalog 2 | create table if not exists 3 | journals_catalog ( 4 | user_id text, 5 | journal_id int default 0, 6 | name text not null default 'Default', 7 | color text, 8 | icon_name text, 9 | icon_url text, 10 | primary key (user_id, journal_id), 11 | foreign key (user_id) references users (id) on delete cascade on update no action 12 | ); 13 | 14 | -- Create default journal catalog (journal_id=0) for existing users 15 | insert into 16 | journals_catalog (user_id) 17 | select 18 | id 19 | from 20 | users 21 | where 22 | true on conflict (user_id, journal_id) 23 | do nothing; 24 | 25 | -- update table journals to enable multiple journals per user 26 | -- In SQLite, you can not use the ALTER TABLE statement to drop a primary key. 27 | -- Instead, you must create a new table with the primary key removed and copy the data into this new table. 28 | pragma foreign_keys = off; 29 | 30 | begin transaction; 31 | 32 | alter table journals 33 | rename to old_journals; 34 | 35 | create table 36 | journals ( 37 | user_id text, 38 | day date, 39 | journal_id int default 0, 40 | created_at datetime, 41 | modified_at datetime, 42 | content text, 43 | revision int not null default 0, 44 | sync_status text not null default 'synced', 45 | primary key (user_id, day, journal_id), 46 | foreign key (user_id) references users (id) on delete cascade on update no action, 47 | foreign key (user_id, journal_id) references journals_catalog (user_id, journal_id) on delete cascade on update no action 48 | ); 49 | 50 | insert into 51 | journals (user_id, day, created_at, modified_at, content) 52 | select 53 | user_id, 54 | day, 55 | created_at, 56 | modified_at, 57 | content 58 | from 59 | old_journals; 60 | 61 | commit; 62 | 63 | pragma foreign_keys = on; 64 | 65 | -- Create a table for Tags 66 | create table if not exists 67 | tags ( 68 | user_id text, 69 | journal_id int default 0, 70 | id text unique, 71 | name text, 72 | color text, 73 | created_at datetime, 74 | modified_at datetime, 75 | revision int, 76 | sync_status text default 'synced', 77 | primary key (user_id, journal_id, id), 78 | foreign key (user_id, journal_id) references journals_catalog (user_id, journal_id) on delete cascade on update no action 79 | ); 80 | 81 | -- Create a table for Public Journal Tags 82 | create table if not exists 83 | entries_tags ( 84 | user_id text, 85 | day date, 86 | journal_id int default 0, 87 | tag_id text, 88 | order_no int not null default 0, 89 | created_at datetime, 90 | modified_at datetime, 91 | revision int, 92 | sync_status text default 'synced', 93 | primary key (user_id, day, journal_id, tag_id), 94 | foreign key (tag_id) references tags (id) on delete cascade on update no action 95 | -- foreign key (user_id, day, journal_id) references journals (user_id, day, journal_id) on delete cascade on update no action 96 | ); 97 | 98 | -- Add subscription column to cache it 99 | alter table users 100 | add subscription text; -------------------------------------------------------------------------------- /src/components/Menu/Settings/Billing/PaymentMethod.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { useQuery } from '@tanstack/react-query' 3 | import { loadStripe } from '@stripe/stripe-js/pure' 4 | import { Elements } from '@stripe/react-stripe-js' 5 | import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' 6 | import { Icon } from 'components' 7 | import { theme } from 'themes' 8 | import { AddCard } from '../AddCard' 9 | import { logger, capitalize, stripeEpochToDate, isDev } from 'utils' 10 | import { 11 | HeaderStyled, 12 | TextStyled, 13 | ActionsStyled, 14 | ActionStyled, 15 | ContentStyled, 16 | CardStyled, 17 | ReceiptsRowStyled, 18 | } from './styled' 19 | import type { PaymentMethodProps } from './types' 20 | import type { BillingInfo } from 'types' 21 | 22 | const PaymentMethod = ({ billingInfo, isLoading, showCardOnly = false }: PaymentMethodProps) => { 23 | const [stripePromise, setStripePromise] = useState(null) 24 | const card = billingInfo?.card 25 | 26 | useQuery({ 27 | queryKey: ['stripePromise'], 28 | queryFn: async () => { 29 | const url = isDev() ? 'https://s.journal.local' : 'https://s.journal.do' 30 | const { publishableKey } = await fetch(`${url}/api/v1/config`).then((r) => r.json()) 31 | setStripePromise(() => loadStripe(publishableKey)) 32 | return publishableKey 33 | }, 34 | }) 35 | 36 | const Card = () => { 37 | if (card) { 38 | const expire = card.card.exp_month + '/' + card.card.exp_year.toString().substring(2) 39 | const brand = capitalize(card.card.brand) 40 | const last4 = card.card.last4 41 | return ( 42 | 43 | 44 | 45 | {brand} **** {last4} 46 | 47 | expiring {expire} 48 | 49 | ) 50 | } 51 | return No card added 52 | } 53 | 54 | return ( 55 | 56 | {!showCardOnly && Payment method} 57 | 58 | {isLoading ? : } 59 | {!showCardOnly && 60 | (isLoading ? ( 61 | 62 | ) : card ? ( 63 | 64 | 65 | ( 68 | 69 | Update 70 | 71 | )} 72 | /> 73 | 74 | 75 | ) : ( 76 | 77 | 78 | ( 80 | 81 | Add card 82 | 83 | )} 84 | /> 85 | 86 | 87 | ))} 88 | 89 | 90 | ) 91 | } 92 | 93 | export { PaymentMethod } 94 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Billing/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled from 'styled-components' 3 | import { theme } from 'themes' 4 | 5 | const HeaderStyled = styled.div` 6 | font-weight: 500; 7 | font-size: 16px; 8 | line-height: 24px; 9 | letter-spacing: -0.03em; 10 | color: ${theme('color.popper.main')}; 11 | margin-bottom: 8px; 12 | margin-top: 32px; 13 | ` 14 | 15 | const TextStyled = styled.div` 16 | flex-grow: 1; 17 | font-weight: 400; 18 | font-size: 12px; 19 | line-height: 20px; 20 | & em { 21 | font-weight: 500; 22 | font-style: normal; 23 | } 24 | & strong { 25 | font-weight: 500; 26 | font-size: 14px; 27 | line-height: 24px; 28 | } 29 | ` 30 | 31 | const ActionsStyled = styled.div` 32 | display: flex; 33 | flex-wrap: wrap; 34 | justify-content: end; 35 | align-self: flex-start; 36 | row-gap: 2px; 37 | gap: 4px; 38 | ` 39 | type ActionStyledProps = { 40 | isHidden?: boolean 41 | } 42 | 43 | const ActionStyled = styled.button` 44 | ${(props) => (props.isHidden ? 'display: none;' : '')}; 45 | font-weight: 400; 46 | font-size: 12px; 47 | line-height: 15px; 48 | white-space: nowrap; 49 | text-align: right; 50 | padding: 4px 6px; 51 | height: fit-content; 52 | color: ${theme('color.primary.main')}; 53 | opacity: 0.8; 54 | border: 0; 55 | border-radius: 100px; 56 | background: transparent; 57 | cursor: pointer; 58 | outline: 0; 59 | transition: ${theme('animation.time.normal')}; 60 | &:hover, 61 | &:focus { 62 | background-color: ${theme('color.popper.hover')}; 63 | opacity: 1; 64 | } 65 | ` 66 | 67 | const ContentStyled = styled.div` 68 | display: flex; 69 | ` 70 | 71 | const CardStyled = styled.div` 72 | display: flex; 73 | align-items: center; 74 | gap: 8px; 75 | & em { 76 | font-weight: 500; 77 | font-style: normal; 78 | } 79 | ` 80 | 81 | const ReceiptsTableStyled = styled.div` 82 | &:first-child { 83 | border-top: 1px solid ${theme('color.popper.border')}; 84 | } 85 | > * { 86 | border-bottom: 1px solid ${theme('color.popper.border')}; 87 | } 88 | ` 89 | 90 | const ReceiptsRowStyled = styled.div` 91 | display: flex; 92 | padding: 8px 0; 93 | gap: 16px; 94 | ` 95 | 96 | const ReceiptsCellStyled = styled.div` 97 | &:first-child { 98 | flex-grow: 1; 99 | } 100 | ` 101 | 102 | const DownloadStyled = styled.a` 103 | font-weight: 400; 104 | font-size: 12px; 105 | line-height: 15px; 106 | padding: 4px 6px; 107 | color: ${theme('color.primary.main')}; 108 | opacity: 0.8; 109 | border: 0; 110 | border-radius: 100px; 111 | background: transparent; 112 | text-decoration: none; 113 | cursor: pointer; 114 | outline: 0; 115 | transition: ${theme('animation.time.normal')}; 116 | &:hover, 117 | &:focus { 118 | background-color: ${theme('color.popper.hover')}; 119 | opacity: 1; 120 | } 121 | ` 122 | 123 | const Divider = styled.div` 124 | background-color: ${theme('color.popper.border')}; 125 | height: 1px; 126 | margin: 32px 0; 127 | ` 128 | 129 | export { 130 | HeaderStyled, 131 | TextStyled, 132 | ActionsStyled, 133 | ActionStyled, 134 | ContentStyled, 135 | CardStyled, 136 | ReceiptsRowStyled, 137 | ReceiptsTableStyled, 138 | ReceiptsCellStyled, 139 | Divider, 140 | DownloadStyled, 141 | } 142 | -------------------------------------------------------------------------------- /src/components/FeedbackWidget/RatingEmojiControl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { Icon } from 'components' 4 | 5 | interface RatingEmojiContainerProps { 6 | borderRadius?: string 7 | } 8 | 9 | const RatingEmojiContainer = styled.div` 10 | display: flex; 11 | align-items: center; 12 | gap: 8px; 13 | justify-content: space-evenly; 14 | border: 0; 15 | border-radius: ${(props) => props.borderRadius || '0'}; 16 | box-sizing: border-box; 17 | padding: 24px; 18 | width: 100%; 19 | margin: 0 0 1px 0; 20 | & svg { 21 | transition: 0.1s ease-out; 22 | padding: 8px; 23 | } 24 | ` 25 | 26 | const emojiJump = keyframes` 27 | 0% { 28 | transform: scale(1.4); 29 | } 30 | 50% { 31 | transform: scale(1.5) translateY(-10px); 32 | } 33 | 100% { 34 | transform: scale(1.4); 35 | } 36 | ` 37 | 38 | const IconEmoji = styled((props) => )` 39 | cursor: pointer; 40 | &:focus { 41 | outline: none; 42 | animation-name: ${emojiJump}; 43 | animation-duration: 0.4s; 44 | animation-timing-function: cubic-bezier(0.17, 0.18, 0.41, 0.99); 45 | animation-fill-mode: both; 46 | } 47 | ` 48 | 49 | type RatingEmojiControlProps = { 50 | onClickFunc: (type: string) => void 51 | shouldReset: boolean 52 | // cachedEntry?: any 53 | // ref?: any 54 | // setEntryHeight: (id: string, height: number) => void 55 | // setCachedEntry: (property: string, value: any) => void 56 | // shouldScrollToDay: (day: string) => boolean 57 | // clearScrollToDay: () => void 58 | } 59 | 60 | function RatingEmojiControl({ onClickFunc, shouldReset }: RatingEmojiControlProps) { 61 | const emojis = ['angry', 'thinking', 'neutral', 'happy', 'love'] 62 | 63 | const [hover, setHover] = useState('') 64 | const [selected, setSelected] = useState('') 65 | 66 | function whatStyle(type: string) { 67 | if (hover) { 68 | if (hover == type) { 69 | return { transform: 'scale(1.4)' } 70 | } else { 71 | return { transform: 'scale(1)' } 72 | } 73 | } 74 | if (selected) { 75 | if (selected == type) { 76 | return { transform: 'scale(1.4)' } 77 | } else { 78 | return { opacity: '0.5' } 79 | } 80 | } 81 | } 82 | 83 | function onClick(type: string) { 84 | setSelected(type) 85 | onClickFunc(type) 86 | } 87 | 88 | function onKeyPress(e: React.KeyboardEvent, type: string) { 89 | if (e.key == 'Enter') { 90 | setSelected(type) 91 | onClickFunc(type) 92 | } 93 | } 94 | 95 | useEffect(() => { 96 | setSelected('') 97 | }, [shouldReset]) 98 | 99 | return ( 100 | 101 | {/* {console.count('Render count:')} */} 102 | {emojis.map((type, i) => ( 103 | onClick(type)} 109 | onKeyPress={(e: any) => onKeyPress(e, type)} 110 | onMouseEnter={() => setHover(type)} 111 | onMouseLeave={() => setHover('')} 112 | key={`emojiRating-${i}`} 113 | /> 114 | ))} 115 | 116 | ) 117 | } 118 | 119 | export { RatingEmojiControl } 120 | -------------------------------------------------------------------------------- /src/components/Entry/EntryMenu/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import { Icon } from 'components' 5 | 6 | const IconCloseStyled = styled((props) => )` 7 | position: absolute; 8 | top: 16px; 9 | right: 16px; 10 | opacity: 0.8; 11 | cursor: pointer; 12 | &:hover { 13 | opacity: 1; 14 | color: ${theme('color.primary.main')}; 15 | } 16 | ` 17 | 18 | const ModalStyled = styled.div` 19 | background-color: ${theme('color.popper.surface')}; 20 | width: 330px; 21 | display: flex; 22 | box-sizing: border-box; 23 | flex-direction: column; 24 | position: relative; 25 | padding: 0; 26 | padding: 24px 24px 16px 24px; 27 | margin: 48px 8px 8px 8px; 28 | border-radius: 12px; 29 | -webkit-app-region: no-drag; 30 | ` 31 | 32 | const ButtonDestructiveStyled = styled.button` 33 | font-weight: 500; 34 | font-size: 14px; 35 | line-height: 24px; 36 | padding: 2px 8px; 37 | color: ${theme('color.error.main')}; 38 | border: 1px solid ${theme('color.error.main')}; 39 | border-radius: 6px; 40 | flex: 1; 41 | background: transparent; 42 | cursor: pointer; 43 | outline: 0; 44 | &:hover, 45 | &:focus { 46 | box-shadow: 0 0 0 3px ${theme('color.error.main', 0.2)}; 47 | transition: box-shadow ${theme('animation.time.normal')} ease; 48 | } 49 | &:disabled { 50 | opacity: 0.6; 51 | cursor: default; 52 | } 53 | ` 54 | 55 | const ButtonStyled = styled.button` 56 | background-color: ${theme('color.popper.main')}; 57 | color: ${theme('color.popper.inverted')}; 58 | outline: 0; 59 | border: 0; 60 | font-weight: 500; 61 | font-size: 14px; 62 | line-height: 24px; 63 | cursor: pointer; 64 | padding: 2px 8px; 65 | border-radius: 6px; 66 | flex: 1; 67 | transition: box-shadow ${theme('animation.time.normal')} ease; 68 | &:hover, 69 | &:focus { 70 | box-shadow: 0 0 0 3px ${theme('color.popper.main', 0.15)}; 71 | } 72 | &:disabled { 73 | opacity: 0.6; 74 | cursor: default; 75 | } 76 | ` 77 | 78 | const ButtonGhostStyled = styled.button` 79 | font-weight: 400; 80 | font-size: 14px; 81 | line-height: 24px; 82 | padding: 2px 8px; 83 | color: ${theme('color.primary.main')}; 84 | border: 0; 85 | border-radius: 6px; 86 | flex: 1; 87 | background: transparent; 88 | cursor: pointer; 89 | outline: 0; 90 | transition: ${theme('animation.time.normal')} ease; 91 | &:focus { 92 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 93 | } 94 | &:hover { 95 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 96 | background-color: ${theme('color.popper.border')}; 97 | } 98 | &:disabled { 99 | opacity: 0.6; 100 | cursor: default; 101 | } 102 | ` 103 | 104 | const TitleStyled = styled.div` 105 | font-style: normal; 106 | font-weight: 500; 107 | font-size: 18px; 108 | line-height: 28px; 109 | margin-bottom: 8px; 110 | letter-spacing: -0.03em; 111 | ` 112 | 113 | const DescriptionStyled = styled.div` 114 | font-weight: 400; 115 | font-size: 12px; 116 | line-height: 18px; 117 | ` 118 | 119 | const ActionsWrapperStyled = styled.div` 120 | display: flex; 121 | gap: 16px; 122 | margin-top: 32px; 123 | height: 38px; 124 | ` 125 | 126 | export { 127 | IconCloseStyled, 128 | ModalStyled, 129 | ButtonDestructiveStyled, 130 | ButtonStyled, 131 | ButtonGhostStyled, 132 | TitleStyled, 133 | DescriptionStyled, 134 | ActionsWrapperStyled, 135 | } 136 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/CancelImmediately/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import { Icon } from 'components' 5 | 6 | const IconCloseStyled = styled((props) => )` 7 | position: absolute; 8 | top: 16px; 9 | right: 16px; 10 | opacity: 0.8; 11 | cursor: pointer; 12 | &:hover { 13 | opacity: 1; 14 | color: ${theme('color.primary.main')}; 15 | } 16 | ` 17 | 18 | const ModalStyled = styled.div` 19 | background-color: ${theme('color.popper.surface')}; 20 | width: 330px; 21 | display: flex; 22 | box-sizing: border-box; 23 | flex-direction: column; 24 | position: relative; 25 | padding: 0; 26 | padding: 24px 24px 16px 24px; 27 | margin: 48px 8px 8px 8px; 28 | border-radius: 12px; 29 | -webkit-app-region: no-drag; 30 | ` 31 | 32 | const ButtonDestructiveStyled = styled.button` 33 | font-weight: 500; 34 | font-size: 14px; 35 | line-height: 24px; 36 | padding: 2px 8px; 37 | color: ${theme('color.error.main')}; 38 | border: 1px solid ${theme('color.error.main')}; 39 | border-radius: 6px; 40 | flex: 1; 41 | background: transparent; 42 | cursor: pointer; 43 | outline: 0; 44 | &:hover, 45 | &:focus { 46 | box-shadow: 0 0 0 3px ${theme('color.error.main', 0.2)}; 47 | transition: box-shadow ${theme('animation.time.normal')} ease; 48 | } 49 | &:disabled { 50 | opacity: 0.6; 51 | cursor: default; 52 | } 53 | ` 54 | 55 | const ButtonStyled = styled.button` 56 | background-color: ${theme('color.popper.main')}; 57 | color: ${theme('color.popper.inverted')}; 58 | outline: 0; 59 | border: 0; 60 | font-weight: 500; 61 | font-size: 14px; 62 | line-height: 24px; 63 | cursor: pointer; 64 | padding: 2px 8px; 65 | border-radius: 6px; 66 | flex: 1; 67 | transition: box-shadow ${theme('animation.time.normal')} ease; 68 | &:hover, 69 | &:focus { 70 | box-shadow: 0 0 0 3px ${theme('color.popper.main', 0.15)}; 71 | } 72 | &:disabled { 73 | opacity: 0.6; 74 | cursor: default; 75 | } 76 | ` 77 | 78 | const ButtonGhostStyled = styled.button` 79 | font-weight: 400; 80 | font-size: 14px; 81 | line-height: 24px; 82 | padding: 2px 8px; 83 | color: ${theme('color.primary.main')}; 84 | border: 0; 85 | border-radius: 6px; 86 | flex: 1; 87 | background: transparent; 88 | cursor: pointer; 89 | outline: 0; 90 | transition: ${theme('animation.time.normal')} ease; 91 | &:focus { 92 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 93 | } 94 | &:hover { 95 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 96 | background-color: ${theme('color.popper.border')}; 97 | } 98 | &:disabled { 99 | opacity: 0.6; 100 | cursor: default; 101 | } 102 | ` 103 | 104 | const TitleStyled = styled.div` 105 | font-style: normal; 106 | font-weight: 500; 107 | font-size: 18px; 108 | line-height: 28px; 109 | margin-bottom: 8px; 110 | letter-spacing: -0.03em; 111 | ` 112 | 113 | const DescriptionStyled = styled.div` 114 | font-weight: 400; 115 | font-size: 12px; 116 | line-height: 18px; 117 | ` 118 | 119 | const ActionsWrapperStyled = styled.div` 120 | display: flex; 121 | gap: 16px; 122 | margin-top: 32px; 123 | height: 38px; 124 | ` 125 | 126 | export { 127 | IconCloseStyled, 128 | ModalStyled, 129 | ButtonDestructiveStyled, 130 | ButtonStyled, 131 | ButtonGhostStyled, 132 | TitleStyled, 133 | DescriptionStyled, 134 | ActionsWrapperStyled, 135 | } 136 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/CancelOrResume/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import { Icon } from 'components' 5 | 6 | const IconCloseStyled = styled((props) => )` 7 | position: absolute; 8 | top: 16px; 9 | right: 16px; 10 | opacity: 0.8; 11 | cursor: pointer; 12 | &:hover { 13 | opacity: 1; 14 | color: ${theme('color.primary.main')}; 15 | } 16 | ` 17 | 18 | const ModalStyled = styled.div` 19 | background-color: ${theme('color.popper.surface')}; 20 | width: 330px; 21 | display: flex; 22 | box-sizing: border-box; 23 | flex-direction: column; 24 | position: relative; 25 | padding: 0; 26 | padding: 24px 24px 16px 24px; 27 | margin: 48px 8px 8px 8px; 28 | border-radius: 12px; 29 | -webkit-app-region: no-drag; 30 | ` 31 | 32 | const ButtonDestructiveStyled = styled.button` 33 | font-weight: 500; 34 | font-size: 14px; 35 | line-height: 24px; 36 | padding: 2px 8px; 37 | color: ${theme('color.error.main')}; 38 | border: 1px solid ${theme('color.error.main')}; 39 | border-radius: 6px; 40 | flex: 1; 41 | background: transparent; 42 | cursor: pointer; 43 | outline: 0; 44 | &:hover, 45 | &:focus { 46 | box-shadow: 0 0 0 3px ${theme('color.error.main', 0.2)}; 47 | transition: box-shadow ${theme('animation.time.normal')} ease; 48 | } 49 | &:disabled { 50 | opacity: 0.6; 51 | cursor: default; 52 | } 53 | ` 54 | 55 | const ButtonStyled = styled.button` 56 | background-color: ${theme('color.popper.main')}; 57 | color: ${theme('color.popper.inverted')}; 58 | outline: 0; 59 | border: 0; 60 | font-weight: 500; 61 | font-size: 14px; 62 | line-height: 24px; 63 | cursor: pointer; 64 | padding: 2px 8px; 65 | border-radius: 6px; 66 | flex: 1; 67 | transition: box-shadow ${theme('animation.time.normal')} ease; 68 | &:hover, 69 | &:focus { 70 | box-shadow: 0 0 0 3px ${theme('color.popper.main', 0.15)}; 71 | } 72 | &:disabled { 73 | opacity: 0.6; 74 | cursor: default; 75 | } 76 | ` 77 | 78 | const ButtonGhostStyled = styled.button` 79 | font-weight: 400; 80 | font-size: 14px; 81 | line-height: 24px; 82 | padding: 2px 8px; 83 | color: ${theme('color.primary.main')}; 84 | border: 0; 85 | border-radius: 6px; 86 | flex: 1; 87 | background: transparent; 88 | cursor: pointer; 89 | outline: 0; 90 | transition: ${theme('animation.time.normal')} ease; 91 | &:focus { 92 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 93 | } 94 | &:hover { 95 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 96 | background-color: ${theme('color.popper.border')}; 97 | } 98 | &:disabled { 99 | opacity: 0.6; 100 | cursor: default; 101 | } 102 | ` 103 | 104 | const TitleStyled = styled.div` 105 | font-style: normal; 106 | font-weight: 500; 107 | font-size: 18px; 108 | line-height: 28px; 109 | margin-bottom: 8px; 110 | letter-spacing: -0.03em; 111 | ` 112 | 113 | const DescriptionStyled = styled.div` 114 | font-weight: 400; 115 | font-size: 12px; 116 | line-height: 18px; 117 | ` 118 | 119 | const ActionsWrapperStyled = styled.div` 120 | display: flex; 121 | gap: 16px; 122 | margin-top: 32px; 123 | height: 38px; 124 | ` 125 | 126 | export { 127 | IconCloseStyled, 128 | ModalStyled, 129 | ButtonDestructiveStyled, 130 | ButtonStyled, 131 | ButtonGhostStyled, 132 | TitleStyled, 133 | DescriptionStyled, 134 | ActionsWrapperStyled, 135 | } 136 | -------------------------------------------------------------------------------- /schemas/schema.postgresql.sql: -------------------------------------------------------------------------------- 1 | -- Create a table for Public Profiles 2 | create table 3 | profiles ( 4 | user_id uuid references auth.users (id) not null, 5 | modified_at timestamp with time zone default now(), 6 | avatar_url text, 7 | full_name text, 8 | primary key (user_id) 9 | ); 10 | 11 | alter table 12 | profiles enable row level security; 13 | 14 | create policy 15 | "Users can manipulate only their own profiles" on profiles for insert 16 | with 17 | check (auth.uid () = user_id); 18 | 19 | -- Create a table for Public Journals 20 | create table 21 | journals ( 22 | user_id uuid references auth.users (id) not null, 23 | day date not null, 24 | created_at timestamp with time zone default now(), 25 | modified_at timestamp with time zone default now(), 26 | content bytea, 27 | iv bytea, 28 | primary key (user_id, day) 29 | ) 30 | partition by 31 | LIST (user_id); 32 | 33 | alter table 34 | journals enable row level security; 35 | 36 | create policy 37 | "Users can manipulate only their own journals" on journals for all using (auth.uid () = user_id); 38 | 39 | create 40 | or replace function create_user_partition () returns trigger as $create_user_partition$ 41 | BEGIN 42 | EXECUTE 'CREATE TABLE public.journals_' || replace(new.id::text,'-','_') || ' PARTITION OF public.journals FOR VALUES IN (''' || new.id || ''')'; 43 | RETURN NEW; 44 | END; 45 | $create_user_partition$ language plpgsql security definer; 46 | 47 | create trigger 48 | create_user_partition 49 | after 50 | insert on auth.users for each row 51 | execute 52 | function create_user_partition (); 53 | 54 | -- Create partitions for existing users 55 | create 56 | or replace function create_user_partition_for_existing_users (arg uuid[]) returns void as $$ 57 | DECLARE 58 | u uuid; 59 | BEGIN 60 | FOREACH u IN ARRAY arg LOOP 61 | EXECUTE 'CREATE TABLE public.journals_' || replace(u::text,'-','_') || ' PARTITION OF public.journals FOR VALUES IN (''' || u || ''')'; 62 | END LOOP; 63 | END; 64 | $$ language plpgsql; 65 | 66 | select 67 | create_user_partition_for_existing_users (array_agg(id)) 68 | from 69 | auth.users; 70 | 71 | -- Create a table for Public Website pages 72 | create table 73 | website_pages ( 74 | id serial unique not null, 75 | page varchar(100) not null, 76 | content text, 77 | primary key (page) 78 | ); 79 | 80 | alter table 81 | website_pages enable row level security; 82 | 83 | create policy 84 | "Anyone can read content" on website_pages for 85 | select 86 | using (true); 87 | 88 | -- Create a table for Public Website changelog 89 | create table 90 | releases ( 91 | version varchar(50), 92 | pub_date timestamp with time zone not null, 93 | notes text, 94 | primary key (version) 95 | ); 96 | 97 | alter table 98 | releases enable row level security; 99 | 100 | create policy 101 | "Anyone can read content" on releases for 102 | select 103 | using (true); 104 | 105 | -- Create a table for user feedback 106 | create table 107 | feedback ( 108 | id serial unique not null, 109 | user_id uuid references auth.users (id) not null, 110 | rating int, 111 | check ( 112 | rating >= 1 113 | and rating <= 5 114 | ), 115 | feedback text, 116 | created_at timestamp with time zone default now(), 117 | email varchar(100), 118 | primary key (id) 119 | ); 120 | 121 | alter table 122 | feedback enable row level security; 123 | 124 | create policy 125 | "Only Authenticated users can add feedback" on feedback for insert 126 | with 127 | check (auth.uid () = user_id); -------------------------------------------------------------------------------- /src/config/UserPreferences.ts: -------------------------------------------------------------------------------- 1 | import { lightTheme, darkTheme, forestTheme, cappuccinoTheme } from 'themes' 2 | 3 | const fontSizeMap = { 4 | small: 18, 5 | normal: 21, 6 | large: 23, 7 | } 8 | type FontSize = keyof typeof fontSizeMap 9 | 10 | const fontFaceMap = { 11 | inter: 'Inter var', 12 | novela: 'Novela', 13 | } 14 | type FontFace = keyof typeof fontFaceMap 15 | 16 | const colorThemeMap = { 17 | light: lightTheme, 18 | dark: darkTheme, 19 | forest: forestTheme, 20 | cappuccino: cappuccinoTheme, 21 | } 22 | type ColorTheme = keyof typeof colorThemeMap 23 | 24 | const spellCheckMap = { 25 | true: 'true', 26 | false: 'false', 27 | } 28 | type SpellCheckEnabled = keyof typeof spellCheckMap 29 | 30 | const calendarOpenMap = { 31 | opened: { entriesOffset: 200, miniDatesVisibility: 'visible' }, 32 | closed: { entriesOffset: 0, miniDatesVisibility: 'hidden' }, 33 | } 34 | type CalendarOpen = keyof typeof calendarOpenMap 35 | 36 | type PromptsOpen = 'opened' | 'closed' 37 | 38 | type PromptSelectedId = number 39 | 40 | const defaultUserPreferences = { 41 | fontSize: 'normal' as FontSize, 42 | fontFace: 'inter' as FontFace, 43 | theme: 'light' as ColorTheme, 44 | calendarOpen: 'closed' as CalendarOpen, 45 | spellCheckEnabled: 'true' as SpellCheckEnabled, 46 | promptsOpen: 'closed' as PromptsOpen, 47 | promptSelectedId: 1 as PromptSelectedId, 48 | } 49 | 50 | const baseTheme = { 51 | appearance: { 52 | fontFace: 'Inter var', 53 | fontSize: '21px', 54 | entriesOffset: '0', 55 | miniDatesVisibility: 'hidden', 56 | }, 57 | animation: { 58 | time: { 59 | veryFast: '50ms', 60 | fast: '100ms', 61 | normal: '200ms', 62 | long: '400ms', 63 | }, 64 | timingFunction: { 65 | dynamic: 'cubic-bezier(0.31, 0.3, 0.17, 0.99)', 66 | }, 67 | }, 68 | } 69 | 70 | const getFontSize = (name: FontSize) => { 71 | return fontSizeMap[name] ? fontSizeMap[name] : fontSizeMap['normal'] 72 | } 73 | 74 | const getFontFace = (name: FontFace) => { 75 | return fontFaceMap[name] ? fontFaceMap[name] : fontFaceMap['inter'] 76 | } 77 | 78 | const getColorTheme = (name: ColorTheme) => { 79 | return colorThemeMap[name] ? colorThemeMap[name] : colorThemeMap['light'] 80 | } 81 | 82 | const getSpellCheckIsEnabled = (name: SpellCheckEnabled) => { 83 | return spellCheckMap[name] ? spellCheckMap[name] : spellCheckMap['true'] 84 | } 85 | 86 | const getCalendarIsOpen = (state: CalendarOpen) => { 87 | return calendarOpenMap[state] ? calendarOpenMap[state] : calendarOpenMap['closed'] 88 | } 89 | 90 | const getBaseThemeWithOverrides = (overrides: any) => { 91 | let theme = { ...baseTheme } 92 | 93 | if (overrides && overrides.fontFace) { 94 | theme.appearance.fontFace = getFontFace(overrides.fontFace) 95 | } 96 | 97 | if (overrides && overrides.fontSize) { 98 | theme.appearance.fontSize = getFontSize(overrides.fontSize) + 'px' 99 | } 100 | 101 | if (overrides && overrides.calendarOpen) { 102 | theme.appearance.entriesOffset = getCalendarIsOpen(overrides.calendarOpen).entriesOffset + 'px' 103 | theme.appearance.miniDatesVisibility = getCalendarIsOpen( 104 | overrides.calendarOpen 105 | ).miniDatesVisibility 106 | } 107 | 108 | return theme 109 | } 110 | 111 | export { 112 | baseTheme, 113 | getBaseThemeWithOverrides, 114 | getFontSize, 115 | getFontFace, 116 | getColorTheme, 117 | getCalendarIsOpen, 118 | getSpellCheckIsEnabled, 119 | defaultUserPreferences, 120 | ColorTheme, 121 | FontSize, 122 | FontFace, 123 | CalendarOpen, 124 | SpellCheckEnabled, 125 | PromptsOpen, 126 | PromptSelectedId, 127 | } 128 | -------------------------------------------------------------------------------- /src/components/EntryList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { EntryItem } from 'components' 3 | import { useEventEditorSelectors } from '@udecode/plate' 4 | import { supabase, arrayEquals, isUnauthorized, logger } from 'utils' 5 | import { useEntriesContext, useUserContext } from 'context' 6 | import dayjs from 'dayjs' 7 | import { BeforeEntries, PostEntries, Wrapper } from './styled' 8 | import type { Day, Tag } from 'types' 9 | 10 | var visibleSections: String[] = [] 11 | var rangeMarker: any 12 | var calendarContainer: any 13 | var scrollToToday: any 14 | var rangeMarkerTop: number 15 | 16 | const renderScrollToToday = () => { 17 | if (!scrollToToday) { 18 | scrollToToday = document.getElementById('ScrollToToday') 19 | } 20 | let today = dayjs().format('YYYY-MM-DD') 21 | if (visibleSections.some((day) => day == today)) { 22 | scrollToToday.style.marginBottom = '-32px' 23 | } else { 24 | scrollToToday.style.marginBottom = 0 25 | } 26 | } 27 | 28 | const renderMarker = () => { 29 | visibleSections.sort() 30 | 31 | if (!rangeMarker) { 32 | rangeMarker = document.getElementById('RangeVisible') 33 | } 34 | 35 | visibleSections.forEach((date: string, i) => { 36 | let elem = document.getElementById(`${date}-calendar`) 37 | if (elem) { 38 | const top = elem.offsetTop 39 | 40 | if (i == 0) { 41 | rangeMarkerTop = top 42 | rangeMarker.style.top = rangeMarkerTop - 2 + 'px' 43 | } 44 | 45 | if (i == visibleSections.length - 1) { 46 | let height = top - rangeMarkerTop + elem.offsetHeight + 4 + 'px' 47 | rangeMarker.style.height = height 48 | } 49 | } 50 | }) 51 | } 52 | 53 | const onIntersection = (entries: any) => { 54 | entries.forEach((entry: any) => { 55 | let date = entry.target.id.slice(0, 10) 56 | if (entry.isIntersecting) { 57 | // Add to array 58 | visibleSections.push(date) 59 | } else { 60 | // Remove from array 61 | visibleSections = visibleSections.filter((v) => { 62 | return v != date 63 | }) 64 | } 65 | renderMarker() 66 | renderScrollToToday() 67 | }) 68 | 69 | if (!calendarContainer) { 70 | calendarContainer = document.getElementById('CalendarContainer') 71 | } 72 | 73 | calendarContainer.scrollTo({ 74 | top: rangeMarkerTop - 48, 75 | behavior: 'smooth', 76 | }) 77 | } 78 | 79 | const entriesObserver = new IntersectionObserver(onIntersection, { 80 | rootMargin: '-100px', 81 | }) 82 | 83 | const EntryMemo = React.memo(EntryItem, (prevProps, nextProps) => { 84 | logger('New memo compare') 85 | if (prevProps.entryDay === nextProps.entryDay) { 86 | return true 87 | } 88 | return false 89 | }) 90 | 91 | function EntryList() { 92 | const [days, setDaysInternal] = useState([]) 93 | const { userEntries, invokeRerenderEntryList } = useEntriesContext() 94 | 95 | const setDays = () => { 96 | let today = dayjs().format('YYYY-MM-DD') as Day 97 | let daysInCache = userEntries.current.map((entry) => entry.day) as Day[] 98 | setDaysInternal([...new Set([...daysInCache, today])].sort()) 99 | } 100 | 101 | useEffect(() => { 102 | invokeRerenderEntryList.current = setDays 103 | }, []) 104 | 105 | return ( 106 | 107 | 108 | {days 109 | .slice(0) 110 | .reverse() 111 | .map((day, i) => ( 112 | item.day == day)} 117 | /> 118 | ))} 119 | 120 | 121 | ) 122 | } 123 | 124 | export { EntryList } 125 | -------------------------------------------------------------------------------- /src/components/Entry/EntryAside.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { theme } from 'themes' 3 | import styled from 'styled-components' 4 | import dayjs from 'dayjs' 5 | import { ordinal, breakpoints, logger, arrayEquals } from 'utils' 6 | import { Icon, EntryTags } from 'components' 7 | import { useEntriesContext } from 'context' 8 | import { EntryMenu } from './EntryMenu' 9 | 10 | const AsideDay = styled.p` 11 | padding: 0; 12 | margin: 0; 13 | color: ${theme('color.primary.main')}; 14 | opacity: 0.3; 15 | font-size: 16px; 16 | font-weight: 500; 17 | line-height: 24px; 18 | ` 19 | 20 | const AsideYear = styled.p` 21 | padding: 0; 22 | margin: 0; 23 | color: ${theme('color.primary.main')}; 24 | opacity: 0.3; 25 | font-size: 12px; 26 | font-weight: 300; 27 | line-height: 20px; 28 | ` 29 | 30 | const AsideMeta = styled.div` 31 | top: 0; 32 | right: 0; 33 | text-align: -webkit-right; 34 | transition: ${theme('animation.time.normal')}; 35 | padding-top: 24px; 36 | ` 37 | 38 | const AsideMain = styled.div` 39 | transition: ${theme('animation.time.normal')}; 40 | ` 41 | 42 | const AsideStickyContainer = styled.div` 43 | position: sticky; 44 | top: 48px; 45 | text-align: end; 46 | ` 47 | 48 | const Aside = styled.div` 49 | -webkit-app-region: no-drag; 50 | width: 160px; 51 | padding-top: 24px; 52 | display: flex; 53 | flex-direction: column; 54 | @media ${breakpoints.s} { 55 | display: none; 56 | } 57 | ` 58 | 59 | const AsideMenu = styled.div` 60 | width: 40px; 61 | padding-top: 24px; 62 | padding-bottom: 6px; 63 | display: flex; 64 | flex-direction: column; 65 | transition: padding ${theme('animation.time.normal')}; 66 | @media ${breakpoints.s} { 67 | display: none; 68 | } 69 | @media ${breakpoints.xl} { 70 | padding-right: var(--appearance-entriesOffset); 71 | } 72 | ` 73 | 74 | const AsideMenuStickyContainer = styled.div` 75 | position: sticky; 76 | -webkit-app-region: no-drag; 77 | top: 48px; 78 | display: flex; 79 | justify-content: center; 80 | ` 81 | 82 | const Trigger = styled((props) => )` 83 | -webkit-app-region: no-drag; 84 | opacity: 0.5; 85 | transition: opacity ${theme('animation.time.normal')}; 86 | ` 87 | 88 | const isToday = (day: any) => { 89 | return day.toString() == dayjs().format('YYYY-MM-DD') 90 | } 91 | 92 | const showDate = (day: any) => { 93 | if (isToday(day)) { 94 | return ( 95 | <> 96 | Today 97 | {dayjs(dayjs(day.toString(), 'YYYY-MM-DD')).format('D MMMM YYYY')} 98 | 99 | ) 100 | } else { 101 | return ( 102 | <> 103 | {dayjs(dayjs(day.toString(), 'YYYY-MM-DD')).format('D MMMM')} 104 | {dayjs(dayjs(day.toString(), 'YYYY-MM-DD')).format('YYYY')} 105 | 106 | ) 107 | } 108 | } 109 | 110 | type EntryAsideProps = { 111 | date: string 112 | wordCount: React.MutableRefObject 113 | freePlanLimitReached: boolean 114 | } 115 | 116 | function EntryAside({ date, wordCount, freePlanLimitReached }: EntryAsideProps) { 117 | return ( 118 | <> 119 | 125 | 126 | 127 | } 131 | /> 132 | 133 | 134 | 135 | ) 136 | } 137 | 138 | export { EntryAside } 139 | -------------------------------------------------------------------------------- /schemas/migration2.postgres.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------- 2 | -- Fix RLS for journals and entries_tags 3 | -------------------------------------------- 4 | -- Create RLS for existing users on journals 5 | create 6 | or replace function enable_rls_for_existing_users_on_journals (arg uuid[]) returns void as $$ 7 | DECLARE 8 | u uuid; 9 | BEGIN 10 | FOREACH u IN ARRAY arg LOOP 11 | EXECUTE 'alter table public.journals_' || replace(u::text,'-','_') || ' enable row level security'; 12 | EXECUTE 'drop policy if exists "User can manipulate only its own journal" on public.journals_' || replace(u::text,'-','_'); 13 | EXECUTE 'create policy "User can manipulate only its own journal" on public.journals_' || replace(u::text,'-','_') || ' for all using (auth.uid () = user_id)'; 14 | END LOOP; 15 | END; 16 | $$ language plpgsql; 17 | 18 | -- Run 19 | select 20 | enable_rls_for_existing_users_on_journals (array_agg(id)) 21 | from 22 | auth.users; 23 | 24 | -- Update trigger function to create RLS for new users on journals 25 | create 26 | or replace function create_user_partition () returns trigger as $create_user_partition$ 27 | BEGIN 28 | EXECUTE 'create table public.journals_' || replace(new.id::text,'-','_') || ' partition of public.journals for values in (''' || new.id || ''')'; 29 | EXECUTE 'alter table public.journals_' || replace(new.id::text,'-','_') || ' enable row level security'; 30 | EXECUTE 'create policy "User can manipulate only its own journal" on public.journals_' || replace(new.id::text,'-','_') || ' for all using (auth.uid () = user_id)'; 31 | RETURN NEW; 32 | END; 33 | $create_user_partition$ language plpgsql security definer; 34 | 35 | -- Create RLS for existing users on entries_tags 36 | create 37 | or replace function enable_rls_for_existing_users_on_entries_tags (arg uuid[]) returns void as $$ 38 | DECLARE 39 | u uuid; 40 | BEGIN 41 | FOREACH u IN ARRAY arg LOOP 42 | EXECUTE 'alter table public.entries_tags_' || replace(u::text,'-','_') || ' enable row level security'; 43 | EXECUTE 'drop policy if exists "User can manipulate only its own tags in journals" on public.entries_tags_' || replace(u::text,'-','_'); 44 | EXECUTE 'create policy "User can manipulate only its own tags in journals" on public.entries_tags_' || replace(u::text,'-','_') || ' for all using (auth.uid () = user_id)'; 45 | END LOOP; 46 | END; 47 | $$ language plpgsql; 48 | 49 | -- Run 50 | select 51 | enable_rls_for_existing_users_on_entries_tags (array_agg(id)) 52 | from 53 | auth.users; 54 | 55 | -- Update trigger function to create RLS for new users on entries_tags 56 | create 57 | or replace function create_user_partition_on_entries_tags () returns trigger as $create_user_partition_on_entries_tags$ 58 | BEGIN 59 | EXECUTE 'create table public.entries_tags_' || replace(new.id::text,'-','_') || ' partition of public.entries_tags for values in (''' || new.id || ''')'; 60 | EXECUTE 'alter table public.entries_tags_' || replace(new.id::text,'-','_') || ' enable row level security'; 61 | EXECUTE 'create policy "User can manipulate only its own tags in journals" on public.entries_tags_' || replace(new.id::text,'-','_') || ' for all using (auth.uid () = user_id)'; 62 | RETURN NEW; 63 | END; 64 | $create_user_partition_on_entries_tags$ language plpgsql security definer; 65 | 66 | -------------------------------------------- 67 | -- Enable Realtime 68 | -------------------------------------------- 69 | begin; 70 | 71 | drop publication if exists supabase_realtime; 72 | 73 | create publication supabase_realtime; 74 | 75 | commit; 76 | 77 | -- add journals to the publication 78 | alter publication supabase_realtime 79 | add table journals; 80 | 81 | -- add tags to the publication 82 | alter publication supabase_realtime 83 | add table tags; 84 | 85 | -- add entries_tags to the publication 86 | alter publication supabase_realtime 87 | add table entries_tags; -------------------------------------------------------------------------------- /src/components/EntryTags/ListItemTagColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' 2 | import { theme, lightTheme } from 'themes' 3 | import { 4 | useFloating, 5 | offset, 6 | FloatingTree, 7 | useListNavigation, 8 | useInteractions, 9 | useDismiss, 10 | FloatingFocusManager, 11 | useFocus, 12 | useFloatingNodeId, 13 | FloatingNode, 14 | FloatingPortal, 15 | } from '@floating-ui/react-dom-interactions' 16 | import { 17 | StyledEditTagColorPickerPopover, 18 | StyledEditTagColorPickerContainer, 19 | StyledColorPickerChevronIcon, 20 | StyledTagColorDot, 21 | StyledItem, 22 | StyledItemColorPicker, 23 | } from './styled' 24 | import type { Tag } from 'types' 25 | import { logger } from 'src/utils' 26 | import { useUserContext } from 'context' 27 | 28 | type ListItemTagColorPickerProps = { 29 | tag: Tag 30 | inputRef: React.MutableRefObject 31 | colorPickerOpen: boolean 32 | setColorPickerOpen: any 33 | tagEditColorRef: React.MutableRefObject 34 | } 35 | 36 | function ListItemTagColorPicker({ 37 | tag, 38 | inputRef, 39 | colorPickerOpen, 40 | setColorPickerOpen, 41 | tagEditColorRef, 42 | }: ListItemTagColorPickerProps) { 43 | const [selectedColor, setSelectedColor] = useState(tagEditColorRef.current) 44 | const { session } = useUserContext() 45 | const { floating, strategy, reference, x, y, context } = useFloating({ 46 | placement: 'left-start', 47 | open: colorPickerOpen, 48 | onOpenChange: setColorPickerOpen, 49 | middleware: [offset({ crossAxis: 0, mainAxis: 20 })], 50 | }) 51 | 52 | const handleColorSelect = (e: any, color: Tag['color']) => { 53 | e.preventDefault() 54 | e.stopPropagation() 55 | tagEditColorRef.current = color 56 | setSelectedColor(color) 57 | logger('handleColorSelect') 58 | toggleOpen(e) 59 | window.electronAPI.capture({ 60 | distinctId: session.user.id, 61 | event: 'tag edit color select', 62 | properties: { color }, 63 | }) 64 | } 65 | 66 | const toggleOpen = (e: any) => { 67 | e.stopPropagation() 68 | if (colorPickerOpen) { 69 | setColorPickerOpen(false) 70 | setTimeout(() => { 71 | inputRef.current.focus() 72 | }, 100) 73 | } else { 74 | setColorPickerOpen(true) 75 | } 76 | } 77 | 78 | return ( 79 | <> 80 | 81 | 82 | toggleOpen(e)} 85 | /> 86 | 87 | 88 | {colorPickerOpen && ( 89 | 90 | 98 | {Object.keys(lightTheme.color.tags).map( 99 | (color: keyof typeof lightTheme.color.tags, i) => ( 100 | handleColorSelect(e, color)} 103 | isActive={color == selectedColor} 104 | > 105 | 106 | 107 | ) 108 | )} 109 | 110 | 111 | )} 112 | 113 | 114 | ) 115 | } 116 | 117 | export { ListItemTagColorPicker } 118 | -------------------------------------------------------------------------------- /updates/darwin/x64/update.json: -------------------------------------------------------------------------------- 1 | { 2 | "currentRelease": "1.0.5", 3 | "releases": [ 4 | { 5 | "version": "1.0.5", 6 | "updateTo": { 7 | "version": "1.0.5", 8 | "pub_date": "2023-02-01T17:00:00+00:00", 9 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.5-x64.dmg", 10 | "name": "1.0.5", 11 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.5.zip" 12 | } 13 | }, 14 | { 15 | "version": "1.0.4", 16 | "updateTo": { 17 | "version": "1.0.4", 18 | "pub_date": "2023-01-14T15:00:00+00:00", 19 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.4-x64.dmg", 20 | "name": "1.0.4", 21 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.4.zip" 22 | } 23 | }, 24 | { 25 | "version": "1.0.3", 26 | "updateTo": { 27 | "version": "1.0.3", 28 | "pub_date": "2022-12-17T10:00:00+00:00", 29 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.3-x64.dmg", 30 | "name": "1.0.3", 31 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.3.zip" 32 | } 33 | }, 34 | { 35 | "version": "1.0.2", 36 | "updateTo": { 37 | "version": "1.0.2", 38 | "pub_date": "2022-12-16T10:00:00+00:00", 39 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.2-x64.dmg", 40 | "name": "1.0.2", 41 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.2.zip" 42 | } 43 | }, 44 | { 45 | "version": "1.0.1", 46 | "updateTo": { 47 | "version": "1.0.1", 48 | "pub_date": "2022-12-15T15:00:00+00:00", 49 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.1-x64.dmg", 50 | "name": "1.0.1", 51 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.1.zip" 52 | } 53 | }, 54 | { 55 | "version": "1.0.0-beta.5", 56 | "updateTo": { 57 | "version": "1.0.0-beta.5", 58 | "pub_date": "2022-07-27T08:00:00+00:00", 59 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.0-beta.5-x64.dmg", 60 | "name": "1.0.0-beta.5", 61 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.0-beta.5.zip" 62 | } 63 | }, 64 | { 65 | "version": "1.0.0-beta.4", 66 | "updateTo": { 67 | "version": "1.0.0-beta.4", 68 | "pub_date": "2022-07-20T10:30:00+00:00", 69 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.0-beta.4-x64.dmg", 70 | "name": "1.0.0-beta.4", 71 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.0-beta.4.zip" 72 | } 73 | }, 74 | { 75 | "version": "1.0.0-beta.3", 76 | "updateTo": { 77 | "version": "1.0.0-beta.3", 78 | "pub_date": "2022-07-07T22:00:00+00:00", 79 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.0-beta.3-x64.dmg", 80 | "name": "1.0.0-beta.3", 81 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.0-beta.3.zip" 82 | } 83 | }, 84 | { 85 | "version": "1.0.0-beta.2", 86 | "updateTo": { 87 | "version": "1.0.0-beta.2", 88 | "pub_date": "2022-07-01T20:00:00+00:00", 89 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.0-beta.2-x64.dmg", 90 | "name": "1.0.0-beta.2", 91 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.0-beta.2.zip" 92 | } 93 | }, 94 | { 95 | "version": "1.0.0-beta.1", 96 | "updateTo": { 97 | "version": "1.0.0-beta.1", 98 | "pub_date": "2022-06-24T20:00:00+00:00", 99 | "notes": "https://desktop.journal.do/darwin/x64/Journal-1.0.0-beta.1-x64.dmg", 100 | "name": "1.0.0-beta.1", 101 | "url": "https://desktop.journal.do/darwin/x64/Journal-darwin-x64-1.0.0-beta.1.zip" 102 | } 103 | } 104 | ] 105 | } 106 | -------------------------------------------------------------------------------- /src/components/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import * as Dialog from '@radix-ui/react-dialog' 2 | import React, { useRef } from 'react' 3 | import styled from 'styled-components' 4 | import { theme } from 'themes' 5 | 6 | const Overlay = styled(Dialog.Overlay)` 7 | position: fixed; 8 | top: 0; 9 | right: 0; 10 | bottom: 0; 11 | left: 0; 12 | background-color: ${theme('color.primary.surface', 0.8)}; 13 | display: grid; 14 | place-items: center; 15 | ` 16 | 17 | const Content = styled(Dialog.Content)` 18 | -webkit-app-region: no-drag; 19 | position: fixed; 20 | color: ${theme('color.primary.main')}; 21 | background-color: ${theme('color.popper.surface')}; 22 | border-radius: 12px; 23 | box-shadow: ${theme('style.shadow')}; 24 | padding: 24px; 25 | display: flex; 26 | flex-direction: column; 27 | width: 330px; 28 | ` 29 | 30 | const Trigger = styled(Dialog.Trigger)` 31 | border: 0; 32 | outline: 0; 33 | background: transparent; 34 | padding: 0; 35 | margin: 0; 36 | ` 37 | 38 | const Title = styled(Dialog.Title)` 39 | font-style: normal; 40 | font-weight: 500; 41 | font-size: 18px; 42 | line-height: 28px; 43 | margin-bottom: 8px; 44 | letter-spacing: -0.03em; 45 | ` 46 | 47 | const Description = styled.div` 48 | font-weight: 400; 49 | font-size: 12px; 50 | line-height: 15px; 51 | ` 52 | const Actions = styled.div` 53 | display: flex; 54 | gap: 16px; 55 | margin-top: 32px; 56 | ` 57 | 58 | const Close = styled.button` 59 | font-weight: 400; 60 | font-size: 14px; 61 | line-height: 17px; 62 | padding: 8px; 63 | color: ${theme('color.primary.main')}; 64 | border: 0; 65 | border-radius: 6px; 66 | flex-grow: 1; 67 | background: transparent; 68 | cursor: pointer; 69 | outline: 0; 70 | &:hover { 71 | box-shadow: 0 0 0 3px ${theme('color.popper.border')}; 72 | transition: box-shadow ${theme('animation.time.normal')} ease; 73 | } 74 | ` 75 | 76 | const Action = styled.button` 77 | font-weight: 500; 78 | font-size: 14px; 79 | line-height: 17px; 80 | padding: 8px; 81 | color: ${theme('color.error.main')}; 82 | border: 1px solid ${theme('color.error.main')}; 83 | border-radius: 6px; 84 | flex-grow: 1; 85 | background: transparent; 86 | cursor: pointer; 87 | outline: 0; 88 | &:hover { 89 | box-shadow: 0 0 0 3px ${theme('color.error.main', 0.2)}; 90 | transition: box-shadow ${theme('animation.time.normal')} ease; 91 | } 92 | ` 93 | 94 | interface ConfirmationModalProps { 95 | action: any 96 | children: any 97 | titleText: string 98 | descriptionText?: string 99 | confirmActionText?: string 100 | cancelActionText?: string 101 | } 102 | 103 | const ConfirmationModal = ({ 104 | action, 105 | children, 106 | titleText, 107 | descriptionText = '', 108 | confirmActionText = 'Confirm', 109 | cancelActionText = 'Cancel', 110 | }: any) => { 111 | const [open, setOpen] = React.useState(false) 112 | const closeRef = useRef(null) 113 | 114 | const performAction = (e: React.MouseEvent) => { 115 | e.preventDefault() 116 | e.stopPropagation() 117 | action() 118 | setOpen(false) 119 | } 120 | 121 | const close = (e: React.MouseEvent) => { 122 | e.preventDefault() 123 | e.stopPropagation() 124 | setOpen(false) 125 | } 126 | 127 | return ( 128 | 129 | {children} 130 | 131 | 132 | 133 | {titleText} 134 | {descriptionText} 135 | 136 | performAction(e)}>{confirmActionText} 137 | close(e)}> 138 | {cancelActionText} 139 | 140 | 141 | 142 | 143 | 144 | 145 | ) 146 | } 147 | 148 | export { ConfirmationModal } 149 | -------------------------------------------------------------------------------- /updates/darwin/arm64/update.json: -------------------------------------------------------------------------------- 1 | { 2 | "currentRelease": "1.0.5", 3 | "releases": [ 4 | { 5 | "version": "1.0.5", 6 | "updateTo": { 7 | "version": "1.0.5", 8 | "pub_date": "2023-01-29T14:00:00+00:00", 9 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.5-arm64.dmg", 10 | "name": "1.0.5", 11 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.5.zip" 12 | } 13 | }, 14 | { 15 | "version": "1.0.4", 16 | "updateTo": { 17 | "version": "1.0.4", 18 | "pub_date": "2023-01-14T15:00:00+00:00", 19 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.4-arm64.dmg", 20 | "name": "1.0.4", 21 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.4.zip" 22 | } 23 | }, 24 | { 25 | "version": "1.0.3", 26 | "updateTo": { 27 | "version": "1.0.3", 28 | "pub_date": "2022-12-17T10:00:00+00:00", 29 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.3-arm64.dmg", 30 | "name": "1.0.3", 31 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.3.zip" 32 | } 33 | }, 34 | { 35 | "version": "1.0.2", 36 | "updateTo": { 37 | "version": "1.0.2", 38 | "pub_date": "2022-12-16T10:00:00+00:00", 39 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.2-arm64.dmg", 40 | "name": "1.0.2", 41 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.2.zip" 42 | } 43 | }, 44 | { 45 | "version": "1.0.1", 46 | "updateTo": { 47 | "version": "1.0.1", 48 | "pub_date": "2022-12-15T15:00:00+00:00", 49 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.1-arm64.dmg", 50 | "name": "1.0.1", 51 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.1.zip" 52 | } 53 | }, 54 | { 55 | "version": "1.0.0-beta.5", 56 | "updateTo": { 57 | "version": "1.0.0-beta.5", 58 | "pub_date": "2022-07-27T08:00:00+00:00", 59 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.0-beta.5-arm64.dmg", 60 | "name": "1.0.0-beta.5", 61 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.0-beta.5.zip" 62 | } 63 | }, 64 | { 65 | "version": "1.0.0-beta.4", 66 | "updateTo": { 67 | "version": "1.0.0-beta.4", 68 | "pub_date": "2022-07-20T10:30:00+00:00", 69 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.0-beta.4-arm64.dmg", 70 | "name": "1.0.0-beta.4", 71 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.0-beta.4.zip" 72 | } 73 | }, 74 | { 75 | "version": "1.0.0-beta.3", 76 | "updateTo": { 77 | "version": "1.0.0-beta.3", 78 | "pub_date": "2022-07-07T22:00:00+00:00", 79 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.0-beta.3-arm64.dmg", 80 | "name": "1.0.0-beta.3", 81 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.0-beta.3.zip" 82 | } 83 | }, 84 | { 85 | "version": "1.0.0-beta.2", 86 | "updateTo": { 87 | "version": "1.0.0-beta.2", 88 | "pub_date": "2022-07-01T20:00:00+00:00", 89 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.0-beta.2-arm64.dmg", 90 | "name": "1.0.0-beta.2", 91 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.0-beta.2.zip" 92 | } 93 | }, 94 | { 95 | "version": "1.0.0-beta.1", 96 | "updateTo": { 97 | "version": "1.0.0-beta.1", 98 | "pub_date": "2022-06-24T20:00:00+00:00", 99 | "notes": "https://desktop.journal.do/darwin/arm64/Journal-1.0.0-beta.1-arm64.dmg", 100 | "name": "1.0.0-beta.1", 101 | "url": "https://desktop.journal.do/darwin/arm64/Journal-darwin-arm64-1.0.0-beta.1.zip" 102 | } 103 | } 104 | ] 105 | } 106 | -------------------------------------------------------------------------------- /tests/helpers/supabase.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'pg' 2 | 3 | const supabaseConn = { 4 | host: '127.0.0.1', 5 | port: 5432, 6 | user: 'postgres', 7 | password: 'your-super-secret-and-long-postgres-password', 8 | } 9 | 10 | const apikey = 11 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRuZmRhdW9vd3lycHhxb2RvbXFuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTQ1MzEzOTUsImV4cCI6MTk3MDEwNzM5NX0.XYkcWry-Eqm0-Hvq-arndEGhQn_yJvGF85-NNf9Sbvk' 12 | 13 | export async function supabaseLoginUser(email: string) { 14 | const data = await fetch( 15 | 'https://supabase.journal.local:8443/auth/v1/token?grant_type=password', 16 | { 17 | method: 'POST', 18 | body: JSON.stringify({ 19 | email, 20 | password: '123456', 21 | }), 22 | headers: { 23 | apikey, 24 | 'Content-Type': 'application/json', 25 | }, 26 | } 27 | ).then((r) => r.json()) 28 | const { refresh_token } = data 29 | return refresh_token 30 | } 31 | 32 | export async function supabaseRegisterUser(email: string) { 33 | const data = await fetch('https://supabase.journal.local:8443/auth/v1/signup', { 34 | method: 'POST', 35 | body: JSON.stringify({ 36 | email, 37 | password: '123456', 38 | }), 39 | headers: { 40 | apikey, 41 | 'Content-Type': 'application/json', 42 | }, 43 | }).then((r) => r.json()) 44 | return data 45 | } 46 | 47 | export async function supabaseDeleteUser(user_id: string) { 48 | const client = new Client(supabaseConn) 49 | await client.connect() 50 | await client.query(`delete from customers where id = '${user_id}'`) 51 | await client.query(`delete from entries_tags where user_id = '${user_id}'`) 52 | await client.query(`delete from journals where user_id = '${user_id}'`) 53 | await client.query(`delete from journals_catalog where user_id = '${user_id}'`) 54 | await client.query(`delete from subscriptions where user_id = '${user_id}'`) 55 | await client.query(`delete from tags where user_id = '${user_id}'`) 56 | await client.query(`delete from users where id = '${user_id}'`) 57 | await client.query(`delete from auth.users where id = '${user_id}'`) 58 | await client.query(`drop table entries_tags_${user_id.replaceAll('-', '_')}`) 59 | await client.query(`drop table journals_${user_id.replaceAll('-', '_')} cascade`) 60 | await client.end() 61 | } 62 | 63 | export async function supabaseGetEntry(user_id: string, day: string) { 64 | const client = new Client(supabaseConn) 65 | await client.connect() 66 | const entry = await client.query( 67 | `select * from journals where user_id = '${user_id}' and day = '${day}'` 68 | ) 69 | await client.end() 70 | return entry.rows[0] 71 | } 72 | 73 | export async function supabaseCopyEntryToDayBefore(user_id: string, day: string) { 74 | const client = new Client(supabaseConn) 75 | await client.connect() 76 | await client.query( 77 | `insert into journals (user_id, day, created_at, modified_at, content, iv, revision) select user_id, day - interval '1' day, created_at, modified_at, content, iv, revision from journals where user_id ='${user_id}' and day = '${day}'` 78 | ) 79 | await client.end() 80 | } 81 | 82 | export async function supabaseCopyEntryContent(user_id: string, dayFrom: string, dayTo: string) { 83 | const client = new Client(supabaseConn) 84 | await client.connect() 85 | const entryFrom = await client.query( 86 | `select * from journals where user_id = '${user_id}' and day = '${dayFrom}'` 87 | ) 88 | await client.query( 89 | `UPDATE journals SET content = subquery.content, iv = subquery.iv, revision = revision + 1 FROM (SELECT content, iv FROM journals WHERE user_id = '${user_id}' and day = '${dayFrom}') AS subquery WHERE user_id = '${user_id}' and day = '${dayTo}'` 90 | ) 91 | 92 | await client.end() 93 | } 94 | 95 | export async function supabaseDeleteEntry(user_id: string, day: string) { 96 | const client = new Client(supabaseConn) 97 | await client.connect() 98 | await client.query(`delete from journals where user_id ='${user_id}' and day = '${day}'`) 99 | await client.end() 100 | } 101 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/ChangeCycle/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { theme } from 'themes' 3 | import { isDev, logger } from 'utils' 4 | import { 5 | useFloating, 6 | FloatingOverlay, 7 | useInteractions, 8 | useDismiss, 9 | useClick, 10 | FloatingFocusManager, 11 | useFloatingNodeId, 12 | FloatingNode, 13 | FloatingPortal, 14 | } from '@floating-ui/react-dom-interactions' 15 | import { useQuery } from '@tanstack/react-query' 16 | import { loadStripe } from '@stripe/stripe-js/pure' 17 | import { Elements } from '@stripe/react-stripe-js' 18 | import type { Countries, Price } from 'types' 19 | import { useUserContext } from 'context' 20 | import { 21 | getCustomer, 22 | fetchCountries, 23 | calcYearlyPlanSavings, 24 | } from '../../../../context/UserContext/subscriptions' 25 | import { CheckoutModalStyled } from './styled' 26 | import { Modal } from './Modal' 27 | 28 | interface SubscribeProps { 29 | renderTrigger: any 30 | prices: Price[] 31 | } 32 | 33 | //////////////////////////// 34 | // 🍔 ChangeCycle component 35 | //////////////////////////// 36 | 37 | const ChangeCycle = ({ renderTrigger, prices }: SubscribeProps) => { 38 | logger('ChangeCycle rerender') 39 | const [open, setOpen] = useState(false) 40 | const [stripePromise, setStripePromise] = useState(null) 41 | const nodeId = useFloatingNodeId() 42 | const { session } = useUserContext() 43 | 44 | useQuery({ 45 | queryKey: ['stripePromise'], 46 | queryFn: async () => { 47 | const url = isDev() ? 'https://s.journal.local' : 'https://s.journal.do' 48 | const { publishableKey } = await fetch(`${url}/api/v1/config`).then((r) => r.json()) 49 | setStripePromise(() => loadStripe(publishableKey)) 50 | return publishableKey 51 | }, 52 | }) 53 | 54 | const { isLoading: billingInfoIsLoading, data: billingInfo } = useQuery({ 55 | queryKey: ['billingInfo'], 56 | queryFn: async () => getCustomer(session.access_token), 57 | }) 58 | 59 | const { reference, floating, context, refs } = useFloating({ 60 | open, 61 | onOpenChange: setOpen, 62 | nodeId, 63 | }) 64 | 65 | const { getReferenceProps, getFloatingProps } = useInteractions([ 66 | useClick(context), 67 | useDismiss(context, { 68 | escapeKey: false, 69 | }), 70 | ]) 71 | 72 | const handleCloseEsc = (e: any) => { 73 | if (e.key == 'Escape') { 74 | if (refs.floating.current && refs.floating.current.contains(document.activeElement)) { 75 | setOpen(false) 76 | } 77 | } 78 | } 79 | 80 | ////////////////////////// 81 | // 🏓 useEffect 82 | ////////////////////////// 83 | 84 | useEffect(() => { 85 | logger('✅ addEventListener') 86 | document.addEventListener('keydown', handleCloseEsc) 87 | 88 | return () => { 89 | logger('❌ removeEventListener') 90 | document.removeEventListener('keydown', handleCloseEsc) 91 | } 92 | }, []) 93 | 94 | useEffect(() => { 95 | if (open) { 96 | window.electronAPI.capture({ 97 | distinctId: session.user.id, 98 | event: 'settings billing plan', 99 | properties: { action: 'upgrade-to-yearly' }, 100 | }) 101 | } 102 | }, [open]) 103 | 104 | ////////////////////////// 105 | // 🚀 Return 106 | ////////////////////////// 107 | 108 | return ( 109 | 110 | {renderTrigger({ open: () => setOpen(true), ref: reference, ...getReferenceProps() })} 111 | 112 | {open && ( 113 | 122 | 123 | 124 | 125 | 131 | 132 | 133 | 134 | 135 | )} 136 | 137 | 138 | ) 139 | } 140 | 141 | export { ChangeCycle } 142 | -------------------------------------------------------------------------------- /src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled from 'styled-components' 3 | import { theme } from 'themes' 4 | import { useUserContext } from 'context' 5 | import { Splash } from 'components' 6 | import { supabase, isDev, logger } from 'utils' 7 | import logo from '../../assets/icons/journaldo-logo@2x.png' 8 | 9 | const Container = styled.div` 10 | position: fixed; 11 | flex-direction: column; 12 | display: flex; 13 | gap: 8px; 14 | z-index: 9999; 15 | align-items: center; 16 | justify-content: center; 17 | width: 100%; 18 | height: 100%; 19 | -webkit-app-region: drag; 20 | ` 21 | 22 | const Welcome = styled.h1` 23 | font-weight: 500; 24 | font-size: 28px; 25 | line-height: 34px; 26 | text-align: center; 27 | letter-spacing: -0.03em; 28 | color: ${theme('color.primary.main')}; 29 | margin: 40px 0 32px 0; 30 | ` 31 | 32 | const LoginButton = styled.a` 33 | font-weight: 600; 34 | font-size: 18px; 35 | line-height: 32px; 36 | text-align: center; 37 | letter-spacing: -0.03em; 38 | color: ${theme('color.primary.surface')}; 39 | background-color: ${theme('color.primary.main')}; 40 | padding: 16px 40px; 41 | border-radius: 100px; 42 | text-decoration: none; 43 | white-space: nowrap; 44 | outline: 0; 45 | ` 46 | 47 | const ErrorMessage = styled.div` 48 | color: ${theme('color.error.main')}; 49 | ` 50 | 51 | const RTForm = styled.form` 52 | position: fixed; 53 | bottom: 16px; 54 | left: 16px; 55 | right: 16px; 56 | ` 57 | 58 | const RTInput = styled.input` 59 | font-size: 16px; 60 | padding: 17px; 61 | display: block; 62 | margin: 4px; 63 | border-radius: 100px; 64 | border: 1px solid ${theme('color.primary.border')}; 65 | background-color: ${theme('color.primary.surface')}; 66 | color: ${theme('color.primary.main')}; 67 | width: -webkit-fill-available; 68 | outline: 0; 69 | &:focus { 70 | border: 1px solid ${theme('color.secondary.hover')}; 71 | } 72 | ` 73 | 74 | const RTError = styled.div` 75 | font-size: 16px; 76 | padding: 17px; 77 | display: block; 78 | margin: 4px; 79 | color: ${theme('color.error.main')}; 80 | ` 81 | 82 | const Logo = styled.img` 83 | width: 64px; 84 | height: 64px; 85 | ` 86 | 87 | const LoginWithToken = () => { 88 | const [authError, setAuthError] = useState('') 89 | const rt = useRef(null) 90 | 91 | const keyPress = async (e: React.KeyboardEvent) => { 92 | if (e.key === 'Enter') { 93 | let refreshToken = rt.current.value 94 | const { error } = await supabase.auth.signIn({ refreshToken }) 95 | if (error) { 96 | logger(error) 97 | setAuthError(error.message) 98 | } 99 | } else { 100 | setAuthError('') 101 | } 102 | } 103 | 104 | const change = async (e: React.ChangeEvent) => { 105 | logger(e.target.value) 106 | try { 107 | const url = new URL(e.target.value) 108 | logger(url.host) 109 | const refreshToken = url.searchParams.get('refresh_token') 110 | const { error } = await supabase.auth.signIn({ refreshToken }) 111 | if (error) { 112 | logger(error) 113 | setAuthError(error.message) 114 | } 115 | } catch { 116 | logger('no refresh_token found') 117 | } 118 | } 119 | 120 | return ( 121 | e.preventDefault()}> 122 | {authError} 123 | keyPress(e)} 130 | onChange={(e) => change(e)} 131 | > 132 | 133 | ) 134 | } 135 | 136 | const Login = () => { 137 | const { authError, session } = useUserContext() 138 | 139 | const log = () => { 140 | let lastUser = window.electronAPI.app.getKey('lastUser') 141 | window.electronAPI.capture({ 142 | distinctId: lastUser, 143 | event: 'user login-with-browser', 144 | }) 145 | } 146 | 147 | return ( 148 | <> 149 | 150 | 151 | 152 | Welcome to Journal 153 | log()}> 154 | Log in with browser 155 | 156 | {authError} 157 | {isDev() && } 158 | 159 | 160 | ) 161 | } 162 | 163 | export { Login } 164 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/ImportExport/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { theme } from 'themes' 3 | import { logger } from 'utils' 4 | import { SectionTitleStyled } from '../styled' 5 | import { serialize } from 'remark-slate' 6 | import { useEntriesContext, useUserContext } from 'context' 7 | import type { Entry } from 'types' 8 | import { plateNodeTypes } from './nodeTypes' 9 | import styled from 'styled-components' 10 | 11 | const WrapperStyled = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | gap: 8px; 15 | ` 16 | const ButtonsWrapperStyled = styled.div` 17 | display: flex; 18 | flex-wrap: wrap; 19 | gap: 16px; 20 | width: fit-content; 21 | padding-top: 16px; 22 | ` 23 | 24 | const ButtonStyled = styled.button` 25 | font-weight: 500; 26 | font-size: 14px; 27 | line-height: 24px; 28 | white-space: nowrap; 29 | padding: 8px 16px; 30 | color: ${theme('color.popper.main')}; 31 | border: 1px solid ${theme('color.popper.main')}; 32 | border-radius: 6px; 33 | flex: 1; 34 | background: transparent; 35 | cursor: pointer; 36 | outline: 0; 37 | &:hover, 38 | &:focus { 39 | box-shadow: 0 0 0 3px ${theme('color.popper.main', 0.2)}; 40 | transition: box-shadow ${theme('animation.time.normal')} ease; 41 | } 42 | &:disabled { 43 | opacity: 0.6; 44 | cursor: default; 45 | } 46 | ` 47 | 48 | const TitleStyled = styled.div` 49 | font-style: normal; 50 | font-weight: 500; 51 | font-size: 16px; 52 | line-height: 24px; 53 | letter-spacing: -0.03em; 54 | ` 55 | 56 | const TextStyled = styled.div` 57 | font-weight: 400; 58 | font-size: 14px; 59 | line-height: 20px; 60 | opacity: 0.6; 61 | ` 62 | 63 | const toMarkdown = (content: any) => { 64 | return content 65 | .map((v: any) => { 66 | logger(v) 67 | if (v.type == 'hr') { 68 | return '---\n' 69 | } 70 | if (!!!v?.type) { 71 | logger('no type') 72 | v.type = 'p' 73 | } 74 | //@ts-ignore 75 | return serialize(v, { nodeTypes: plateNodeTypes }) 76 | }) 77 | .join('') 78 | } 79 | 80 | const exportTxt = (entries: Entry[]) => { 81 | try { 82 | const output = entries 83 | .map((entry) => { 84 | return 'Day: ' + entry.day + '\n\n' + toMarkdown(entry.content) 85 | }) 86 | .join('\n\n\n') 87 | window.electronAPI.saveFile(output, 'txt') 88 | } catch (error) { 89 | logger('Error:') 90 | logger(error) 91 | } 92 | } 93 | 94 | const exportJson = (entries: Entry[]) => { 95 | try { 96 | const output = entries.map((entry) => { 97 | return { 98 | day: entry.day, 99 | content: toMarkdown(entry.content), 100 | } 101 | }) 102 | window.electronAPI.saveFile(JSON.stringify(output), 'json') 103 | } catch (error) { 104 | logger('Error:') 105 | logger(error) 106 | } 107 | } 108 | 109 | const ImportExportTabContent = () => { 110 | logger('ImportExport re-render') 111 | const { userEntries } = useEntriesContext() 112 | const { session } = useUserContext() 113 | 114 | useEffect(() => { 115 | window.electronAPI.capture({ 116 | distinctId: session.user.id, 117 | event: 'settings view-tab', 118 | properties: { tab: 'export' }, 119 | }) 120 | }, []) 121 | 122 | const exportTxtHandler = () => { 123 | exportTxt(userEntries.current) 124 | window.electronAPI.capture({ 125 | distinctId: session.user.id, 126 | event: 'settings export-journal', 127 | properties: { format: 'txt' }, 128 | }) 129 | } 130 | 131 | const exportJsonHandler = () => { 132 | exportJson(userEntries.current) 133 | window.electronAPI.capture({ 134 | distinctId: session.user.id, 135 | event: 'settings export-journal', 136 | properties: { format: 'json' }, 137 | }) 138 | } 139 | 140 | return ( 141 | <> 142 | Export 143 | 144 | Export all Journal entries as markdown: 145 | 146 | You own your data and you can download it any time. 147 |
148 | All entries are automatically synced with the cloud, so you don't have to make backups. 149 |
150 | 151 | exportTxtHandler()}>Journal.txt ↓ 152 | exportJsonHandler()}>Journal.json ↓ 153 | 154 |
155 | 156 | ) 157 | } 158 | 159 | export { ImportExportTabContent } 160 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Subscribe/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { theme } from 'themes' 3 | import { isDev, logger } from 'utils' 4 | import { 5 | useFloating, 6 | FloatingOverlay, 7 | useInteractions, 8 | useDismiss, 9 | useClick, 10 | FloatingFocusManager, 11 | useFloatingNodeId, 12 | FloatingNode, 13 | FloatingPortal, 14 | } from '@floating-ui/react-dom-interactions' 15 | import { useQuery } from '@tanstack/react-query' 16 | import { loadStripe } from '@stripe/stripe-js/pure' 17 | import { Elements } from '@stripe/react-stripe-js' 18 | import type { Countries, Price } from 'types' 19 | import { useUserContext } from 'context' 20 | import { getCustomer, fetchCountries } from '../../../../context/UserContext/subscriptions' 21 | import { CheckoutModalStyled } from './styled' 22 | import { Modal } from './Modal' 23 | 24 | interface SubscribeProps { 25 | renderTrigger: any 26 | prices: Price[] 27 | billingInterval: 'year' | 'month' 28 | } 29 | 30 | ////////////////////////// 31 | // 🍔 Subscribe component 32 | ////////////////////////// 33 | 34 | const Subscribe = ({ renderTrigger, prices, billingInterval }: SubscribeProps) => { 35 | logger('Subscribe rerender') 36 | const [stripePromise, setStripePromise] = useState(null) 37 | const [open, setOpen] = useState(false) 38 | const nodeId = useFloatingNodeId() 39 | const { session } = useUserContext() 40 | 41 | const { data: countries } = useQuery({ 42 | queryKey: ['countries'], 43 | queryFn: fetchCountries, 44 | }) 45 | 46 | const { isLoading: billingInfoIsLoading, data: billingInfo } = useQuery({ 47 | queryKey: ['billingInfo'], 48 | queryFn: async () => getCustomer(session.access_token), 49 | }) 50 | 51 | useQuery({ 52 | queryKey: ['stripePromise'], 53 | queryFn: async () => { 54 | const url = isDev() ? 'https://s.journal.local' : 'https://s.journal.do' 55 | const { publishableKey } = await fetch(`${url}/api/v1/config`).then((r) => r.json()) 56 | setStripePromise(() => loadStripe(publishableKey)) 57 | return publishableKey 58 | }, 59 | }) 60 | 61 | const { reference, floating, context, refs } = useFloating({ 62 | open, 63 | onOpenChange: setOpen, 64 | nodeId, 65 | }) 66 | 67 | const { getReferenceProps, getFloatingProps } = useInteractions([ 68 | useClick(context), 69 | useDismiss(context, { 70 | escapeKey: false, 71 | }), 72 | ]) 73 | 74 | const handleCloseEsc = (e: any) => { 75 | if (e.key == 'Escape') { 76 | if (refs.floating.current && refs.floating.current.contains(document.activeElement)) { 77 | setOpen(false) 78 | } 79 | } 80 | } 81 | 82 | ////////////////////////// 83 | // 🏓 useEffect 84 | ////////////////////////// 85 | 86 | useEffect(() => { 87 | logger('✅ addEventListener') 88 | document.addEventListener('keydown', handleCloseEsc) 89 | 90 | return () => { 91 | logger('❌ removeEventListener') 92 | document.removeEventListener('keydown', handleCloseEsc) 93 | } 94 | }, []) 95 | 96 | useEffect(() => { 97 | if (open) { 98 | window.electronAPI.capture({ 99 | distinctId: session.user.id, 100 | event: 'settings upgrade upgrade-cta', 101 | properties: { plan: 'Writer' }, 102 | }) 103 | } 104 | }, [open]) 105 | 106 | ////////////////////////// 107 | // 🚀 Return 108 | ////////////////////////// 109 | 110 | return ( 111 | 112 | {renderTrigger({ open: () => setOpen(true), ref: reference, ...getReferenceProps() })} 113 | 114 | {open && ( 115 | 124 | 125 | 126 | 127 | 135 | 136 | 137 | 138 | 139 | )} 140 | 141 | 142 | ) 143 | } 144 | 145 | export { Subscribe } 146 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/AddCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { theme, getCSSVar } from 'themes' 3 | import { isDev, logger } from 'utils' 4 | import { 5 | useFloating, 6 | FloatingOverlay, 7 | useInteractions, 8 | useDismiss, 9 | useClick, 10 | FloatingFocusManager, 11 | useFloatingNodeId, 12 | FloatingNode, 13 | FloatingPortal, 14 | } from '@floating-ui/react-dom-interactions' 15 | import { loadStripe } from '@stripe/stripe-js/pure' 16 | import { useQuery } from '@tanstack/react-query' 17 | import { CheckoutModalStyled } from './styled' 18 | import { Modal } from './Modal' 19 | import { Elements } from '@stripe/react-stripe-js' 20 | import { useUserContext } from 'context' 21 | import { 22 | getSubscription, 23 | getCustomer, 24 | fetchCountries, 25 | createSetupIntent, 26 | } from '../../../../context/UserContext/subscriptions' 27 | 28 | interface AddCardProps { 29 | renderTrigger: any 30 | isUpdate?: boolean 31 | } 32 | 33 | ////////////////////////// 34 | // 🍔 AddCard component 35 | ////////////////////////// 36 | 37 | const AddCard = ({ renderTrigger, isUpdate = false }: AddCardProps) => { 38 | logger('AddCard rerender') 39 | const { session, subscription, createSubscription } = useUserContext() 40 | const [stripePromise, setStripePromise] = useState(null) 41 | const [open, setOpen] = useState(false) 42 | const nodeId = useFloatingNodeId() 43 | 44 | const { data: countries } = useQuery({ 45 | queryKey: ['countries'], 46 | queryFn: fetchCountries, 47 | }) 48 | 49 | const { 50 | isLoading: billingInfoIsLoading, 51 | data: billingInfo, 52 | refetch: refetchBillingInfo, 53 | } = useQuery({ 54 | queryKey: ['billingInfo'], 55 | queryFn: async () => getCustomer(session.access_token), 56 | }) 57 | 58 | useQuery({ 59 | queryKey: ['stripePromise'], 60 | queryFn: async () => { 61 | const url = isDev() ? 'https://s.journal.local' : 'https://s.journal.do' 62 | const { publishableKey } = await fetch(`${url}/api/v1/config`).then((r) => r.json()) 63 | setStripePromise(() => loadStripe(publishableKey)) 64 | return publishableKey 65 | }, 66 | }) 67 | 68 | const { reference, floating, context, refs } = useFloating({ 69 | open, 70 | onOpenChange: setOpen, 71 | nodeId, 72 | }) 73 | 74 | const { getReferenceProps, getFloatingProps } = useInteractions([ 75 | useClick(context), 76 | useDismiss(context, { 77 | escapeKey: false, 78 | }), 79 | ]) 80 | 81 | const handleCloseEsc = (e: any) => { 82 | if (e.key == 'Escape') { 83 | if (refs.floating.current && refs.floating.current.contains(document.activeElement)) { 84 | setOpen(false) 85 | } 86 | } 87 | } 88 | 89 | ////////////////////////// 90 | // 🏓 useEffect 91 | ////////////////////////// 92 | 93 | useEffect(() => { 94 | logger('✅ addEventListener') 95 | document.addEventListener('keydown', handleCloseEsc) 96 | 97 | return () => { 98 | logger('❌ removeEventListener') 99 | document.removeEventListener('keydown', handleCloseEsc) 100 | } 101 | }, []) 102 | 103 | useEffect(() => { 104 | if (open) { 105 | window.electronAPI.capture({ 106 | distinctId: session.user.id, 107 | event: 'settings billing payment-method', 108 | properties: { action: isUpdate ? 'update' : 'add' }, 109 | }) 110 | } 111 | }, [open]) 112 | 113 | ////////////////////////// 114 | // 🚀 Return 115 | ////////////////////////// 116 | 117 | return ( 118 | 119 | {renderTrigger({ open: () => setOpen(true), ref: reference, ...getReferenceProps() })} 120 | 121 | {open && ( 122 | 131 | 132 | 133 | 134 | 142 | 143 | 144 | 145 | 146 | )} 147 | 148 | 149 | ) 150 | } 151 | 152 | export { AddCard } 153 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import type { PostgrestError } from '@supabase/supabase-js' 2 | import { getNodeString } from '@udecode/plate' 3 | 4 | function isRenderer() { 5 | // running in a web browser 6 | if (typeof process === 'undefined') return true 7 | 8 | // node-integration is disabled 9 | if (!process) return true 10 | 11 | // We're in node.js somehow 12 | if (!process.type) return false 13 | 14 | return process.type === 'renderer' 15 | } 16 | 17 | function shallowEqual(object1: any, object2: any) { 18 | const keys1 = Object.keys(object1) 19 | const keys2 = Object.keys(object2) 20 | if (keys1.length !== keys2.length) { 21 | return false 22 | } 23 | for (let key of keys1) { 24 | if (object1[key] !== object2[key]) { 25 | return false 26 | } 27 | } 28 | return true 29 | } 30 | 31 | function arrayEquals(a: Array, b: Array) { 32 | return ( 33 | Array.isArray(a) && 34 | Array.isArray(b) && 35 | a.length === b.length && 36 | a.every((val, index) => val === b[index]) 37 | ) 38 | } 39 | 40 | function isArrayEmpty(a: any[]) { 41 | return Array.isArray(a) && !a.length 42 | } 43 | 44 | const countWords = (text: any) => { 45 | let res = [] 46 | let str = text.replace(/[\t\n\r\.\?\!]/gm, ' ').split(' ') 47 | str.map((s: any) => { 48 | let trimStr = s.trim() 49 | if (trimStr.length > 0) { 50 | res.push(trimStr) 51 | } 52 | }) 53 | return res.length 54 | } 55 | 56 | const countEntryWords = (content: object[]) => { 57 | if (Array.isArray(content)) { 58 | return countWords(content.map((n: any) => getNodeString(n)).join(' ')) 59 | } else { 60 | return 0 61 | } 62 | } 63 | 64 | const entryHasNoContent = (content: any[]) => { 65 | return content.some((n: any) => !getNodeString(n)) 66 | } 67 | 68 | const setCssVars = (items: any, prefix = '-'): void => { 69 | Object.entries(items).flatMap(([key, value]: any) => { 70 | const varName = `${prefix}-${key}` 71 | if (typeof value === 'object') { 72 | setCssVars(value, varName) 73 | } else { 74 | document.documentElement.style.setProperty(varName, value) 75 | } 76 | }) 77 | } 78 | 79 | const createCssVar = (items: [string], prefix = '-'): string[] => 80 | Object.entries(items).flatMap(([key, value]: any) => { 81 | const varName = `${prefix}-${key}` 82 | if (typeof value === 'object') return createCssVar(value, varName) 83 | return `${varName}:${value}` 84 | }) 85 | 86 | const createCssVars = (themeColors: any) => createCssVar(themeColors).join(';') 87 | 88 | const alphaToHex = (alpha: number) => { 89 | if (alpha < 0) alpha = 0 90 | if (alpha > 100) alpha = 100 91 | let multiplier = 255 / 100 92 | let val = Math.round(alpha * multiplier).toString(16) 93 | return val.length == 1 ? '0' + val : val 94 | } 95 | 96 | function ordinal(n: number) { 97 | var s = ['th', 'st', 'nd', 'rd'] 98 | var v = n % 100 99 | return n + (s[(v - 20) % 10] || s[v] || s[0]) 100 | } 101 | 102 | function isDev() { 103 | return process.env.NODE_ENV == 'development' || isTesting() 104 | } 105 | 106 | function isTesting() { 107 | if (isRenderer()) { 108 | return window.electronAPI.isTesting() 109 | } else { 110 | return process.argv.includes('testing') 111 | } 112 | } 113 | 114 | function isUnauthorized(error: PostgrestError) { 115 | if (error.message == 'JWT expired' || error.code == '42501') { 116 | return true 117 | } else { 118 | return false 119 | } 120 | } 121 | 122 | function isUniqueViolation(error: PostgrestError) { 123 | if (error.code == '23505') { 124 | return true 125 | } else { 126 | return false 127 | } 128 | } 129 | 130 | function isForeignKeyViolation(error: PostgrestError) { 131 | if (error.code == '23503') { 132 | return true 133 | } else { 134 | return false 135 | } 136 | } 137 | 138 | function randomInt(max: number) { 139 | // max number (exclusive) 140 | // e.g. max=3 -> 0,1,2 141 | return (max * Math.random()) << 0 142 | } 143 | 144 | const awaitTimeout = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay)) 145 | 146 | const capitalize = (word: string) => { 147 | return word[0].toUpperCase() + word.substring(1) 148 | } 149 | 150 | const displayAmount = (amount: number) => { 151 | // prevent amount being -0 152 | if (amount == 0) { 153 | amount = 0 154 | } 155 | const formatter = new Intl.NumberFormat('en-US', { 156 | style: 'currency', 157 | currency: 'USD', 158 | }) 159 | const a = formatter.format(amount / 100) 160 | if (a.slice(-3) == '.00') { 161 | return a.slice(0, -3) 162 | } else { 163 | return a 164 | } 165 | } 166 | 167 | export { 168 | shallowEqual, 169 | arrayEquals, 170 | countWords, 171 | createCssVars, 172 | setCssVars, 173 | alphaToHex, 174 | ordinal, 175 | isDev, 176 | isTesting, 177 | isUnauthorized, 178 | isUniqueViolation, 179 | isForeignKeyViolation, 180 | randomInt, 181 | isArrayEmpty, 182 | entryHasNoContent, 183 | countEntryWords, 184 | awaitTimeout, 185 | capitalize, 186 | displayAmount, 187 | } 188 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/Upgrade/Features.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { theme } from 'themes' 4 | import { Icon } from 'components' 5 | import * as Accordion from '@radix-ui/react-accordion' 6 | import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote' 7 | import { nanoid } from 'nanoid' 8 | import { logger, supabase, awaitTimeout } from 'utils' 9 | import Skeleton from 'react-loading-skeleton' 10 | import { useQuery } from '@tanstack/react-query' 11 | import { useUserContext } from 'context' 12 | import { onlyText } from 'react-children-utilities' 13 | 14 | const Chevron = styled(Icon)` 15 | transition: transform ${theme('animation.time.normal')}; 16 | [data-state='open'] & { 17 | transform: rotate(180deg); 18 | } 19 | ` 20 | 21 | const AccordionItem = styled(Accordion.Item)`` 22 | 23 | const Open = keyframes` 24 | 0% { 25 | height: 0; 26 | opacity: 0; 27 | padding-bottom: 8px; 28 | } 29 | 100% { 30 | height: var(--radix-accordion-content-height); 31 | opacity: 0.8; 32 | padding-bottom: 16px; 33 | } 34 | ` 35 | 36 | const Close = keyframes` 37 | 0% { 38 | height: var(--radix-accordion-content-height); 39 | opacity: 0.8; 40 | padding-bottom: 16px; 41 | } 42 | 100% { 43 | height: 0; 44 | opacity: 0; 45 | padding-bottom: 8px; 46 | } 47 | ` 48 | 49 | const AccordionContent = styled(Accordion.Content)` 50 | font-weight: 400; 51 | font-size: 12px; 52 | line-height: 20px; 53 | opacity: 0.8; 54 | letter-spacing: normal; 55 | overflow: hidden; 56 | &[data-state='open'] { 57 | animation: ${Open} ${theme('animation.time.normal')} ease-out; 58 | animation-fill-mode: both; 59 | } 60 | &[data-state='closed'] { 61 | animation: ${Close} ${theme('animation.time.normal')} ease-out; 62 | animation-fill-mode: both; 63 | } 64 | ` 65 | 66 | const AccordionHeader = styled(Accordion.Header)` 67 | margin: 0; 68 | ` 69 | 70 | const AccordionTrigger = styled(Accordion.Trigger)` 71 | display: flex; 72 | color: ${theme('color.popper.main')}; 73 | cursor: pointer; 74 | width: -webkit-fill-available; 75 | text-align: left; 76 | background-color: transparent; 77 | border: 0; 78 | border-top: 1px solid ${theme('color.popper.border')}; 79 | outline: 0; 80 | padding: 8px 0; 81 | font-weight: 500; 82 | font-size: 12px; 83 | line-height: 20px; 84 | ` 85 | 86 | const TriggerLabel = styled.span` 87 | flex-grow: 1; 88 | ` 89 | 90 | const H2 = styled.div` 91 | font-weight: 500; 92 | font-size: 16px; 93 | line-height: 24px; 94 | letter-spacing: -0.03em; 95 | color: ${theme('color.popper.main')}; 96 | margin-bottom: 16px; 97 | margin-top: 25px; 98 | ` 99 | 100 | const SkeletonContainer = styled.div` 101 | display: block; 102 | & span { 103 | margin: 4px 0; 104 | } 105 | ` 106 | 107 | const fetchFeaturesAndFAQ = async () => { 108 | const { data, error } = await supabase 109 | .from('website_pages') 110 | .select('content') 111 | .eq('page', 'pricing') 112 | .single() 113 | if (error) { 114 | throw new Error(error.message) 115 | } 116 | // await awaitTimeout(2000) 117 | return await window.electronAPI.mdxSerialize(data?.content ?? '') 118 | } 119 | 120 | const Features = () => { 121 | logger('Features rerender') 122 | const { session } = useUserContext() 123 | const { isLoading, isError, data, error } = useQuery({ 124 | queryKey: ['features'], 125 | queryFn: fetchFeaturesAndFAQ, 126 | }) 127 | 128 | const captureAccordionClick = (item: string) => { 129 | window.electronAPI.capture({ 130 | distinctId: session.user.id, 131 | event: 'settings upgrade feature-expand', 132 | properties: { item }, 133 | }) 134 | } 135 | 136 | const components = { 137 | h2: (props: any) =>

, 138 | Acc: (props: any) => , 139 | AccItem: (props: any) => , 140 | AccTitle: ({ children, ...rest }: any) => ( 141 | <> 142 | captureAccordionClick(onlyText(children))} {...rest}> 143 | 144 | {children} 145 | 146 | 147 | 148 | 149 | ), 150 | AccContent: (props: any) => , 151 | } 152 | 153 | if (isLoading) { 154 | return ( 155 | 156 | 163 | 164 | ) 165 | } 166 | 167 | if (isError) { 168 | return <> 169 | } 170 | 171 | return 172 | } 173 | 174 | export { Features } 175 | -------------------------------------------------------------------------------- /src/components/Menu/AppearanceToolbar/styled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import * as Toolbar from '@radix-ui/react-toolbar' 3 | import styled, { keyframes } from 'styled-components' 4 | import { theme } from 'themes' 5 | import { breakpoints } from 'utils' 6 | 7 | const reveal = keyframes` 8 | 0% { 9 | margin-bottom: -24px; 10 | opacity: 0; 11 | } 12 | 100% { 13 | margin-bottom: 0px; 14 | opacity: 1; 15 | } 16 | ` 17 | 18 | interface ToggleButtonProps { 19 | padding?: string 20 | fontName?: string 21 | } 22 | 23 | const ToggleButtonStyled = styled(({ padding, fontName, ...props }) => ( 24 | 25 | ))` 26 | height: 48px; 27 | font-size: 14px; 28 | line-height: 14px; 29 | min-width: 48px; 30 | padding: ${(props) => (props.padding ? props.padding : '16px')}; 31 | font-family: ${(props) => (props.fontName ? props.fontName : 'inherit')}; 32 | border-radius: 100px; 33 | cursor: pointer; 34 | border: 1px solid ${theme('color.popper.border')}; 35 | background-color: ${theme('color.popper.surface')}; 36 | color: ${theme('color.popper.disabled')}; 37 | transition: opacity ${theme('animation.time.normal')}; 38 | &:disabled { 39 | cursor: initial; 40 | } 41 | &:focus { 42 | outline: 0; 43 | } 44 | &:hover { 45 | transition: border ${theme('animation.time.normal')}; 46 | border: 1px solid ${theme('color.popper.disabled')}; 47 | } 48 | &[data-state='on'] { 49 | opacity: 1; 50 | border: 1px solid ${theme('color.popper.main')}; 51 | border: 1px solid ${theme('color.popper.main')}; 52 | color: ${theme('color.popper.main')}; 53 | } 54 | ` 55 | 56 | const ToggleButtonSmallStyled = styled(({ padding, fontName, ...props }) => ( 57 | 58 | ))` 59 | height: 22px; 60 | font-size: 14px; 61 | line-height: 14px; 62 | min-width: 22px; 63 | display: flex; 64 | padding: 0; 65 | align-items: center; 66 | justify-content: center; 67 | font-family: ${(props) => (props.fontName ? props.fontName : 'inherit')}; 68 | border-radius: 100px; 69 | cursor: pointer; 70 | border: 1px solid ${theme('color.popper.border')}; 71 | background-color: ${theme('color.popper.surface')}; 72 | color: ${theme('color.popper.disabled')}; 73 | transition: ${theme('animation.time.normal')}; 74 | &:disabled { 75 | cursor: initial; 76 | } 77 | &:focus { 78 | outline: 0; 79 | } 80 | &:hover { 81 | border: 1px solid ${theme('color.popper.disabled')}; 82 | } 83 | &[data-state='on'] { 84 | opacity: 1; 85 | border: 1px solid ${theme('color.popper.main')}; 86 | border: 1px solid ${theme('color.popper.main')}; 87 | color: ${theme('color.popper.main')}; 88 | } 89 | ` 90 | 91 | const AppearanceToolbarWrapperStyled = styled.div` 92 | position: fixed; 93 | bottom: 80px; 94 | left: 0; 95 | right: 0; 96 | display: flex; 97 | justify-content: center; 98 | @media ${breakpoints.s} { 99 | transform: scale(0.9); 100 | bottom: 60px; 101 | } 102 | @media ${breakpoints.xs} { 103 | transform: scale(0.7); 104 | bottom: 40px; 105 | } 106 | ` 107 | 108 | const AppearanceToolbarStyled = styled(Toolbar.Root)` 109 | padding: 12px; 110 | border-radius: 100px; 111 | gap: 8px; 112 | display: flex; 113 | box-shadow: ${theme('style.shadow')}; 114 | background-color: ${theme('color.popper.surface')}; 115 | transition: 0; 116 | animation-name: ${reveal}; 117 | animation-duration: ${theme('animation.time.normal')}; 118 | animation-timing-function: ${theme('animation.timingFunction.dynamic')}; 119 | animation-fill-mode: both; 120 | -webkit-app-region: no-drag; 121 | ` 122 | const ToggleGroupStyled = styled(Toolbar.ToggleGroup)` 123 | gap: 8px; 124 | display: flex; 125 | ` 126 | 127 | const ToggleGroupNestedStyled = styled.div` 128 | width: 22px; 129 | gap: 4px; 130 | display: flex; 131 | flex-direction: column; 132 | ` 133 | 134 | const ToggleFontAStyled = styled(ToggleButtonStyled)` 135 | font-size: 13px; 136 | ` 137 | 138 | const ToggleFontAAStyled = styled(ToggleButtonStyled)` 139 | font-size: 16px; 140 | ` 141 | 142 | const ToggleFontAAAStyled = styled(ToggleButtonStyled)` 143 | font-size: 22px; 144 | ` 145 | interface ColorSwatchProps { 146 | fillColor: string 147 | } 148 | 149 | const ColorSwatchStyled = styled.div` 150 | height: 31px; 151 | width: 31px; 152 | border-radius: 100px; 153 | background-color: ${(props) => props.fillColor}; 154 | ` 155 | 156 | const ColorSwatchSmallStyled = styled.div` 157 | height: 14px; 158 | width: 14px; 159 | border-radius: 100px; 160 | background-color: ${(props) => props.fillColor}; 161 | ` 162 | 163 | const HorizontalDividerStyled = styled(Toolbar.Separator)` 164 | background-color: ${theme('color.popper.border')}; 165 | width: 1px; 166 | margin: 4px 8px; 167 | ` 168 | 169 | export { 170 | AppearanceToolbarWrapperStyled, 171 | AppearanceToolbarStyled, 172 | ToggleButtonStyled, 173 | ToggleButtonSmallStyled, 174 | ToggleGroupStyled, 175 | ToggleGroupNestedStyled, 176 | ToggleFontAStyled, 177 | ToggleFontAAStyled, 178 | ToggleFontAAAStyled, 179 | ColorSwatchStyled, 180 | ColorSwatchSmallStyled, 181 | HorizontalDividerStyled, 182 | } 183 | --------------------------------------------------------------------------------