diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..8b7dfb3a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_BUILD_TARGET=extension +VITE_API_URL=https://api.hackertab.dev/ diff --git a/.gitignore b/.gitignore index 64d6e15e..56b52d41 100644 --- a/.gitignore +++ b/.gitignore @@ -4,24 +4,26 @@ .env* *.zip .todo + # dependencies /node_modules /.pnp .pnp.js /dist + # testing /coverage # production /build /dist + # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local - npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/README.md b/README.md index 23041fbd..6f571378 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ As a developer, it can be difficult to stay on top of everything happening in th - 🔖 Bookmark and read it later. - 🌙 Dark mode for when it gets late. - ✨ AI-powered recommendations exclusively tailored to your preferences. - +- 🧠 Chat GPT integration Even more features are going to come in the future! ## Data sources @@ -56,7 +56,8 @@ Please do not hesitate to ask a question, report a bug or add a suggestion. or s ## Development -Please use the develop branch. Create an .env file with the necessary +Please use the develop branch. +don't forget to rename `.env.example` to `.env` ```bash $ git clone --branch develop git@github.com:medyo/hackertab.dev.git diff --git a/public/base.manifest.json b/public/base.manifest.json index 13f81829..9c1bcb55 100644 --- a/public/base.manifest.json +++ b/public/base.manifest.json @@ -1,7 +1,7 @@ { "name": "Hackertab.dev - developer news", "description": "All developer news in one tab", - "version": "1.20.1", + "version": "1.22.0", "chrome_url_overrides": { "newtab": "index.html" }, diff --git a/src/assets/App.css b/src/assets/App.css index d460729b..aced6560 100644 --- a/src/assets/App.css +++ b/src/assets/App.css @@ -11,6 +11,7 @@ body { font-family: 'nunito'; font-size: 100%; } + .appError { justify-content: center; align-items: center; @@ -329,6 +330,7 @@ a { height: 16px; width: 16px; } + .blockHeaderBadge { width: auto; font-size: 12px; @@ -343,6 +345,7 @@ a { text-transform: lowercase; color: white; } + .blockHeaderIcon img { display: block; } @@ -421,6 +424,7 @@ a { .blockRow:not(:last-child) { border-bottom: 1px solid var(--card-content-divider); } + .rowCover { border-radius: 4px; display: block; @@ -441,6 +445,7 @@ a { display: flex; flex-direction: row; } + .rowLink { color: var(--primary-text-color); margin: 0; @@ -454,10 +459,12 @@ a { .rowTitle:hover { color: var(--primary-hover-text-color); } + .titleWithCover { display: block; width: 100%; } + .dark .blockHeaderWhite { color: white; } @@ -907,6 +914,7 @@ Producthunt item padding: 2px; color: var(--tag-background-color); } + /*.searchBarIcon > svg { background-color: white; color: black; @@ -926,9 +934,11 @@ Producthunt item width: 100%; background-color: var(--card-header-background-color); } + .searchBarInput:focus { outline: none; } + .tooltipLoading { display: flex; justify-content: center; @@ -1014,6 +1024,63 @@ Producthunt item opacity: 1; } +.settingsGroup { + margin-bottom: 2rem; + padding: 1rem; + background-color: var(--bg-color-secondary); + border-radius: 8px; +} + +.settingsGroupTitle { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-color-primary); +} + +.settingsGroupContent { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.settingItem { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.settingDescription { + font-size: 0.9rem; + color: var(--text-color-secondary); +} + +.settingSelect { + padding: 0.5rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--bg-color-primary); + color: var(--text-color-primary); + font-size: 0.9rem; + max-width: 300px; +} + +.timeInputs { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.timeInput { + width: 60px; + padding: 0.5rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--bg-color-primary); + color: var(--text-color-primary); + font-size: 0.9rem; +} + /***************** *** BREAKPOINTS *******************/ @@ -1274,9 +1341,7 @@ Producthunt item } .block { - width: calc( - (1800px - 20px * min(5, var(--max-visible-cards))) / min(5, var(--max-visible-cards)) - ); + width: calc((1800px - 20px * min(5, var(--max-visible-cards))) / min(5, var(--max-visible-cards))); } } @@ -1314,7 +1379,7 @@ Producthunt item scroll-snap-type: y mandatory; } -.layoutLayers > * { +.layoutLayers>* { scroll-snap-align: end; } @@ -1337,10 +1402,7 @@ Producthunt item } .preload * { - -webkit-transition: none !important; - -moz-transition: none !important; - -ms-transition: none !important; - -o-transition: none !important; + transition: none !important; } .defaultToast { @@ -1349,6 +1411,7 @@ Producthunt item color: var(--primary-text-color); border-radius: 10px; } + .capitalize { text-transform: capitalize; -} +} \ No newline at end of file diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 749928b7..9274696a 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -15,14 +15,41 @@ import { useUserPreferences } from 'src/stores/preferences' export const Header = () => { const [themeIcon, setThemeIcon] = useState() - const { theme, setTheme, setDNDDuration, isDNDModeActive } = useUserPreferences() + const { theme, setTheme, themePreferences, setThemePreferences, setDNDDuration, isDNDModeActive } = useUserPreferences() const { userBookmarks } = useBookmarks() const navigate = useNavigate() const location = useLocation() + // Check and update theme based on time + useEffect(() => { + const checkAutoTheme = () => { + if (themePreferences.mode === 'auto') { + const now = new Date() + const currentHour = now.getHours() + const { autoStartHour, autoEndHour } = themePreferences + + // If start hour is less than end hour, dark mode is during the same day + // If start hour is greater than end hour, dark mode spans across midnight + const isDarkModeTime = autoStartHour <= autoEndHour + ? currentHour >= autoStartHour || currentHour < autoEndHour + : currentHour >= autoStartHour && currentHour < autoEndHour + + const newTheme = isDarkModeTime ? 'dark' : 'light' + if (theme !== newTheme) { + setTheme(newTheme) + trackThemeSelect(newTheme) + identifyUserTheme(newTheme) + } + } + } + + checkAutoTheme() + const interval = setInterval(checkAutoTheme, 60000) // Check every minute + return () => clearInterval(interval) + }, [themePreferences, theme, setTheme]) + useEffect(() => { document.documentElement.classList.add(theme) - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { @@ -36,10 +63,20 @@ export const Header = () => { }, [theme]) const onThemeChange = () => { - const newTheme = theme === 'dark' ? 'light' : 'dark' - setTheme(newTheme) - trackThemeSelect(newTheme) - identifyUserTheme(newTheme) + if (themePreferences.mode === 'auto') { + // Switch to manual mode with toggled theme + setThemePreferences({ mode: 'manual' }) + const newTheme = theme === 'dark' ? 'light' : 'dark' + setTheme(newTheme) + trackThemeSelect(newTheme) + identifyUserTheme(newTheme) + } else { + // Already in manual mode, just toggle theme + const newTheme = theme === 'dark' ? 'light' : 'dark' + setTheme(newTheme) + trackThemeSelect(newTheme) + identifyUserTheme(newTheme) + } } const onSettingsClick = () => { diff --git a/src/features/settings/components/GeneralSettings/GeneralSettings.tsx b/src/features/settings/components/GeneralSettings/GeneralSettings.tsx index 0915c0ef..37aed124 100644 --- a/src/features/settings/components/GeneralSettings/GeneralSettings.tsx +++ b/src/features/settings/components/GeneralSettings/GeneralSettings.tsx @@ -1,4 +1,5 @@ import React from 'react' +import Select, { SingleValue } from 'react-select' import Toggle from 'react-toggle' import 'react-toggle/style.css' import { ChipsSet } from 'src/components/Elements' @@ -12,10 +13,11 @@ import { trackListingModeSelect, trackMaxVisibleCardsChange, trackTabTarget, + trackThemeModeSelect, trackThemeSelect, } from 'src/lib/analytics' import { useUserPreferences } from 'src/stores/preferences' -import { Option } from 'src/types' +import { Option, ThemeMode } from 'src/types' import { DNDSettings } from './DNDSettings' import './generalSettings.css' @@ -30,6 +32,8 @@ export const GeneralSettings = () => { setListingMode, setMaxVisibleCards, setOpenLinksNewTab, + themePreferences, + setThemePreferences, } = useUserPreferences() const onOpenLinksNewTabChange = (e: React.ChangeEvent) => { @@ -53,6 +57,40 @@ export const GeneralSettings = () => { identifyUserTheme(newTheme) } + const onThemeModeChange = (mode: ThemeMode) => { + setThemePreferences({ mode }) + trackThemeModeSelect(mode) + if (mode === 'auto') { + // Immediately check and apply the correct theme based on time + const now = new Date() + const currentHour = now.getHours() + const { autoStartHour, autoEndHour } = themePreferences + + const isDarkModeTime = autoStartHour <= autoEndHour + ? currentHour >= autoStartHour || currentHour < autoEndHour + : currentHour >= autoStartHour && currentHour < autoEndHour + + const newTheme = isDarkModeTime ? 'dark' : 'light' + if (theme !== newTheme) { + setTheme(newTheme) + trackThemeSelect(newTheme) + identifyUserTheme(newTheme) + } + } + } + + const onDarkModeStartTimeChange = (startHour: number) => { + setThemePreferences({ + autoStartHour: startHour, + }) + } + + const onDarkModeEndTimeChange = (endHour: number) => { + setThemePreferences({ + autoEndHour: endHour, + }) + } + const onMaxVisibleCardsChange = (selectedChips: Option[]) => { if (selectedChips.length) { const maxVisibleCards = parseInt(selectedChips[0].value) @@ -62,6 +100,16 @@ export const GeneralSettings = () => { } } + interface ThemeModeOption { + value: ThemeMode + label: string + } + + const themeModeOptions: ThemeModeOption[] = [ + { value: 'manual', label: 'Manual' }, + { value: 'auto', label: 'Automatic (Time-based)' }, + ] + return ( { 'Customize your experience by selecting the number of cards you want to see, the search engine you want to use and more.' }>
+
+
Theme Settings
+
+
+
+

