├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── documentation ├── dashb-example-01.png ├── dashb-example-02.png ├── dashb-example-03.png ├── dashb-screenshot-01.png ├── demo-screens-01.jpg ├── examples.md ├── widget-embed.md └── widget-rssreader.md ├── index.html ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── android-chrome-144x144.png ├── android-chrome-192x192.png ├── apple-touch-icon.png ├── browserconfig.xml ├── dashb-example-01.png ├── dashb-example-02.png ├── dashb-example-03.png ├── demo-01.gif ├── demo-02.gif ├── demo-screens-01.jpg ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── logo.png ├── mstile-150x150.png ├── safari-pinned-tab.svg ├── site.webmanifest ├── thumbnail-analog-clock.html ├── thumbnail-analog-clock.png ├── thumbnail-embed.png ├── thumbnail-lofi.png ├── thumbnail-quote.png ├── thumbnail-rssreader.png ├── thumbnail-stock.png ├── thumbnail-stockmini.png └── thumbnail-weather.png ├── src ├── App.css ├── App.tsx ├── bg-firefly.css ├── bg-starfield.css ├── components │ ├── AppHeader.tsx │ ├── TutorialModal │ │ └── TutorialModal.tsx │ ├── Widget │ │ └── Widget.tsx │ ├── WidgetSettings │ │ ├── DynamicControl.tsx │ │ ├── Form.tsx │ │ ├── WidgetSettings.tsx │ │ ├── WidgetSettingsTutorial.tsx │ │ ├── dynamic-control-types.ts │ │ ├── form-styles.css │ │ └── form.css │ ├── app │ │ └── ProtectedRoute.tsx │ └── base │ │ ├── AddWidgetModal │ │ └── AddWidgetModal.tsx │ │ ├── Base.tsx │ │ ├── index.tsx │ │ └── styles.module.css ├── favicon.svg ├── hooks │ ├── useAppContext.tsx │ ├── usePubSub.tsx │ └── useWidgetSettings.tsx ├── index.css ├── logo.svg ├── main.tsx ├── pages │ ├── MainPage.tsx │ ├── MainPageUtils.tsx │ ├── MorePage.tsx │ ├── ProtectedPage.tsx │ ├── SettingPage.tsx │ └── TermsPage.tsx ├── react-grid-layout.css ├── react-grid-layout2.css ├── utils │ ├── apiUtils.ts │ ├── appUtils.ts │ └── constants.ts ├── vite-env.d.ts └── widgets │ ├── AirQuality │ ├── AirQuality.json │ └── AirQuality.tsx │ ├── AnalogClock │ ├── AnalogClock.json │ ├── AnalogClock.tsx │ ├── Clock.css │ └── Clock.tsx │ ├── Embed │ ├── Embed.json │ └── Embed.tsx │ ├── LofiPlayer │ ├── LofiPlayer.json │ └── LofiPlayer.tsx │ ├── Note │ ├── Note.json │ └── Note.tsx │ ├── Quote │ ├── Quote.json │ └── Quote.tsx │ ├── RSSReader │ ├── RSSReader.json │ └── RSSReader.tsx │ ├── StockChart │ ├── StockChart.json │ └── StockChart.tsx │ ├── StockMini │ ├── StockMini.json │ └── StockMini.tsx │ ├── Toggl │ ├── Toggl.json │ ├── Toggl.tsx │ └── TogglProjectBarChart.tsx │ ├── Weather │ ├── FormattedDate.tsx │ ├── Weather.css │ ├── Weather.json │ ├── Weather.tsx │ ├── WeatherForecast.css │ ├── WeatherForecast.tsx │ ├── WeatherForecastDay.tsx │ ├── WeatherIcon.tsx │ ├── WeatherInfo.tsx │ ├── WeatherTemperature.tsx │ └── weatherUtils.ts │ └── index.tsx ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── types ├── index.ts ├── nightwind │ └── index.d.ts └── react-animated-weather │ └── index.d.ts └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | quote_type = single 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["prettier", "eslint:recommended", "plugin:@typescript-eslint/recommended"], 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "no-unused-vars": "off", 10 | "@typescript-eslint/no-unused-vars": ["error"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eslintcache 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | pnpm-debug.log* 10 | lerna-debug.log* 11 | 12 | node_modules 13 | dist 14 | dist-ssr 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dashb 2 | 3 | Dashb.io - Minimalist's Dashboard and Widgets. 4 | 5 | [Dashboard Examples](./documentation/examples.md) 6 | 7 | ## Scripts 8 | 9 | ``` 10 | $ npm run build 11 | $ npm run dev 12 | ``` 13 | 14 | [](public/demo-02.gif) 15 | 16 | ## Widgets 17 | 18 | - [x] Air Quality 19 | - [x] Analog Clocks - support multiple clocks and timezones. 20 | - [x] Embed - [Docs](./documentation/widget-embed.md) 21 | - Insert any page to your dashboard using iframe. (news, forum posts..) 22 | - [x] LofiPlayer 23 | - Lofi music player. 24 | - [x] Notes 25 | - Text note with multiple tabs. 26 | - [x] Quotes 27 | - [x] RSS News Reader - [Docs](./documentation/widget-rssreader.md) 28 | - Fetch news from RSS sources. 29 | - [x] StockChart 30 | - [x] StockMini 31 | - [x] Toggl 32 | - Bar chart for Toggl to know where you spent your time. 33 | - [x] Weather 34 | - Daily, hourly weather information. 35 | - [ ] Markdown Notes 36 | 37 | ### ...and your own Widget! 38 | 39 | Create your own widget and publish it for everyone to use. Please open one PR per widget. 40 | 41 | #### Note widget 42 | 43 | - Note widget supports multiple notes or tabs. Click on its Settings to enable other tabs. 44 | 45 | ## Links 46 | 47 | - [Blog: Keep Life Organized With Dashb.io: It changed my morning routine](https://dev.to/ngduc/keep-life-organized-with-dashbio-it-changed-my-morning-routine-2ogb) 48 | -------------------------------------------------------------------------------- /documentation/dashb-example-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/documentation/dashb-example-01.png -------------------------------------------------------------------------------- /documentation/dashb-example-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/documentation/dashb-example-02.png -------------------------------------------------------------------------------- /documentation/dashb-example-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/documentation/dashb-example-03.png -------------------------------------------------------------------------------- /documentation/dashb-screenshot-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/documentation/dashb-screenshot-01.png -------------------------------------------------------------------------------- /documentation/demo-screens-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/documentation/demo-screens-01.jpg -------------------------------------------------------------------------------- /documentation/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - Dashboard with TODO notes, Weather, Air Quality, Stock index, News, Lofi music player 4 | 5 | 6 | - Dashboard to monior Stock prices, Trading notes 7 | 8 | 9 | - Dashboard with different News sources 10 | 11 | 12 | - Use as a Personal Dashboard (for morning routines, quick glance of information) 13 | 14 | -------------------------------------------------------------------------------- /documentation/widget-embed.md: -------------------------------------------------------------------------------- 1 | # Embed 2 | 3 | Embed widget show any web page in its window. It is useful to embed news sites, forum posts, etc. 4 | 5 | ## Embed URL Examples: 6 | 7 | Copy an URL below and paste it in the Embed's setting: 8 | 9 | - Some examples: embed news, forum posts, any web page, etc. 10 | 11 | Embed News Sources: (use these URLs in the widget settings) 12 | - Hacker News - Weekly: https://hn.algolia.com/?dateRange=pastWeek&page=0&prefix=false&query=&sort=byPopularity&type=story 13 | - Reddit Webdev Top Posts: https://feed.mikle.com/widget/v2/164076/?preloader-text=Loading& 14 | 15 | Embed Stocks, Crypto Charts: 16 | - Build your widget, then copy the Iframe's src url: https://www.investing.com/webmaster-tools/ 17 | 18 | Embed Calendar: 19 | 20 | - How to Embed Google Calendar: https://www.howtogeek.com/781315/how-to-embed-google-calendar-on-a-website-or-blog/ 21 | -------------------------------------------------------------------------------- /documentation/widget-rssreader.md: -------------------------------------------------------------------------------- 1 | # RSS News Readers 2 | 3 | RSS News Reader fetches news from an RSS Feed URL specified in its Settings. 4 | 5 | ## RSS Feed Examples: 6 | 7 | Copy an URL below and paste it in the RSS News Reader's setting: 8 | 9 | - Reddit - Technology - Top of the Day: https://www.reddit.com/r/technology/top/.rss?t=day 10 | - Reddit - News - Top of the Day: https://www.reddit.com/r/news/top/.rss?t=day 11 | - LifeHacker: https://lifehacker.com/rss 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dashb.io 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-react-tailwind-headlessui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview", 9 | "lint": "npx eslint ./src --ext ts --ext tsx --ext js", 10 | "lint:fix": "npx eslint ./src --ext ts --ext tsx --ext js --fix", 11 | "prettier": "npx prettier --write ./src", 12 | "prepare": "echo -- husky install" 13 | }, 14 | "dependencies": { 15 | "@hookform/error-message": "^2.0.1", 16 | "@react-oauth/google": "^0.11.1", 17 | "axios": "^1.6.0", 18 | "date-fns": "^2.30.0", 19 | "date-fns-tz": "^2.0.0", 20 | "dotenv": "^16.3.1", 21 | "eventemitter3": "^5.0.1", 22 | "lodash": "^4.17.21", 23 | "logrocket": "^6.0.1", 24 | "query-string": "^8.1.0", 25 | "react": "^18.2.0", 26 | "react-animated-weather": "^4.0.1", 27 | "react-dom": "^18.2.0", 28 | "react-grid-layout": "^1.4.2", 29 | "react-hook-form": "^7.47.0", 30 | "react-icons": "^4.11.0", 31 | "react-router-dom": "^6.17.0", 32 | "react-tooltip": "^5.22.0", 33 | "react-tradingview-embed": "^3.0.6", 34 | "recharts": "^2.9.0", 35 | "swr": "^2.2.4" 36 | }, 37 | "devDependencies": { 38 | "@types/lodash": "^4.14.200", 39 | "@types/react": "^18.2.33", 40 | "@types/react-dom": "^18.2.14", 41 | "@types/react-grid-layout": "^1.3.4", 42 | "@typescript-eslint/parser": "^6.9.0", 43 | "@vitejs/plugin-react": "^4.1.0", 44 | "autoprefixer": "^10.4.16", 45 | "eslint": "^8.52.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "eslint-plugin-prettier": "^5.0.1", 48 | "husky": "^8.0.3", 49 | "lint-staged": "^15.0.2", 50 | "nightwind": "^1.1.13", 51 | "postcss": "^8.4.31", 52 | "prettier": "^3.0.3", 53 | "tailwindcss": "^3.3.5", 54 | "typescript": "^5.2.2", 55 | "vite": "^4.5.0" 56 | }, 57 | "prettier": { 58 | "tabWidth": 2, 59 | "printWidth": 120, 60 | "singleQuote": true, 61 | "trailingComma": "none" 62 | }, 63 | "engines": { 64 | "node": ">=16.0.0" 65 | }, 66 | "lint-staged": { 67 | "*.{ts,tsx,js}": "eslint --cache --fix" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /public/android-chrome-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/android-chrome-144x144.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2d89ef 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/dashb-example-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/dashb-example-01.png -------------------------------------------------------------------------------- /public/dashb-example-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/dashb-example-02.png -------------------------------------------------------------------------------- /public/dashb-example-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/dashb-example-03.png -------------------------------------------------------------------------------- /public/demo-01.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/demo-01.gif -------------------------------------------------------------------------------- /public/demo-02.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/demo-02.gif -------------------------------------------------------------------------------- /public/demo-screens-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/demo-screens-01.jpg -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/logo.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dashb", 3 | "short_name": "Dashb", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-144x144.png", 7 | "sizes": "144x144", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /public/thumbnail-analog-clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-analog-clock.png -------------------------------------------------------------------------------- /public/thumbnail-embed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-embed.png -------------------------------------------------------------------------------- /public/thumbnail-lofi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-lofi.png -------------------------------------------------------------------------------- /public/thumbnail-quote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-quote.png -------------------------------------------------------------------------------- /public/thumbnail-rssreader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-rssreader.png -------------------------------------------------------------------------------- /public/thumbnail-stock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-stock.png -------------------------------------------------------------------------------- /public/thumbnail-stockmini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-stockmini.png -------------------------------------------------------------------------------- /public/thumbnail-weather.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/public/thumbnail-weather.png -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngduc/dashb/5895552355086eceda0e9dd8c5de44ceade645f6/src/App.css -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import nightwind from 'nightwind/helper'; 2 | import { Routes, Route, BrowserRouter } from 'react-router-dom'; 3 | import ProtectedRoute from './components/app/ProtectedRoute'; 4 | import AppHeader from './components/AppHeader'; 5 | import MainPage from './pages/MainPage'; 6 | import { Suspense, lazy } from 'react'; 7 | import './App.css'; 8 | 9 | const SettingPage = lazy(() => import('./pages/SettingPage')); 10 | const MorePage = lazy(() => import('./pages/MorePage')); 11 | const TermsPage = lazy(() => import('./pages/TermsPage')); 12 | 13 | const isDarkMode = (localStorage.getItem('nightwind-mode') ?? 'dark') === 'dark'; 14 | if (isDarkMode) { 15 | nightwind.enable(true); 16 | } 17 | 18 | export default () => { 19 | return ( 20 | 21 |
22 | 23 | 24 | 25 | } /> 26 | }> 30 | 31 | 32 | } 33 | /> 34 | }> 38 | 39 | 40 | } 41 | /> 42 | }> 46 | 47 | 48 | } 49 | /> 50 | 51 | Protected Profile} /> 52 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/AppHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation, useNavigate } from 'react-router-dom'; 2 | import nightwind from 'nightwind/helper'; 3 | import { getLS, getLSUser, logOut } from '../utils/appUtils'; 4 | import { PubSubEvent, usePub, useSub } from '../hooks/usePubSub'; 5 | import { Dropdown } from './base'; 6 | import { useState } from 'react'; 7 | import TutorialModal from './TutorialModal/TutorialModal'; 8 | 9 | type BaseProps = { 10 | children?: any; 11 | className?: string; 12 | onClick?: () => void; 13 | isLoading?: boolean; 14 | style?: object; 15 | [x: string]: any; 16 | }; 17 | // Icons from ngduc/portable-react 18 | export const Icons = { 19 | Brightness: ({ className, ...others }: BaseProps) => ( 20 | 30 | {' '} 31 | {' '} 32 | {' '} 33 | {' '} 34 | {' '} 35 | {' '} 36 | {' '} 37 | {' '} 38 | 39 | 40 | ) 41 | }; 42 | 43 | export default function AppHeader() { 44 | const { pathname } = useLocation(); 45 | const navigate = useNavigate(); 46 | const publish = usePub(); 47 | const [mainKey, setMainKey] = useState('main'); 48 | const [tutorialShowed, setTutorialShowed] = useState(getLS('tutorialShowed', '')); 49 | const lsUser = getLSUser(); 50 | const isAnonymousUser = !lsUser.name || lsUser.name === 'User' || lsUser.name === 'LOCAL'; 51 | const isLoggedIn = !isAnonymousUser && lsUser?.name; 52 | 53 | useSub(PubSubEvent.SignInDone, (user: any) => { 54 | setMainKey(user.id); 55 | }); 56 | // console.log('isAnonymousUser', isAnonymousUser); 57 | 58 | return ( 59 |
63 |

navigate('/')}> 64 | 65 | 66 | {' '} 67 | Dashb 68 | .io 69 |

70 |
71 | 72 | Main 73 | 74 | 75 | More 76 | 77 |
78 | 79 |
80 | 81 | 87 | 93 | {!isLoggedIn && ( 94 | 100 | )} 101 | {!isAnonymousUser && isLoggedIn && ( 102 | 108 | )} 109 | 110 | 119 |
120 | 121 | {!tutorialShowed && ( 122 | { 124 | localStorage.setItem('tutorialShowed', '1'); 125 | setTutorialShowed('1'); 126 | }} 127 | /> 128 | )} 129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/TutorialModal/TutorialModal.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { Modal } from '../base'; 3 | 4 | type Props = { 5 | onConfirm: () => void; 6 | }; 7 | export default function TutorialModal({ onConfirm }: Props) { 8 | const navigate = useNavigate(); 9 | return ( 10 | 15 | Tutorial Screenshot 16 | 17 | {/* 22 | See more Dashboard Examples 23 | */} 24 | { 27 | navigate('/more'); 28 | onConfirm(); 29 | }} 30 | > 31 | See more Dashboard Examples 32 | 33 | 34 | } 35 | onConfirm={onConfirm} 36 | onCancel={onConfirm} 37 | showCancel={false} 38 | > 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Widget/Widget.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import { useWidgetSettings } from '../../hooks/useWidgetSettings'; 3 | // import schema from './AirQuality.json'; 4 | import WidgetSettings, { MoverIcon, SettingsIcon } from '../../components/WidgetSettings/WidgetSettings'; 5 | import { KeyValueString } from '../../../types'; 6 | import { WidgetWidth } from '../../utils/constants'; 7 | import { hToPx, widToName } from '../../utils/appUtils'; 8 | import { PubSubEvent, usePub, useSub } from '../../hooks/usePubSub'; 9 | import { isIframeWidget } from '../../widgets'; 10 | import { useAppContext } from '../../hooks/useAppContext'; 11 | 12 | type Props = { 13 | wid: string; 14 | schema: any; 15 | w: number; 16 | h: number; 17 | cn?: string; 18 | render: ({ 19 | settings, 20 | saveSettings 21 | }: { 22 | settings: KeyValueString; 23 | saveSettings: (settings: KeyValueString) => void; 24 | }) => ReactNode; 25 | onSettings: ({ settings, isSubmitted }: { settings: KeyValueString; isSubmitted?: boolean }) => void; 26 | }; 27 | 28 | export default function Widget({ wid, schema, w, h, cn, render, onSettings }: Props) { 29 | const { tabSettings } = useAppContext(); 30 | const [moverShowed, setMoverShowed] = useState(false); 31 | const [timer, setTimer] = useState(0); 32 | const [isMoving, setIsMoving] = useState(false); 33 | const [settings, setSettings] = useState({}); 34 | const { settingsShowed, saveSettings, toggleSettings } = useWidgetSettings(wid, (settings) => { 35 | setSettings(settings); 36 | onSettings({ settings, isSubmitted: false }); 37 | }); 38 | const publish = usePub(); 39 | 40 | useSub(PubSubEvent.Moving, ({ stop }: { stop: boolean }) => { 41 | if (stop === true) { 42 | setIsMoving(() => false); 43 | } else { 44 | // const newState = !isMoving; 45 | // console.log('newState', isMoving, newState); 46 | // setIsMoving(newState); 47 | // publish(PubSubEvent.MovingToast, { isMoving: newState }); 48 | setIsMoving((isCurrentlyMoving) => { 49 | const newState = !isCurrentlyMoving; 50 | publish(PubSubEvent.MovingToast, { isMoving: newState }); 51 | return newState; 52 | }); 53 | } 54 | }); 55 | const movingCss = `border-[2px] border-yellow-700 draggableHandle cursor-move`; 56 | const borderCss = `${ 57 | isMoving ? movingCss : `border-[1px] border-gray-50 ${tabSettings?.border ?? ''} ${tabSettings?.borderColor ?? ''}` 58 | }`; 59 | 60 | return ( 61 |
66 |
{ 68 | setMoverShowed(true); 69 | if (timer) { 70 | clearTimeout(timer); 71 | } 72 | setTimer(setTimeout(() => setMoverShowed(false), 3000)); 73 | }} 74 | > 75 | {moverShowed && } 76 | 77 |
78 | 79 | {settingsShowed ? ( 80 | { 84 | // console.log('WidgetSettings - onSubmit', settings); 85 | toggleSettings(); 86 | setSettings(settings); 87 | onSettings({ settings, isSubmitted: true }); 88 | }} 89 | onCancel={() => toggleSettings()} 90 | /> 91 | ) : isMoving && isIframeWidget(wid) ? ( 92 | <> 93 | {/* we can't drag an IFrame Widget => only render Widget Name instead: */} 94 |
{widToName(wid) + ' widget'}
95 | 96 | ) : ( 97 | render({ settings, saveSettings }) 98 | )} 99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/components/WidgetSettings/DynamicControl.tsx: -------------------------------------------------------------------------------- 1 | import { useFormContext } from 'react-hook-form'; 2 | import { DynamicFieldData } from './dynamic-control-types'; 3 | 4 | // source: https://codesandbox.io/s/dynamic-form-from-json-112e08?file=/src/DynamicControl.tsx 5 | export const DynamicControl = ({ inputType, fieldName, defaultValue, options = [], config = {} }: DynamicFieldData) => { 6 | const { register } = useFormContext(); 7 | 8 | switch (inputType) { 9 | case 'text': 10 | return ; 11 | case 'select': { 12 | return ( 13 | 20 | ); 21 | } 22 | case 'number': 23 | return ; 24 | default: 25 | return ; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/WidgetSettings/Form.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '@hookform/error-message'; 2 | import { FormProvider, useForm } from 'react-hook-form'; 3 | import { DynamicFieldData } from './dynamic-control-types'; 4 | import { DynamicControl } from './DynamicControl'; 5 | 6 | interface FormProps { 7 | fields: DynamicFieldData[]; 8 | } 9 | 10 | export const Form = ({ fields }: FormProps) => { 11 | const formMethods = useForm(); 12 | const { 13 | handleSubmit, 14 | formState: { isSubmitting, errors } 15 | } = formMethods; 16 | 17 | function onSubmit(data: any) { 18 | console.log(data); 19 | } 20 | 21 | return ( 22 |
23 | 24 | {fields.map((d, i) => ( 25 |
26 | 27 | 28 | 29 | 30 | 31 |
32 | ))} 33 |
34 | 35 | 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/WidgetSettings/WidgetSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useForm } from 'react-hook-form'; 3 | import { AiOutlineSetting } from 'react-icons/ai'; 4 | import { RiDragMove2Fill } from 'react-icons/ri'; 5 | import { PubSubEvent, usePub } from '../../hooks/usePubSub'; 6 | import { useWidgetSettings } from '../../hooks/useWidgetSettings'; 7 | import { Tooltip } from 'react-tooltip'; 8 | import './form.css'; 9 | import { getLS } from '../../utils/appUtils'; 10 | import WidgetSettingsTutorial from './WidgetSettingsTutorial'; 11 | 12 | type Props = { 13 | wid: string; 14 | schema: any; 15 | onSubmit: (data: any) => void; 16 | onCancel: () => void; 17 | }; 18 | 19 | export default function WidgetSettings({ wid, schema, onSubmit, onCancel }: Props) { 20 | const { 21 | register, 22 | handleSubmit, 23 | formState: { errors }, 24 | setValue 25 | } = useForm(); 26 | const { settings, saveSettings } = useWidgetSettings(wid, (settings) => { 27 | // console.log('--- settings', settings); 28 | settings ? Object.keys(settings).forEach((k) => setValue(k, settings[k])) : ''; // set setting values to form 29 | }); 30 | const publish = usePub(); 31 | 32 | const onFormSubmit = async (formData: any) => { 33 | const { data, error } = await saveSettings(formData); 34 | if (!error) { 35 | onSubmit(data.settings); 36 | } 37 | }; 38 | 39 | const handleInputChange = (event: any) => { 40 | const { name, value } = event.target; 41 | // console.log('name, value', event.target.checked); 42 | setValue(name, value); 43 | }; 44 | 45 | const renderFormControl = (key: string, field: any) => { 46 | switch (field.type) { 47 | case 'text': 48 | case 'email': 49 | case 'number': 50 | case 'password': 51 | return ( 52 |
53 | 54 | 64 | {errors && errors[key] && {`${errors[key]?.message}`}} 65 |
66 | ); 67 | case 'textarea': 68 | return ( 69 |
70 | 71 | 72 | 73 | 101 |
102 | ); 103 | }} 104 | /> 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/widgets/Quote/Quote.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "wid": "quote", 4 | "name": "Quote", 5 | "thumbnail": "/thumbnail-quote.png", 6 | "w": 1, 7 | "h": 1 8 | }, 9 | "schema": { 10 | "italic": { 11 | "label": "Italic", 12 | "type": "checkbox" 13 | } 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/widgets/Quote/Quote.tsx: -------------------------------------------------------------------------------- 1 | import json from './Quote.json'; 2 | import Widget from '../../components/Widget/Widget'; 3 | import { useEffect, useState } from 'react'; 4 | import { apiGet } from '../../utils/apiUtils'; 5 | import { FiRefreshCcw } from 'react-icons/fi'; 6 | import _ from 'lodash'; 7 | 8 | type Props = { 9 | wid: string; 10 | }; 11 | 12 | type QuoteData = { 13 | content: string; 14 | author: string; 15 | }; 16 | 17 | export default function Quote({ wid }: Props) { 18 | const [quoteData, setQuoteData] = useState(null); 19 | 20 | const fetch = async () => { 21 | const { data } = await apiGet('https://api.quotable.io/random?tags=famous-quotes', { noCache: true }); 22 | setQuoteData(data); 23 | }; 24 | const fetchDebounced = _.debounce(fetch, 200); 25 | 26 | useEffect(() => { 27 | fetchDebounced(); 28 | }, []); 29 | 30 | return ( 31 | {}} 38 | render={({ settings }) => { 39 | return ( 40 |
41 |
42 |
“{quoteData?.content}”
43 |
— {quoteData?.author}
44 |
45 | 46 | fetchDebounced()}> 47 | 48 | 49 |
50 | ); 51 | }} 52 | /> 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/widgets/RSSReader/RSSReader.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "wid": "rssreader", 4 | "name": "RSS News Reader", 5 | "thumbnail": "/thumbnail-rssreader.png", 6 | "w": 1, 7 | "h": 2 8 | }, 9 | "schema": { 10 | "url": { 11 | "label": "RSS Feed URL", 12 | "type": "text", 13 | "placeholder": "https://rss-feed-url...", 14 | "autoFocus": true, 15 | "className": "w-full", 16 | "validation": { 17 | "required": "URL is required", 18 | "minLength": { 19 | "value": 1, 20 | "message": "URL should have at least 1 character" 21 | } 22 | } 23 | }, 24 | "examples": { 25 | "label": "Note sure what to use?", 26 | "type": "label", 27 | "defaultValue": "Find RSS Feed URL Examples here: Examples" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/widgets/RSSReader/RSSReader.tsx: -------------------------------------------------------------------------------- 1 | import json from './RSSReader.json'; 2 | import Widget from '../../components/Widget/Widget'; 3 | import { useEffect, useState } from 'react'; 4 | import { apiGet } from '../../utils/apiUtils'; 5 | import { FiRefreshCcw } from 'react-icons/fi'; 6 | import _ from 'lodash'; 7 | import { useWidgetSettings } from '../../hooks/useWidgetSettings'; 8 | 9 | type Props = { 10 | wid: string; 11 | }; 12 | 13 | type QuoteData = { 14 | content: string; 15 | author: string; 16 | }; 17 | 18 | const DefaultUrl = 'https://www.reddit.com/r/technology/top/.rss?t=day'; 19 | 20 | export default function RSSReader({ wid }: Props) { 21 | const [items, setItems] = useState([]); 22 | const [url, setUrl] = useState(''); 23 | const [err, setErr] = useState(''); 24 | 25 | const fetch = async () => { 26 | if (url) { 27 | const { data, error } = await apiGet(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(url)}`, { 28 | noCache: true 29 | }); 30 | if (error) { 31 | setErr(error); 32 | } else { 33 | setItems(data?.items ?? []); 34 | } 35 | } 36 | }; 37 | const fetchDebounced = _.debounce(fetch, 200); 38 | 39 | const { settings } = useWidgetSettings(wid, (settings) => { 40 | setUrl(settings?.url ?? DefaultUrl); 41 | }); 42 | 43 | useEffect(() => { 44 | fetchDebounced(); 45 | }, [url]); 46 | 47 | return ( 48 | { 55 | setUrl(settings?.url); 56 | }} 57 | render={({ settings }) => { 58 | return ( 59 |
60 | {err ? ( 61 |
Failed to load RSS URL. Please try with another URL in the Settings.
62 | ) : ( 63 | <> 64 | 75 | 76 | )} 77 |
78 | ); 79 | }} 80 | /> 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/widgets/StockChart/StockChart.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "wid": "stock", 4 | "name": "Stock Chart", 5 | "thumbnail": "/thumbnail-stock.png", 6 | "w": 1, 7 | "h": 2 8 | }, 9 | "schema": { 10 | "symbol": { 11 | "label": "Symbol", 12 | "type": "text", 13 | "placeholder": "Stock Symbol", 14 | "autoFocus": true, 15 | "validation": { 16 | "required": "Symbol is required", 17 | "minLength": { 18 | "value": 1, 19 | "message": "Symbol should have at least 1 character" 20 | } 21 | } 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/widgets/StockChart/StockChart.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from 'react'; 2 | import { SymbolOverview } from 'react-tradingview-embed'; 3 | import json from './StockChart.json'; 4 | import Widget from '../../components/Widget/Widget'; 5 | import { WidgetWidth } from '../../utils/constants'; 6 | import { PubSubEvent, useSub } from '../../hooks/usePubSub'; 7 | import { hToPx } from '../../utils/appUtils'; 8 | 9 | type Props = { 10 | wid: string; 11 | symbol: string; 12 | }; 13 | 14 | export default function StockChart({ wid, symbol }: Props) { 15 | const [currentSymbol, setCurrentSymbol] = useState(symbol); 16 | const [theme, setTheme] = useState(localStorage.getItem('nightwind-mode') ?? 'dark'); 17 | useSub(PubSubEvent.ThemeChange, () => { 18 | setTheme(localStorage.getItem('nightwind-mode') ?? 'dark'); 19 | }); 20 | 21 | // memo: to avoid re-rendering (when moving widget) 22 | const Chart = memo(() => { 23 | return ( 24 | <> 25 |
26 | 36 | 37 | ); 38 | }); 39 | 40 | return ( 41 | { 48 | setCurrentSymbol(settings?.symbol ?? symbol); // default to symbol prop if no settings 49 | }} 50 | render={() => { 51 | return ; 52 | }} 53 | /> 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/widgets/StockMini/StockMini.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "wid": "stockmini", 4 | "name": "Stock Chart Mini", 5 | "thumbnail": "/thumbnail-stockmini.png", 6 | "w": 1, 7 | "h": 1 8 | }, 9 | "schema": { 10 | "symbol": { 11 | "label": "Symbol", 12 | "type": "text", 13 | "placeholder": "Stock Symbol", 14 | "autoFocus": true, 15 | "validation": { 16 | "required": "Symbol is required", 17 | "minLength": { 18 | "value": 1, 19 | "message": "Symbol should have at least 1 character" 20 | } 21 | } 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/widgets/StockMini/StockMini.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo, useState } from 'react'; 2 | import { MiniChart, SymbolOverview } from 'react-tradingview-embed'; 3 | import json from './StockMini.json'; 4 | import Widget from '../../components/Widget/Widget'; 5 | import { WidgetHeight, WidgetWidth } from '../../utils/constants'; 6 | import { PubSubEvent, useSub } from '../../hooks/usePubSub'; 7 | 8 | type Props = { 9 | wid: string; 10 | symbol: string; 11 | }; 12 | 13 | export default function StockMini({ wid, symbol }: Props) { 14 | const [currentSymbol, setCurrentSymbol] = useState(symbol); 15 | const [theme, setTheme] = useState(localStorage.getItem('nightwind-mode') ?? 'dark'); 16 | useSub(PubSubEvent.ThemeChange, () => { 17 | setTheme(localStorage.getItem('nightwind-mode') ?? 'dark'); 18 | }); 19 | 20 | // memo: to avoid re-rendering (when moving widget) 21 | const Chart = memo(() => { 22 | return ( 23 | 32 | ); 33 | }); 34 | 35 | return ( 36 | { 43 | setCurrentSymbol(settings?.symbol ?? symbol); // default to symbol prop if no settings 44 | }} 45 | render={() => { 46 | return ; 47 | }} 48 | /> 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/widgets/Toggl/Toggl.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "wid": "toggl", 4 | "name": "Toggl", 5 | "thumbnail": "", 6 | "w": 1, 7 | "h": 2 8 | }, 9 | "schema": { 10 | "apiKey": { 11 | "label": "API Key", 12 | "type": "text", 13 | "autoFocus": true, 14 | "className": "w-full", 15 | "validation": { 16 | "required": "API Key is required", 17 | "minLength": { 18 | "value": 1, 19 | "message": "API Key should have at least 1 character" 20 | } 21 | } 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/widgets/Toggl/Toggl.tsx: -------------------------------------------------------------------------------- 1 | import TogglProjectBarChart from './TogglProjectBarChart'; 2 | import json from './Toggl.json'; 3 | import Widget from '../../components/Widget/Widget'; 4 | 5 | type Props = { 6 | wid: string; 7 | }; 8 | 9 | export default function Toggl({ wid }: Props) { 10 | return ( 11 | {}} 18 | render={({ settings }) => { 19 | if (!settings?.apiKey) { 20 | return
Toggl - Requires API Key (set it in Settings)
; 21 | } 22 | return ; 23 | }} 24 | /> 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/widgets/Toggl/TogglProjectBarChart.tsx: -------------------------------------------------------------------------------- 1 | // export default function TogglProjectBarChart() { 2 | // return
TogglProjectBarChart
; 3 | // } 4 | 5 | import React, { useState } from 'react'; 6 | import _ from 'lodash'; 7 | import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, LabelList } from 'recharts'; 8 | import { zonedTimeToUtc, format } from 'date-fns-tz'; 9 | import { apiGet } from '../../utils/apiUtils'; 10 | import { useAppContext } from '../../hooks/useAppContext'; 11 | import { KeyValueString } from '../../../types'; 12 | import { WidgetHeight, WidgetWidth } from '../../utils/constants'; 13 | 14 | // // Your project mapping data 15 | // const projectMappingData = [ 16 | // { id: 195933427, name: 'kid' }, 17 | // { id: 195933439, name: 'home' }, 18 | // { id: 195934120, name: 'others' }, 19 | // { id: 195939159, name: 'project' } 20 | // ]; 21 | 22 | // // Define a dictionary mapping project names to colors 23 | // const colorDict: { [key: string]: string } = { kid: 'green', home: 'blue', others: 'brown', project: 'purple' }; // Add more mappings if needed 24 | 25 | // // Convert project mapping data to a dictionary for easy lookup 26 | // const projectMappingDict: { [key: number]: string } = {}; 27 | // projectMappingData.forEach((item) => { 28 | // projectMappingDict[item.id] = item.name; 29 | // }); 30 | 31 | const CustomTooltip = ({ active, payload, label }: any) => { 32 | if (active && payload && payload.length) { 33 | return ( 34 |
35 |

{`${label}`}

36 | {payload.map((item: any) => ( 37 |

{`${item.name}: ${item.value.toFixed(2)} minutes`}

38 | ))} 39 |
40 | ); 41 | } 42 | 43 | return null; 44 | }; 45 | 46 | type Props = { 47 | wid: string; 48 | }; 49 | 50 | export default function TogglProjectBarChart({ wid }: Props) { 51 | const [data, setData] = React.useState([]); 52 | const [settings, setSettings] = React.useState({}); 53 | const { jwtToken } = useAppContext(); 54 | const [err, setErr] = useState(''); 55 | const [mainKey, setMainKey] = useState(''); 56 | 57 | const [projects, setProjects] = useState([]); 58 | const [colorDict, setColorDict] = useState({}); 59 | // Your project mapping data 60 | // const projectMappingData = [ 61 | // { id: 195933427, name: 'kid' }, 62 | // { id: 195933439, name: 'home' }, 63 | // { id: 195934120, name: 'others' }, 64 | // { id: 195939159, name: 'project' } 65 | // ]; 66 | 67 | const fetchData = async () => { 68 | const { data, error } = await apiGet(`/api/toggl/entries?wid=${wid}`, {}); 69 | if (error) { 70 | setErr(error.message); 71 | return; 72 | } 73 | const timeEntries = data?.entries ?? []; 74 | const projects = data?.projects ?? []; 75 | 76 | // Define a dictionary mapping project names to colors 77 | // const cdict: { [key: string]: string } = { kid: 'green', home: 'blue', others: 'brown', project: 'purple' }; // Add more mappings if needed 78 | const cdict: { [key: string]: string } = {}; 79 | 80 | // Convert project mapping data to a dictionary for easy lookup 81 | const projectMappingDict: { [key: number]: string } = {}; 82 | projects.forEach((item: any) => { 83 | projectMappingDict[item.id] = item.name; 84 | cdict[item.name] = item.color; 85 | }); 86 | // console.log(cdict, projectMappingDict, projects); 87 | 88 | setProjects(projects); 89 | setColorDict(cdict); 90 | 91 | // Filter out entries with negative duration 92 | const filteredEntries = timeEntries.filter((entry: any) => entry.duration >= 0); 93 | 94 | // Convert start and stop to datetime, and get date from start 95 | filteredEntries.forEach((entry: any) => { 96 | const pacificTimeZone = 'America/Los_Angeles'; 97 | entry.tzStart = format(zonedTimeToUtc(entry.start, pacificTimeZone), 'MM-dd'); 98 | entry.tzStop = format(zonedTimeToUtc(entry.stop, pacificTimeZone), 'MM-dd'); 99 | entry.date = entry.tzStart; 100 | 101 | // Map project_id to project_name 102 | entry.project_name = projectMappingDict[entry.project_id]; 103 | 104 | // Convert duration from seconds to minutes 105 | entry.durationMins = entry.duration / 60; 106 | }); 107 | // console.log('filteredEntries', filteredEntries, projectMappingDict); 108 | 109 | // Group by date and project_name, and sum the durations 110 | const groupedData = _.groupBy(filteredEntries, 'date'); 111 | let chartData: any[] = []; 112 | 113 | Object.keys(groupedData).forEach((date) => { 114 | const dateGroup = groupedData[date]; 115 | const item: any = { date }; 116 | 117 | dateGroup.forEach((entry: any) => { 118 | // console.log('entry', entry); 119 | if (item[entry.project_name]) { 120 | item[entry.project_name] += entry.durationMins; 121 | } else { 122 | item[entry.project_name] = entry.durationMins; 123 | } 124 | }); 125 | const obj = { ...item }; 126 | delete obj.date; 127 | item.total = _.sum(Object.values(obj)); 128 | 129 | dateGroup.forEach((entry: any) => { 130 | item[entry.project_name + '_pct'] = Math.round((item[entry.project_name] * 100) / item.total); 131 | }); 132 | 133 | // console.log('dateEntry', dateEntry); 134 | chartData.push(item); 135 | }); 136 | // Sort chartData by date 137 | chartData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); 138 | 139 | chartData = chartData.slice(-3); // only take 3 recent days 140 | setData(chartData); 141 | }; 142 | const fetchDataDebounced = _.debounce(fetchData, 200); 143 | 144 | React.useEffect(() => { 145 | // console.log('projects', projects); 146 | // if (projects.length > 0) { 147 | // const a = fetchDataDebounced(); 148 | // console.log('a', a); 149 | // a?.then((chartData) => { 150 | // setData(chartData ?? []); 151 | // setMainKey(`${Math.random()}`); 152 | // console.log('chartData', chartData); 153 | // }); 154 | fetchDataDebounced(); 155 | // } 156 | }, [err]); 157 | 158 | const minutesToHoursMinutes = (minutes: number) => { 159 | const hours = Math.floor(minutes / 60); 160 | const remainingMinutes = Math.round(minutes % 60); 161 | return `${hours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}`; 162 | }; 163 | 164 | // const calculatePercentage = (value: number, total: number) => { 165 | // return ((value / total) * 100).toFixed(2); 166 | // }; 167 | 168 | const renderCustomizedLabel = () => { 169 | return (props: any) => { 170 | const { x, y, width, value } = props; 171 | return value ? ( 172 | 173 | 181 | {value}% 182 | 183 | 184 | ) : ( 185 | '' 186 | ); 187 | }; 188 | }; 189 | // console.log(projects, data); 190 | 191 | return ( 192 |
193 | 204 | 205 | 206 | 207 | {/* } /> */} 208 | 209 | {projects.map((project) => ( 210 | 211 | minutesToHoursMinutes(value)} 216 | /> 217 | 218 | 219 | ))} 220 | 221 |
222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /src/widgets/Weather/FormattedDate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function FormattedDate(props: any) { 4 | let days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 5 | let day = days[props.date.getDay()]; 6 | let hours = props.date.getHours(); 7 | if (hours < 10) { 8 | hours = `0${hours}`; 9 | } 10 | 11 | let minutes = props.date.getMinutes(); 12 | if (minutes < 10) { 13 | minutes = `0${minutes}`; 14 | } 15 | // {day}{hours}{`:`}{minutes} 16 | 17 | return ( 18 |
19 | {day} {hours} 20 | {`:`} 21 | {minutes} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/widgets/Weather/Weather.css: -------------------------------------------------------------------------------- 1 | .Weather { 2 | font-family: arial, sans-serif; 3 | /* background-color: #b0d6eb; */ 4 | /* background-color: #333; */ 5 | /* border: 1px solid #cacac8; */ 6 | padding: 4px; 7 | border-radius: 4px; 8 | /* color: rgb(135, 135, 135); */ 9 | /* color: #ccc; */ 10 | } 11 | 12 | .Weather form { 13 | /* margin-bottom: 20px; */ 14 | } 15 | 16 | .Weather h1 { 17 | display: block; 18 | font-size: 16px; 19 | /* font-weight: 100; */ 20 | /* margin: 20px 0 0 0; */ 21 | /* line-height: 29px; */ 22 | } 23 | 24 | .Weather ul { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | .Weather li { 30 | font-size: 12px; 31 | font-weight: 100; 32 | line-height: 19px; 33 | list-style: none; 34 | } 35 | 36 | .Weather canvas { 37 | margin-top: 5px; 38 | height: 32px; 39 | width: 32px; 40 | margin-right: 5px auto; 41 | display: inline-block; 42 | } 43 | 44 | .Weather .temperature { 45 | font-weight: bold; 46 | /* color: rgb(33, 33, 33); */ 47 | /* color: #ccc; */ 48 | font-size: 52px; 49 | line-height: 52px; 50 | font-weight: 400; 51 | } 52 | 53 | .Weather .unit { 54 | /* color: rgb(33, 33, 33); */ 55 | font-size: 16px; 56 | font-weight: 400; 57 | position: relative; 58 | line-height: 1; 59 | top: -32px; 60 | } 61 | -------------------------------------------------------------------------------- /src/widgets/Weather/Weather.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "wid": "weather", 4 | "name": "Weather", 5 | "thumbnail": "/thumbnail-weather.png", 6 | "w": 1, 7 | "h": 1 8 | }, 9 | "schema": { 10 | "city": { 11 | "label": "City", 12 | "type": "text", 13 | "autoFocus": true, 14 | "validation": { 15 | "required": "City is required", 16 | "minLength": { 17 | "value": 1, 18 | "message": "City should have at least 1 character" 19 | } 20 | } 21 | }, 22 | "days": { 23 | "label": "Days", 24 | "type": "select", 25 | "defaultValue": "4", 26 | "options": [ 27 | { 28 | "value": "1", 29 | "label": "1" 30 | }, 31 | { 32 | "value": "2", 33 | "label": "2" 34 | }, 35 | { 36 | "value": "3", 37 | "label": "3" 38 | }, 39 | { 40 | "value": "4", 41 | "label": "4" 42 | }, 43 | { 44 | "value": "5", 45 | "label": "5" 46 | }, 47 | { 48 | "value": "6", 49 | "label": "6" 50 | }, 51 | { 52 | "value": "7", 53 | "label": "7" 54 | } 55 | ], 56 | "validation": { 57 | "required": "Days is required" 58 | } 59 | }, 60 | "hourly": { 61 | "label": "Hourly", 62 | "type": "checkbox" 63 | }, 64 | "useFahrenheit": { 65 | "label": "Use Fahrenheit", 66 | "type": "checkbox" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/widgets/Weather/Weather.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import WeatherInfo from './WeatherInfo'; 3 | import WeatherForecast from './WeatherForecast'; 4 | import './Weather.css'; 5 | 6 | import json from './Weather.json'; 7 | import Widget from '../../components/Widget/Widget'; 8 | import { apiGet } from '../../utils/apiUtils'; 9 | import { useAppContext } from '../../hooks/useAppContext'; 10 | 11 | type Props = { 12 | wid: string; 13 | }; 14 | 15 | const DefaultCity = 'New York'; 16 | 17 | export default function Weather({ wid }: Props) { 18 | const [weatherData, setWeatherdData] = useState({ ready: false }); 19 | 20 | async function search(city: string) { 21 | let apiUrl = `/api/weather/data?wid=${wid}&city=${city}`; 22 | const { data } = await apiGet(apiUrl, {}); 23 | const info = data.data; 24 | setWeatherdData({ 25 | ready: true, 26 | coordinates: info.coord, 27 | temperature: info.main.temp, 28 | humidity: info.main.humidity, 29 | date: new Date(info.dt * 1000), 30 | description: info.weather[0].description, 31 | icon: info.weather[0].icon, 32 | wind: info.wind.speed, 33 | city: info.name 34 | }); 35 | } 36 | 37 | return ( 38 | { 45 | search(settings?.city ?? DefaultCity); 46 | }} 47 | render={({ settings }) => { 48 | // console.log('settings', settings); 49 | return ( 50 | <> 51 | {weatherData.ready && ( 52 |
53 | 54 | 59 |
60 | )} 61 | 62 | ); 63 | }} 64 | /> 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/widgets/Weather/WeatherForecast.css: -------------------------------------------------------------------------------- 1 | .WeatherForecast { 2 | text-align: center; 3 | } 4 | 5 | .WeatherForecast-temperature-min { 6 | opacity: 0.6; 7 | margin-left: 5px; 8 | } 9 | -------------------------------------------------------------------------------- /src/widgets/Weather/WeatherForecast.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import './WeatherForecast.css'; 3 | import WeatherForecastDay from './WeatherForecastDay'; 4 | import { useAppContext } from '../../hooks/useAppContext'; 5 | import { apiGet } from '../../utils/apiUtils'; 6 | import { cToF } from './weatherUtils'; 7 | 8 | export default function WeatherForecast(props: any) { 9 | let [loaded, setLoaded] = useState(false); 10 | let [forecast, setForecast] = useState(null); 11 | const [hourlyData, setHourlyData] = useState(null); 12 | 13 | useEffect(() => { 14 | setLoaded(false); 15 | }, [props.coordinates]); 16 | 17 | async function load() { 18 | let longitude = props.coordinates.lon; 19 | let latitude = props.coordinates.lat; 20 | const apiUrl = `/api/weather/onecall?lat=${latitude}&lon=${longitude}`; 21 | 22 | const { data } = await apiGet(apiUrl, {}); 23 | setForecast(data.data.daily); 24 | 25 | const arr: any = []; 26 | (data?.data?.hourly ?? []).forEach((item: any, idx: number) => { 27 | if (idx % 3 === 0) { 28 | arr.push(item); 29 | } 30 | }); 31 | setHourlyData(arr); 32 | setLoaded(true); 33 | } 34 | 35 | if (loaded) { 36 | return ( 37 |
38 |
39 | {forecast.map(function (dailyForecast: any, index: number) { 40 | if (index < props.days) { 41 | return ( 42 |
43 | 44 |
45 | ); 46 | } else { 47 | return null; 48 | } 49 | })} 50 |
51 | 52 | {props?.settings?.hourly === true && ( 53 |
54 | {hourlyData.map((item: any, idx: number) => { 55 | if (idx > 4) { 56 | return; 57 | } 58 | return ( 59 | 60 | {new Date(item.dt * 1000) 61 | .toLocaleString('en-US') 62 | .replace(/:00:00/g, '') 63 | .split(',') 64 | .slice(1) // example: '10/31/2023, 07:00:00 PM' => '07 PM' 65 | .join(' ') + 66 | ' ' + 67 | (props?.settings?.useFahrenheit ? Math.round(cToF(item.temp)) : Math.round(item.temp)) + 68 | '°'} 69 | 70 | ); 71 | })} 72 |
73 | )} 74 |
75 | ); 76 | } else { 77 | load(); 78 | 79 | return null; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/widgets/Weather/WeatherForecastDay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import WeatherIcon from './WeatherIcon'; 3 | import { cToF } from './weatherUtils'; 4 | 5 | export default function WeatherForecastDay(props: any) { 6 | function maxTemperature() { 7 | let temperature = Math.round(props.data.temp.max); 8 | return `${props.useFahrenheit ? Math.round(cToF(temperature)) : Math.round(temperature)}°`; 9 | } 10 | 11 | function minTemperature() { 12 | let temperature = Math.round(props.data.temp.min); 13 | return `${props.useFahrenheit ? Math.round(cToF(temperature)) : Math.round(temperature)}°`; 14 | } 15 | 16 | function day() { 17 | let date = new Date(props.data.dt * 1000); 18 | let day = date.getDay(); 19 | 20 | let days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 21 | 22 | return days[day]; 23 | } 24 | 25 | return ( 26 |
27 |
{day()}
28 | 29 |
30 | {maxTemperature()} 31 | {minTemperature()} 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/widgets/Weather/WeatherIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactAnimatedWeather from 'react-animated-weather'; 3 | import { KeyValueString } from '../../../types'; 4 | 5 | export default function WeatherIcon(props: any) { 6 | const codeMapping: KeyValueString = { 7 | '01d': 'CLEAR_DAY', 8 | '01n': 'CLEAR_NIGHT', 9 | '02d': 'PARTLY_CLOUDY_DAY', 10 | '02n': 'PARTLY_CLOUDY_NIGHT', 11 | '03d': 'PARTLY_CLOUDY_DAY', 12 | '03n': 'PARTLY_CLOUDY_NIGHT', 13 | '04d': 'CLOUDY', 14 | '04n': 'CLOUDY', 15 | '09d': 'RAIN', 16 | '09n': 'RAIN', 17 | '10d': 'RAIN', 18 | '10n': 'RAIN', 19 | '11d': 'RAIN', 20 | '11n': 'RAIN', 21 | '13d': 'SNOW', 22 | '13n': 'SNOW', 23 | '50d': 'FOG', 24 | '50n': 'FOG' 25 | }; 26 | 27 | return ; 28 | } 29 | -------------------------------------------------------------------------------- /src/widgets/Weather/WeatherInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import FormattedDate from './FormattedDate'; 3 | import WeatherIcon from './WeatherIcon'; 4 | import WeatherTemperature from './WeatherTemperature'; 5 | 6 | const toMilesPerHouse = (kph: number) => { 7 | return kph / 1.609344; 8 | }; 9 | 10 | type Props = { 11 | data: any; 12 | settings: any; 13 | }; 14 | 15 | export default function WeatherInfo({ data, settings }: Props) { 16 | return ( 17 |
18 |
19 |

{data.city}

20 |
    21 |
  • 22 | 23 |
  • 24 |
  • {data.description}
  • 25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 | 39 |
40 |
41 |
42 |
43 |
    44 |
  • Humidity: {Math.round(data.humidity)}%
  • 45 |
  • Wind: {Math.round(toMilesPerHouse(data.wind))} mph
  • 46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/widgets/Weather/WeatherTemperature.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent, useState } from 'react'; 2 | import { cToF } from './weatherUtils'; 3 | 4 | type Props = { 5 | celsius: number; 6 | useFahrenheit: boolean; 7 | }; 8 | 9 | export default function WeatherTemperature({ celsius, useFahrenheit }: Props) { 10 | const [unit, setUnit] = useState('fahrenheit'); 11 | 12 | // function showFahrenheit(event: MouseEvent) { 13 | // event.preventDefault(); 14 | // setUnit('fahrenheit'); 15 | // } 16 | 17 | // function showCelsius(event: MouseEvent) { 18 | // event.preventDefault(); 19 | // setUnit('celsius'); 20 | // } 21 | 22 | if (unit === 'celsius') { 23 | return ( 24 |
25 | {Math.round(celsius)} 26 | {/* 27 | ℃ |{' '} 28 | 29 | ℉ 30 | 31 | */} 32 |
33 | ); 34 | } else { 35 | return ( 36 |
37 | {useFahrenheit ? Math.round(cToF(celsius)) : Math.round(celsius)} 38 | {/* 39 | 40 | ℃ 41 | 42 | | ℉ 43 | */} 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/widgets/Weather/weatherUtils.ts: -------------------------------------------------------------------------------- 1 | export function cToF(cel: number) { 2 | return (cel * 9) / 5 + 32; 3 | } 4 | -------------------------------------------------------------------------------- /src/widgets/index.tsx: -------------------------------------------------------------------------------- 1 | import { Widget } from '../../types'; 2 | import jsonAnalogClock from './AnalogClock/AnalogClock.json'; 3 | import jsonAirQuality from './AirQuality/AirQuality.json'; 4 | import jsonEmbed from './Embed/Embed.json'; 5 | import jsonLofiPlayer from './LofiPlayer/LofiPlayer.json'; 6 | import jsonNote from './Note/Note.json'; 7 | import jsonQuote from './Quote/Quote.json'; 8 | import jsonRSSReader from './RSSReader/RSSReader.json'; 9 | import jsonStockChart from './StockChart/StockChart.json'; 10 | import jsonStockMini from './StockMini/StockMini.json'; 11 | import jsonToggl from './Toggl/Toggl.json'; 12 | import jsonWeather from './Weather/Weather.json'; 13 | 14 | export function isIframeWidget(wid: string) { 15 | return wid.startsWith('stock') || wid.startsWith('embed') || wid.startsWith('rssreader'); 16 | } 17 | 18 | export function isDoubleHeightWidget(wid: string) { 19 | return ( 20 | wid.startsWith('embed-') || wid.startsWith('stock-') || wid.startsWith('toggl-') || wid.startsWith('rssreader-') 21 | ); 22 | } 23 | 24 | export const widgetList: Widget[] = [ 25 | jsonAnalogClock, 26 | jsonAirQuality, 27 | jsonEmbed, 28 | jsonLofiPlayer, 29 | jsonNote, 30 | jsonQuote, 31 | jsonRSSReader, 32 | jsonStockChart, 33 | jsonStockMini, 34 | jsonToggl, 35 | jsonWeather 36 | ]; 37 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{html,js,jsx,ts,tsx}'], 3 | theme: { 4 | extend: {} 5 | }, 6 | darkMode: 'class', 7 | plugins: [require('nightwind'), require('tailwindcss'), require('autoprefixer')] 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src", "types"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type KeyValueString = { 2 | [key: string]: string; 3 | }; 4 | 5 | export type WidgetSettingsProps = { 6 | wid: string; 7 | onSubmit: (settings: KeyValueString) => void; 8 | }; 9 | 10 | type WidgetInfo = { 11 | wid: string; 12 | name: string; 13 | thumbnail: string; 14 | w: number; 15 | h: number; 16 | }; 17 | 18 | export type Widget = { 19 | wid?: string; 20 | info: WidgetInfo; 21 | schema: { [key: string]: any }; 22 | }; 23 | 24 | export type UserWidget = { 25 | wid: string; 26 | }; 27 | -------------------------------------------------------------------------------- /types/nightwind/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'nightwind/helper'; 2 | -------------------------------------------------------------------------------- /types/react-animated-weather/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-animated-weather'; 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default ({ mode }) => { 6 | process.env = Object.assign(process.env, loadEnv(mode, process.cwd(), '')); 7 | 8 | return defineConfig({ 9 | plugins: [react()], 10 | // proxy for Backend APIs to avoid CORS issues. 11 | server: { 12 | port: 5300, // UI port 13 | proxy: { 14 | '/api': { 15 | target: process.env.VITE_UI_API_BASE, // see .env, DEV: "http://localhost:3050", 16 | changeOrigin: true, 17 | secure: false 18 | } 19 | } 20 | } 21 | }); 22 | }; 23 | --------------------------------------------------------------------------------