Theme Mode

+

Choose how you want the theme to be controlled

+
+
+ onDarkModeStartTimeChange(Number(e.target.value))} + /> + to + onDarkModeEndTimeChange(Number(e.target.value))} + /> +
+
+
+ )} +
+
+

Max number of cards to display

@@ -107,21 +219,18 @@ export const GeneralSettings = () => {
-

Dark Mode

-
- +
+

Open links in a new tab

-
- -
-

Open links in a new tab

-

Compact mode

+
+

Compact mode

+
div:first-child { + width: 292px; +} + .settingContent .form { display: flex; flex-direction: row; @@ -86,30 +92,37 @@ Modal cursor: pointer; transition: opacity 0.2s linear; } + .settingContent button:hover { opacity: 0.9; } + .rssButton { background-color: #ee802f; color: white; } + .rssButton:hover { opacity: 0.9; } + .settingContent { width: 100%; flex: 1; } + .settingHint { font-size: 12px; margin-top: 12px; } + .settingHint a { text-decoration: underline; cursor: pointer; font-weight: 500; color: var(--primary-text-color); } + .modalHeader { display: flex; flex-direction: row; @@ -122,6 +135,7 @@ Modal padding: 0; color: var(--primary-text-color); } + .modalCloseBtn { align-items: center; background-color: transparent; @@ -137,10 +151,58 @@ Modal text-align: center; width: 40px; } + .modalCloseBtn:hover { opacity: 0.7; } +.timeInputs { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.timeInput { + width: 40px; + height: 28px; + padding: 2px 4px; + border: 1px solid var(--settings-input-border-color); + border-radius: 4px; + background: var(--settings-input-background-color); + color: var(--settings-input-text-color); + font-size: 13px; + text-align: center; +} + +.timeInput:focus { + outline: none; + border-color: var(--settings-input-border-focus-color); +} + +/* Style the spinners */ +.timeInput::-webkit-inner-spin-button { + opacity: 1; + background: transparent; + cursor: pointer; + height: 24px; +} + +.timeInput::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.timeInput[type=number] { + -moz-appearance: textfield; + appearance: textfield; +} + +.timeInputs span { + color: var(--secondary-text-color); + font-size: 13px; +} + /** Select styles **/ @@ -148,27 +210,34 @@ Select styles background-color: var(--card-background-color) !important; border-color: var(--tag-border-color) !important; } + .hackertab__indicator-separator { background-color: var(--tag-secondary-color) !important; } + .hackertab__indicator { color: var(--tag-secondary-color) !important; } + .hackertab__multi-value { background-color: var(--tag-background-color) !important; border-color: var(--tag-background-color) !important; border-radius: 20px !important; } + .hackertab__multi-value__label { color: var(--tag-text-color) !important; } + .hackertab__menu { background-color: var(--card-background-color) !important; } + .hackertab__option { color: var(--tag-input-background) !important; background-color: var(--card-background-color) !important; } + .hackertab__single-value { color: var(--primary-text-color) !important; } @@ -177,11 +246,26 @@ Select styles background: var(--tag-background-color) !important; color: var(--tag-text-color) !important; } + .hackertab__multi-value__remove { border-radius: 20px !important; color: var(--tag-secondary-color) !important; } +.settingsGroup { + padding: 16px; + background-color: var(--card-background-color); + border: 1px solid var(--card-border-color); + border-radius: 8px; +} + +.settingsGroupTitle { + font-weight: 600; + font-size: 16px; + color: var(--primary-text-color); + margin-bottom: 16px; +} + @media (max-width: 768px) { .Modal { left: 0; @@ -195,11 +279,13 @@ Select styles box-shadow: none; width: auto; } + .settingContent { margin-top: 6px; } + .settingRow { flex-direction: column; align-items: flex-start; } -} +} \ No newline at end of file diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index c03674e1..443c364c 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -51,6 +51,7 @@ export enum Attributes { DIRECTION = 'Direction', SEARCH_ENGINE = 'Search Engine', THEME = 'Theme', + THEME_MODE = 'Theme Mode', LANGUAGE = 'Language', LANGUAGES = 'Languages', DATE_RANGE = 'Date Range', @@ -155,6 +156,14 @@ export const trackThemeSelect = (theme: 'dark' | 'light') => { }) } +export const trackThemeModeSelect = (mode: 'auto' | 'manual') => { + trackEvent({ + object: Objects.THEME, + verb: Verbs.SELECT, + attributes: { [Attributes.THEME_MODE]: mode }, + }) +} + export const trackLanguageAdd = (languageName: string) => { trackEvent({ object: Objects.LANGUAGE, diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index 43c527b5..7620ef27 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -12,11 +12,13 @@ import { SelectedCard, SupportedCardType, Theme, + ThemePreferences } from '../types' export type UserPreferencesState = { userSelectedTags: Tag[] theme: Theme + themePreferences: ThemePreferences openLinksNewTab: boolean onboardingCompleted: boolean onboardingResult: Omit | null @@ -48,6 +50,7 @@ type UserPreferencesStoreActions = { isDNDModeActive: () => boolean addSearchEngine: (searchEngine: SearchEngineType) => void removeSearchEngine: (searchEngineUrl: string) => void + setThemePreferences: (prefs: Partial) => void } const defaultStorage: StateStorage = { @@ -118,6 +121,11 @@ export const useUserPreferences = create( cardsSettings: {}, maxVisibleCards: 4, theme: 'dark', + themePreferences: { + mode: 'manual', + autoStartHour: 19, // 7 PM + autoEndHour: 6, // 6 AM + }, onboardingCompleted: false, onboardingResult: null, searchEngine: 'chatgpt', @@ -176,7 +184,11 @@ export const useUserPreferences = create( DNDDuration: 'never', setSearchEngine: (searchEngine: string) => set({ searchEngine: searchEngine }), setListingMode: (listingMode: ListingMode) => set({ listingMode: listingMode }), - setTheme: (theme: Theme) => set({ theme: theme }), + setTheme: (theme: Theme) => set({ theme }), + setThemePreferences: (prefs: Partial) => + set((state) => ({ + themePreferences: { ...state.themePreferences, ...prefs }, + })), setOpenLinksNewTab: (openLinksNewTab: boolean) => set({ openLinksNewTab: openLinksNewTab }), setCards: (selectedCards: SelectedCard[]) => set({ cards: selectedCards }), setTags: (selectedTags: Tag[]) => set({ userSelectedTags: selectedTags }), diff --git a/src/types/index.ts b/src/types/index.ts index 7ac14c23..501fb4fe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -118,8 +118,15 @@ export type Option = { export type DNDDuration = | { - value: number - countdown: number - } + value: number + countdown: number + } | 'always' | 'never' + +export type ThemeMode = 'auto' | 'manual' +export type ThemePreferences = { + mode: ThemeMode + autoStartHour: number // 24-hour format + autoEndHour: number // 24-hour format +}