├── .prettierignore ├── pnpm-workspace.yaml ├── packages ├── client │ ├── src │ │ ├── types │ │ │ ├── vite-plugin-svgr.d.ts │ │ │ ├── mantine-theme.d.ts │ │ │ ├── feed.ts │ │ │ ├── layout.ts │ │ │ ├── typeGuards.ts │ │ │ ├── types.ts │ │ │ └── schema.ts │ │ ├── ui │ │ │ ├── components │ │ │ │ ├── Feed │ │ │ │ │ ├── Feed.module.css │ │ │ │ │ ├── EmptyList │ │ │ │ │ │ ├── EmptyList.module.css │ │ │ │ │ │ └── EmptyList.tsx │ │ │ │ │ ├── FeedIcon │ │ │ │ │ │ ├── FeedIcon.module.css │ │ │ │ │ │ └── FeedIcon.tsx │ │ │ │ │ ├── Window │ │ │ │ │ │ ├── List.module.css │ │ │ │ │ │ ├── Grid.module.css │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── utils.ts │ │ │ │ │ │ ├── List.tsx │ │ │ │ │ │ ├── Grid.tsx │ │ │ │ │ │ └── Window.tsx │ │ │ │ │ ├── FeedItem │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── display │ │ │ │ │ │ │ ├── ListFeedItem │ │ │ │ │ │ │ │ ├── ListFeedItem.tsx │ │ │ │ │ │ │ │ └── ListFeedItem.module.css │ │ │ │ │ │ │ ├── components.ts │ │ │ │ │ │ │ ├── TileFeedItem │ │ │ │ │ │ │ │ ├── TileFeedItem.tsx │ │ │ │ │ │ │ │ └── TileFeedItem.module.css │ │ │ │ │ │ │ └── DetailFeedItem │ │ │ │ │ │ │ │ ├── DetailFeedItem.tsx │ │ │ │ │ │ │ │ └── DetailFeedItem.module.css │ │ │ │ │ │ ├── FeedHoverCard │ │ │ │ │ │ │ ├── FeedHoverCard.module.css │ │ │ │ │ │ │ └── FeedHoverCard.tsx │ │ │ │ │ │ ├── TimeAgoBadge │ │ │ │ │ │ │ ├── TimeAgoBadge.module.css │ │ │ │ │ │ │ └── TimeAgoBadge.tsx │ │ │ │ │ │ ├── FeedItem.tsx │ │ │ │ │ │ └── FeedItem.module.css │ │ │ │ │ ├── EditFeedForm │ │ │ │ │ │ ├── inputs │ │ │ │ │ │ │ ├── FiltersInput.module.css │ │ │ │ │ │ │ ├── UrlInput.tsx │ │ │ │ │ │ │ ├── TitleInput.tsx │ │ │ │ │ │ │ ├── ColorInput.tsx │ │ │ │ │ │ │ └── FiltersInput.tsx │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── ButtonGroup.tsx │ │ │ │ │ │ └── EditFeedForm.module.css │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── Feed.tsx │ │ │ │ │ └── EditFeedFormOverlay.tsx │ │ │ │ ├── modals │ │ │ │ │ ├── ImportExportModal │ │ │ │ │ │ └── ImportExportModal.module.css │ │ │ │ │ ├── AboutModal │ │ │ │ │ │ ├── AboutModal.module.css │ │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ │ └── AboutModal.tsx │ │ │ │ │ └── SettingsModal │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── Label.tsx │ │ │ │ │ │ ├── ColorSchemeSettings.tsx │ │ │ │ │ │ ├── AppearanceSettings.tsx │ │ │ │ │ │ ├── SettingsModal.tsx │ │ │ │ │ │ └── FeedSettings.tsx │ │ │ │ ├── common │ │ │ │ │ ├── Logo │ │ │ │ │ │ ├── Logo.module.css │ │ │ │ │ │ └── Logo.tsx │ │ │ │ │ ├── Divider │ │ │ │ │ │ ├── Dividier.module.css │ │ │ │ │ │ └── Divider.tsx │ │ │ │ │ ├── Tooltip.tsx │ │ │ │ │ ├── InputWrapper │ │ │ │ │ │ ├── InputWrapper.module.css │ │ │ │ │ │ └── InputWrapper.tsx │ │ │ │ │ ├── FailableImage.tsx │ │ │ │ │ └── Scroller │ │ │ │ │ │ └── Scroller.module.css │ │ │ │ ├── Dock │ │ │ │ │ ├── Panel │ │ │ │ │ │ ├── Panel.module.css │ │ │ │ │ │ ├── TabIcon │ │ │ │ │ │ │ ├── TabIcon.module.css │ │ │ │ │ │ │ └── TabIcon.tsx │ │ │ │ │ │ ├── TabTitle.tsx │ │ │ │ │ │ ├── PanelButton.tsx │ │ │ │ │ │ └── PanelExtra.tsx │ │ │ │ │ ├── Placeholder │ │ │ │ │ │ ├── Placeholder.module.css │ │ │ │ │ │ └── Placeholder.tsx │ │ │ │ │ ├── loadTab.tsx │ │ │ │ │ ├── makeTabDataCache.ts │ │ │ │ │ └── Dock.tsx │ │ │ │ └── App │ │ │ │ │ ├── AppShell.module.css │ │ │ │ │ ├── Modal │ │ │ │ │ ├── Modal.module.css │ │ │ │ │ ├── ModalInner.tsx │ │ │ │ │ └── Modal.tsx │ │ │ │ │ ├── Header │ │ │ │ │ ├── HeaderButton.tsx │ │ │ │ │ ├── HeaderToggleButton.tsx │ │ │ │ │ └── Header.module.css │ │ │ │ │ ├── App.tsx │ │ │ │ │ ├── theme.ts │ │ │ │ │ ├── AppShell.tsx │ │ │ │ │ └── Notifications.tsx │ │ │ ├── hooks │ │ │ │ ├── useInit.ts │ │ │ │ └── store.ts │ │ │ └── global.css │ │ ├── store │ │ │ ├── slices │ │ │ │ ├── settings │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── selectors.ts │ │ │ │ │ └── settingsSlice.ts │ │ │ │ ├── layout │ │ │ │ │ ├── entities │ │ │ │ │ │ ├── boxes │ │ │ │ │ │ │ ├── boxesSlice.ts │ │ │ │ │ │ │ ├── boxesEntityAdapter.ts │ │ │ │ │ │ │ └── selectors.ts │ │ │ │ │ │ ├── tabs │ │ │ │ │ │ │ ├── tabsEntityAdapter.ts │ │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ │ ├── tabsSlice.ts │ │ │ │ │ │ │ └── selectors.ts │ │ │ │ │ │ └── panels │ │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ │ ├── panelsEntityAdapter.ts │ │ │ │ │ │ │ ├── panelsSlice.ts │ │ │ │ │ │ │ └── selectors.ts │ │ │ │ │ ├── reducer.ts │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── layoutSlice.ts │ │ │ │ │ ├── selectors │ │ │ │ │ │ └── selectDenormalizedLayout.ts │ │ │ │ │ └── extraReducers.ts │ │ │ │ ├── notifications │ │ │ │ │ ├── selectors.ts │ │ │ │ │ ├── notificationsEntityAdapter.ts │ │ │ │ │ ├── actions.ts │ │ │ │ │ └── notificationsSlice.ts │ │ │ │ ├── api │ │ │ │ │ ├── feedApi.ts │ │ │ │ │ ├── apiSlice.ts │ │ │ │ │ ├── version.ts │ │ │ │ │ ├── layoutApi.ts │ │ │ │ │ ├── selectors.ts │ │ │ │ │ └── settingsApi.ts │ │ │ │ ├── app │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── selectors.ts │ │ │ │ │ └── appSlice.ts │ │ │ │ └── feedItems │ │ │ │ │ ├── feedItemsEntityAdapter.ts │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── feedItemsSlice.ts │ │ │ │ │ ├── selectors.ts │ │ │ │ │ └── extraReducers.ts │ │ │ ├── middleware │ │ │ │ ├── types.ts │ │ │ │ ├── settings │ │ │ │ │ ├── init.ts │ │ │ │ │ ├── persistSettingsEffect.ts │ │ │ │ │ ├── restoreSettings.ts │ │ │ │ │ └── importSettingsEffect.ts │ │ │ │ ├── feedItem │ │ │ │ │ ├── removeOrphanedFeedItems.ts │ │ │ │ │ ├── init.ts │ │ │ │ │ ├── removeOldFeedItemsEffect.ts │ │ │ │ │ ├── removeTabFeedItemsEffect.ts │ │ │ │ │ ├── persistFeedItemsEffect.ts │ │ │ │ │ └── restoreFeedItems.ts │ │ │ │ ├── feed │ │ │ │ │ ├── init.ts │ │ │ │ │ ├── refreshFeedEffect.ts │ │ │ │ │ ├── refreshAllFeedsEffect.ts │ │ │ │ │ ├── removeTabEffect.ts │ │ │ │ │ ├── editFeedEffect.ts │ │ │ │ │ ├── periodicFetchEffect.ts │ │ │ │ │ └── fetchFeed.ts │ │ │ │ ├── listenerMiddleware.ts │ │ │ │ ├── layout │ │ │ │ │ ├── removePanelEffect.ts │ │ │ │ │ ├── rcLayoutChangeEffect.ts │ │ │ │ │ ├── init.ts │ │ │ │ │ ├── removeTabEffect.ts │ │ │ │ │ ├── restoreLayout.ts │ │ │ │ │ ├── persistLayoutEffect.ts │ │ │ │ │ ├── requestNewTabEffect.ts │ │ │ │ │ └── closeOtherFeedSettingsEffect.ts │ │ │ │ ├── init.ts │ │ │ │ └── utils.ts │ │ │ ├── types.ts │ │ │ ├── sortComparer.ts │ │ │ ├── makeStore.ts │ │ │ ├── reducer.ts │ │ │ └── createSlice.ts │ │ ├── main.tsx │ │ ├── utils.ts │ │ └── constants.ts │ ├── public │ │ ├── favicon-16.png │ │ └── favicon-32.png │ ├── postcss.config.js │ ├── vite.config.ts │ ├── index.html │ ├── tsconfig.json │ └── package.json ├── common │ ├── src │ │ ├── constants.ts │ │ └── schema │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── api.ts │ │ │ └── layout.ts │ ├── tsconfig.json │ └── package.json └── server │ ├── src │ ├── api │ │ ├── feed │ │ │ ├── types.ts │ │ │ └── feed.ts │ │ ├── errors.ts │ │ ├── api.ts │ │ └── state.ts │ ├── constants.ts │ ├── startServer.ts │ └── redis │ │ └── dataConversion.ts │ ├── tsconfig.json │ └── package.json ├── .gitignore ├── .prettierrc.json ├── .editorconfig ├── .dockerignore ├── .vscode └── settings.json ├── tsconfig.json ├── Dockerfile ├── package.json └── artwork └── favicon.svg /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | -------------------------------------------------------------------------------- /packages/client/src/types/vite-plugin-svgr.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/Feed.module.css: -------------------------------------------------------------------------------- 1 | .feed { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /packages/client/public/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buzz/newsdash/main/packages/client/public/favicon-16.png -------------------------------------------------------------------------------- /packages/client/public/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buzz/newsdash/main/packages/client/public/favicon-32.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | node_modules 3 | npm-debug.log* 4 | tsconfig.tsbuildinfo 5 | /deploy.sh 6 | /packages/*/dist 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/modals/ImportExportModal/ImportExportModal.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | width: rem(16); 3 | } 4 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/EmptyList/EmptyList.module.css: -------------------------------------------------------------------------------- 1 | .loader:global { 2 | animation: spin-animation 3s infinite linear; 3 | } 4 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedIcon/FeedIcon.module.css: -------------------------------------------------------------------------------- 1 | .favicon { 2 | height: rem(16); 3 | width: rem(16); 4 | object-fit: contain; 5 | } 6 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/Logo/Logo.module.css: -------------------------------------------------------------------------------- 1 | .logo path[fill='#504a47'] { 2 | @mixin dark { 3 | fill: #a1948e !important; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/settings/actions.ts: -------------------------------------------------------------------------------- 1 | import settingsSlice from './settingsSlice' 2 | 3 | export const { updateSettings, restoreSettings } = settingsSlice.actions 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "endOfLine": "lf", 4 | "printWidth": 100, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | end_of_line = lf 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*~ 2 | **/.DS_Store 3 | **/.git 4 | **/.gitignore 5 | **/README.md 6 | **/node_modules 7 | **/dist 8 | .editorconfig 9 | .dockerignore 10 | LICENSE.txt 11 | Dockerfile 12 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/Window/List.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | border-bottom: 1px solid var(--mantine-color-default-border); 3 | 4 | &.last { 5 | border-bottom: none; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/modals/AboutModal/AboutModal.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: block; 3 | max-width: rem(334); 4 | max-height: rem(128); 5 | margin: 0 auto var(--mantine-spacing-xl); 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.experimental.useFlatConfig": true, 4 | "search.exclude": { 5 | "**/dist": true, 6 | "**/node_modules": true 7 | }, 8 | "editor.rulers": [100] 9 | } 10 | -------------------------------------------------------------------------------- /packages/common/src/constants.ts: -------------------------------------------------------------------------------- 1 | const IMG_WIDTH = 300 2 | const IMG_HEIGHT = 200 3 | const IMG_AR = IMG_WIDTH / IMG_HEIGHT 4 | const UNKNOWN_ERROR_MESSAGE = 'Unknown error' 5 | 6 | export { IMG_AR, IMG_HEIGHT, IMG_WIDTH, UNKNOWN_ERROR_MESSAGE } 7 | -------------------------------------------------------------------------------- /packages/common/src/schema/common.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const webUrlSchema = z 4 | .string() 5 | .url() 6 | .refine((url) => url.startsWith('http'), { message: 'URL must start with http or https.' }) 7 | 8 | export { webUrlSchema } 9 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/types.ts: -------------------------------------------------------------------------------- 1 | import type { FeedItem } from '#types/feed' 2 | 3 | interface FeedItemComponentProps { 4 | feedItem: FeedItem 5 | imageUrl?: string 6 | language?: string 7 | } 8 | 9 | export type { FeedItemComponentProps } 10 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/modals/SettingsModal/types.ts: -------------------------------------------------------------------------------- 1 | import type { Settings } from '#types/types' 2 | 3 | interface SettingsProps { 4 | settings: Settings 5 | throttledUpdateSettings: (settings: Partial) => void 6 | } 7 | 8 | export type { SettingsProps } 9 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/Divider/Dividier.module.css: -------------------------------------------------------------------------------- 1 | .divider { 2 | --divider-color: var(--mantine-color-default-border); 3 | 4 | :global(.mantine-Divider-label) { 5 | color: var(--mantine-color-text); 6 | font-size: var(--mantine-font-size-md); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/types.ts: -------------------------------------------------------------------------------- 1 | import type { ListenerEffectAPI } from '@reduxjs/toolkit' 2 | 3 | import type { AppDispatch, RootState } from '#store/types' 4 | 5 | type AppListenerEffectAPI = ListenerEffectAPI 6 | 7 | export type { AppListenerEffectAPI } 8 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": false, 5 | "module": "Node16", 6 | "moduleResolution": "Node16", 7 | "strict": true, 8 | "target": "ES2022" 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip as MantineTooltip, type TooltipProps } from '@mantine/core' 2 | 3 | function Tooltip(props: TooltipProps) { 4 | return 5 | } 6 | 7 | export default Tooltip 8 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/EditFeedForm/inputs/FiltersInput.module.css: -------------------------------------------------------------------------------- 1 | .filterInput { 2 | flex-grow: 1; 3 | } 4 | 5 | .trashIcon { 6 | color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-6)); 7 | height: var(--mantine-spacing-lg); 8 | width: var(--mantine-spacing-lg); 9 | } 10 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/Window/Grid.module.css: -------------------------------------------------------------------------------- 1 | .cell { 2 | border-right: 1px solid var(--mantine-color-default-border); 3 | border-bottom: 1px solid var(--mantine-color-default-border); 4 | 5 | &.lastCol { 6 | border-right: none; 7 | } 8 | 9 | &.lastRow { 10 | border-bottom: none; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/boxes/boxesSlice.ts: -------------------------------------------------------------------------------- 1 | import createSlice from '#store/createSlice' 2 | 3 | import { boxesInitialState } from './boxesEntityAdapter' 4 | 5 | const boxesSlice = createSlice({ 6 | name: 'boxes', 7 | initialState: boxesInitialState, 8 | reducers: {}, 9 | }) 10 | 11 | export default boxesSlice 12 | -------------------------------------------------------------------------------- /packages/client/src/types/mantine-theme.d.ts: -------------------------------------------------------------------------------- 1 | import '@mantine/core' 2 | 3 | declare module '@mantine/core' { 4 | export interface MantineThemeOther { 5 | transition: { 6 | duration: { 7 | default: number 8 | short: number 9 | extraShort: number 10 | } 11 | timingFunction: string 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/src/types/feed.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | import { feedItemSchema as apiFeedItemSchema } from '@newsdash/common/schema' 4 | 5 | const feedItemSchema = apiFeedItemSchema.extend({ 6 | tabId: z.string(), 7 | new: z.boolean(), 8 | }) 9 | 10 | type FeedItem = z.infer 11 | 12 | export type { FeedItem } 13 | export { feedItemSchema } 14 | -------------------------------------------------------------------------------- /packages/client/src/store/types.ts: -------------------------------------------------------------------------------- 1 | import type makeStore from './makeStore' 2 | import type reducer from './reducer' 3 | 4 | /** Store type */ 5 | type Store = ReturnType 6 | 7 | /** Root state */ 8 | type RootState = ReturnType 9 | 10 | /** Dispatch type */ 11 | type AppDispatch = Store['dispatch'] 12 | 13 | export type { AppDispatch, RootState, Store } 14 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/notifications/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { RootState } from '#store/types' 2 | 3 | import notificationsEntityAdapter from './notificationsEntityAdapter' 4 | 5 | /** Adapter selectors */ 6 | const notificationsSelectors = notificationsEntityAdapter.getSelectors( 7 | (state: RootState) => state.notifications 8 | ) 9 | 10 | export default notificationsSelectors 11 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | 3 | import LogoDark from '#assets/logo.svg?react' 4 | 5 | import classes from './Logo.module.css' 6 | 7 | function Logo({ className }: LogoProps) { 8 | return 9 | } 10 | 11 | interface LogoProps { 12 | className?: string 13 | } 14 | 15 | export default Logo 16 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/api/feedApi.ts: -------------------------------------------------------------------------------- 1 | import type { Feed } from '@newsdash/common/schema' 2 | 3 | import apiSlice from './apiSlice' 4 | 5 | const feedApi = apiSlice.injectEndpoints({ 6 | endpoints: (builder) => ({ 7 | fetchFeed: builder.query({ 8 | query: (url) => `feed/parse?url=${encodeURIComponent(url)}`, 9 | }), 10 | }), 11 | }) 12 | 13 | export default feedApi 14 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/Divider/Divider.tsx: -------------------------------------------------------------------------------- 1 | import { Divider as MantineDivider } from '@mantine/core' 2 | 3 | import classes from './Dividier.module.css' 4 | 5 | function Divider({ label }: DividerProps) { 6 | return 7 | } 8 | 9 | interface DividerProps { 10 | label?: string 11 | } 12 | 13 | export default Divider 14 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/InputWrapper/InputWrapper.module.css: -------------------------------------------------------------------------------- 1 | .label { 2 | display: flex; 3 | align-items: center; 4 | gap: var(--mantine-spacing-xs); 5 | 6 | .spacer { 7 | flex-grow: 1; 8 | } 9 | 10 | .icon { 11 | color: var(--mantine-color-blue-3); 12 | cursor: help; 13 | width: var(--mantine-font-size-xl); 14 | height: var(--mantine-font-size-xl); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/common/src/schema/index.ts: -------------------------------------------------------------------------------- 1 | export type { Feed, FeedInfo, FeedItem, PersistLayout, Result, VersionInfo } from './api.js' 2 | export { 3 | feedInfoSchema, 4 | feedItemSchema, 5 | feedSchema, 6 | persistLayoutSchema, 7 | resultSchema, 8 | versionInfoSchema, 9 | } from './api.js' 10 | export type { Box, CustomTabFields, Display, Panel, Tab } from './layout.js' 11 | export * as layout from './layout.js' 12 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/app/actions.ts: -------------------------------------------------------------------------------- 1 | import appSlice from './appSlice' 2 | 3 | const init = appSlice.createAction('init') 4 | 5 | const initDone = appSlice.createAction('initDone') 6 | 7 | const importSettings = appSlice.createAction('importSettings') 8 | 9 | export const { changeHeaderVisibile, changeColorScheme, closeModal, openModal } = appSlice.actions 10 | export { importSettings, init, initDone } 11 | -------------------------------------------------------------------------------- /packages/client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-preset-mantine': {}, 4 | 'postcss-simple-vars': { 5 | variables: { 6 | 'mantine-breakpoint-xs': '36em', 7 | 'mantine-breakpoint-sm': '48em', 8 | 'mantine-breakpoint-md': '62em', 9 | 'mantine-breakpoint-lg': '75em', 10 | 'mantine-breakpoint-xl': '88em', 11 | }, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/settings/init.ts: -------------------------------------------------------------------------------- 1 | import type { AppListenerEffectAPI } from '#store/middleware/types' 2 | 3 | import importSettingsEffect from './importSettingsEffect' 4 | import persistSettingsEffect from './persistSettingsEffect' 5 | 6 | function init(listenerApi: AppListenerEffectAPI) { 7 | importSettingsEffect(listenerApi) 8 | persistSettingsEffect(listenerApi) 9 | } 10 | 11 | export default init 12 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/notifications/notificationsEntityAdapter.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter } from '@reduxjs/toolkit' 2 | 3 | import type { Notification } from '#types/types' 4 | 5 | const notificationsEntityAdapter = createEntityAdapter() 6 | 7 | const notificationsInitialState = notificationsEntityAdapter.getInitialState() 8 | 9 | export { notificationsInitialState } 10 | export default notificationsEntityAdapter 11 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/modals/SettingsModal/Label.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Center } from '@mantine/core' 2 | import type { Icon } from '@tabler/icons-react' 3 | 4 | function Label({ icon: Icon, text }: LabelProps) { 5 | return ( 6 |
7 | 8 | {text} 9 |
10 | ) 11 | } 12 | 13 | interface LabelProps { 14 | icon: Icon 15 | text: string 16 | } 17 | 18 | export default Label 19 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/api/apiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' 2 | 3 | import { API_BASE, FETCH_TIMEOUT } from '#constants' 4 | 5 | const apiSlice = createApi({ 6 | reducerPath: 'api', 7 | baseQuery: fetchBaseQuery({ 8 | baseUrl: API_BASE, 9 | timeout: FETCH_TIMEOUT, 10 | }), 11 | endpoints: () => ({}), 12 | }) 13 | 14 | export const { reducer } = apiSlice 15 | export default apiSlice 16 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Dock/Panel/Panel.module.css: -------------------------------------------------------------------------------- 1 | .panelButton { 2 | --ai-color: var(--dock-nav-button-color) !important; 3 | 4 | &:not([disabled]):hover { 5 | color: var(--dock-nav-button-color-hover); 6 | } 7 | 8 | &[disabled] { 9 | background-color: transparent; 10 | color: var(--dock-nav-button-color); 11 | opacity: 0.5; 12 | } 13 | } 14 | 15 | .title { 16 | display: flex; 17 | align-items: center; 18 | gap: rem(4); 19 | } 20 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/api/version.ts: -------------------------------------------------------------------------------- 1 | import type { VersionInfo } from '@newsdash/common/schema' 2 | 3 | import apiSlice from './apiSlice' 4 | 5 | const version = apiSlice.injectEndpoints({ 6 | endpoints: (builder) => ({ 7 | /** Fetch backend version info */ 8 | getVersion: builder.query({ 9 | query: () => 'version', 10 | }), 11 | }), 12 | }) 13 | 14 | export const { useGetVersionQuery } = version 15 | export default version 16 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc' 2 | import { defineConfig } from 'vite' 3 | import svgr from 'vite-plugin-svgr' 4 | 5 | export default defineConfig({ 6 | build: { 7 | target: ['chrome126', 'firefox128'], 8 | }, 9 | server: { 10 | port: 3001, 11 | proxy: { '/api': 'http://localhost:3000' }, 12 | }, 13 | plugins: [ 14 | react(), 15 | svgr({ 16 | svgrOptions: { dimensions: false }, 17 | }), 18 | ], 19 | }) 20 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/tabs/tabsEntityAdapter.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter } from '@reduxjs/toolkit' 2 | 3 | import type { Tab } from '@newsdash/common/schema' 4 | 5 | import { orderSortComparer } from '#store/sortComparer' 6 | 7 | const tabsEntityAdapter = createEntityAdapter({ sortComparer: orderSortComparer }) 8 | 9 | const tabsInitialState = tabsEntityAdapter.getInitialState() 10 | 11 | export { tabsInitialState } 12 | export default tabsEntityAdapter 13 | -------------------------------------------------------------------------------- /packages/client/src/ui/hooks/useInit.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | import { init } from '#store/slices/app/actions' 4 | import { useDispatch } from '#ui/hooks/store' 5 | 6 | function useInit() { 7 | const dispatch = useDispatch() 8 | const initialized = useRef(false) 9 | useEffect(() => { 10 | if (!initialized.current) { 11 | initialized.current = true 12 | dispatch(init()) 13 | } 14 | }, [dispatch]) 15 | } 16 | 17 | export default useInit 18 | -------------------------------------------------------------------------------- /packages/server/src/api/feed/types.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest } from 'fastify' 2 | import type RssParser from 'rss-parser' 3 | 4 | interface UrlQueryString { 5 | url?: string 6 | } 7 | 8 | type UrlRequest = FastifyRequest<{ Querystring: UrlQueryString }> 9 | 10 | interface CustomFeedFields { 11 | language?: string 12 | } 13 | 14 | type RssParserResult = Awaited['parseString']>> 15 | 16 | export type { CustomFeedFields, RssParserResult, UrlRequest } 17 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/feedItems/feedItemsEntityAdapter.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter } from '@reduxjs/toolkit' 2 | 3 | import { dateSortComparer } from '#store/sortComparer' 4 | import type { FeedItem } from '#types/feed' 5 | 6 | const feedItemsEntityAdapter = createEntityAdapter({ 7 | sortComparer: dateSortComparer, 8 | }) 9 | 10 | const feedItemsInitialState = feedItemsEntityAdapter.getInitialState() 11 | 12 | export { feedItemsInitialState } 13 | export default feedItemsEntityAdapter 14 | -------------------------------------------------------------------------------- /packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | newsdash 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/EditFeedForm/types.ts: -------------------------------------------------------------------------------- 1 | import type { UseFormReturnType } from '@mantine/form' 2 | import type { z } from 'zod' 3 | 4 | import type { formSchema } from './useEditForm' 5 | 6 | type EditForm = UseFormReturnType< 7 | EditFeedFormValues, 8 | (values: EditFeedFormValues) => EditFeedFormValues 9 | > 10 | 11 | interface InputProps { 12 | form: EditForm 13 | } 14 | 15 | type EditFeedFormValues = z.infer 16 | 17 | export type { EditFeedFormValues, EditForm, InputProps } 18 | -------------------------------------------------------------------------------- /packages/client/src/ui/hooks/store.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-restricted-imports */ 2 | import { 3 | useDispatch as useDispatchReactRedux, 4 | useSelector as useSelectorReactRedux, 5 | } from 'react-redux' 6 | import type { TypedUseSelectorHook } from 'react-redux' 7 | 8 | import type { AppDispatch, RootState } from '#store/types' 9 | 10 | const useDispatch: () => AppDispatch = useDispatchReactRedux 11 | const useSelector: TypedUseSelectorHook = useSelectorReactRedux 12 | 13 | export { useDispatch, useSelector } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "isolatedModules": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "ES2022" 12 | }, 13 | "include": ["./**/*.ts", "./**/*.js", "./package.json"], 14 | "exclude": [ 15 | "./packages/client/src/**/*.ts", 16 | "./packages/client/src/**/*.tsx", 17 | "./packages/server/src/**/*.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/App/AppShell.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | --main-padding: calc(var(--mantine-spacing-xs) * 0.5); 3 | 4 | background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8)); 5 | display: flex; 6 | padding: var(--main-padding); 7 | transition-duration: var(--transition-duration); 8 | transition-property: padding-top; 9 | transition-timing-function: var(--transition-timing-function); 10 | 11 | &[data-header='visible'] { 12 | padding-top: calc(var(--app-shell-header-height) + var(--main-padding)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/display/ListFeedItem/ListFeedItem.tsx: -------------------------------------------------------------------------------- 1 | import TimeAgoBadge from '#ui/components/Feed/FeedItem/TimeAgoBadge/TimeAgoBadge' 2 | import type { FeedItemComponentProps } from '#ui/components/Feed/FeedItem/types' 3 | 4 | import classes from './ListFeedItem.module.css' 5 | 6 | function ListFeedItem({ feedItem }: FeedItemComponentProps) { 7 | return ( 8 | <> 9 |
{feedItem.title}
10 | 11 | 12 | ) 13 | } 14 | 15 | export default ListFeedItem 16 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Tab } from '@newsdash/common/schema' 2 | 3 | import { API_BASE } from '#constants' 4 | import type { FeedItem } from '#types/feed' 5 | 6 | function makeTabLogoUrl(tab: Tab) { 7 | return tab.link ? `${API_BASE}feed/logo?url=${encodeURIComponent(tab.link)}` : undefined 8 | } 9 | 10 | function makeFeedItemImageUrl(feedItem: FeedItem) { 11 | return feedItem.link 12 | ? `${API_BASE}feed/image?url=${encodeURIComponent(feedItem.link)}` 13 | : undefined 14 | } 15 | 16 | export { makeFeedItemImageUrl, makeTabLogoUrl } 17 | -------------------------------------------------------------------------------- /packages/client/src/store/sortComparer.ts: -------------------------------------------------------------------------------- 1 | interface Orderable { 2 | order: number 3 | } 4 | 5 | interface WithIsoDate { 6 | date: string 7 | } 8 | 9 | function orderSortComparer(a: T, b: T) { 10 | return a.order - b.order 11 | } 12 | 13 | function dateSortComparer(a: T, b: T) { 14 | const aDate = new Date(a.date) 15 | const bDate = new Date(b.date) 16 | 17 | if (aDate < bDate) { 18 | return 1 19 | } 20 | if (aDate > bDate) { 21 | return -1 22 | } 23 | return 0 24 | } 25 | 26 | export { dateSortComparer, orderSortComparer } 27 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/api/layoutApi.ts: -------------------------------------------------------------------------------- 1 | import type { PersistLayout, Result } from '@newsdash/common/schema' 2 | 3 | import apiSlice from './apiSlice' 4 | 5 | const layoutApi = apiSlice.injectEndpoints({ 6 | endpoints: (builder) => ({ 7 | getLayout: builder.query({ 8 | query: () => 'state/layout', 9 | }), 10 | persistLayout: builder.mutation({ 11 | query: (body) => ({ 12 | body, 13 | method: 'POST', 14 | url: 'state/layout', 15 | }), 16 | }), 17 | }), 18 | }) 19 | 20 | export default layoutApi 21 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feedItem/removeOrphanedFeedItems.ts: -------------------------------------------------------------------------------- 1 | import { removeFeedItems } from '#store/slices/feedItems/actions' 2 | import { selectOrphanedFeedItemIds } from '#store/slices/feedItems/selectors' 3 | import type { AppListenerEffectAPI } from '#store/middleware/types' 4 | 5 | function removeOrphanedFeedItems(listenerApi: AppListenerEffectAPI) { 6 | const state = listenerApi.getState() 7 | const feedIds = selectOrphanedFeedItemIds(state) 8 | if (feedIds.size > 0) { 9 | listenerApi.dispatch(removeFeedItems([...feedIds])) 10 | } 11 | } 12 | 13 | export default removeOrphanedFeedItems 14 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/api/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | 3 | import boxesSelectors from '#store/slices/layout/entities/boxes/selectors' 4 | import panelsSelectors from '#store/slices/layout/entities/panels/selectors' 5 | import { selectPersistTabs } from '#store/slices/layout/entities/tabs/selectors' 6 | 7 | /** Select layout for persisting */ 8 | const selectPersistLayout = createSelector( 9 | [boxesSelectors.selectAll, panelsSelectors.selectAll, selectPersistTabs], 10 | (boxes, panels, tabs) => ({ boxes, panels, tabs }) 11 | ) 12 | 13 | export { selectPersistLayout } 14 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/common/FailableImage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | function FailableImage({ alt, className, src }: FailableImageProps) { 4 | const [imgFailed, setImgFailed] = useState(false) 5 | 6 | return !imgFailed && src !== undefined ? ( 7 | {alt} { 11 | setImgFailed(true) 12 | }} 13 | src={src} 14 | /> 15 | ) : null 16 | } 17 | 18 | interface FailableImageProps { 19 | alt: string 20 | className?: string 21 | src?: string 22 | } 23 | 24 | export default FailableImage 25 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": false, 5 | "module": "NodeNext", 6 | "moduleResolution": "Node16", 7 | "resolveJsonModule": true, 8 | "rootDir": "./src", 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "ES2022", 12 | /* Path aliases */ 13 | "paths": { 14 | "#api/*": ["./src/api/*"], 15 | "#constants": ["./src/constants.ts"], 16 | "#redis": ["./src/redis/redis.ts"], 17 | "#schema": ["./src/schema.ts"] 18 | } 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/settings/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | 3 | import { selectPersistLayout } from '#store/slices/api/selectors' 4 | import type { RootState } from '#store/types' 5 | 6 | /** Select settings slice */ 7 | const selectSettings = (state: RootState) => state.settings 8 | 9 | /** Select settings and layout for export */ 10 | const selectSettingsExport = createSelector( 11 | [selectSettings, selectPersistLayout], 12 | (settings, layout) => JSON.stringify({ layout, settings }) 13 | ) 14 | 15 | export { selectSettingsExport } 16 | export default selectSettings 17 | -------------------------------------------------------------------------------- /packages/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@mantine/core/styles.css' 2 | import '@mantine/notifications/styles.css' 3 | import './ui/global.css' 4 | 5 | import React from 'react' 6 | import ReactDOM from 'react-dom/client' 7 | 8 | import makeStore from '#store/makeStore' 9 | import App from '#ui/components/App/App' 10 | 11 | const rootElem = document.querySelector('#root') 12 | 13 | if (rootElem === null) { 14 | throw new Error('Could not find root element!') 15 | } 16 | 17 | const store = makeStore() 18 | 19 | ReactDOM.createRoot(rootElem).render( 20 | 21 | 22 | 23 | ) 24 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/tabs/actions.ts: -------------------------------------------------------------------------------- 1 | import type { Update } from '@reduxjs/toolkit' 2 | 3 | import type { Tab } from '@newsdash/common/schema' 4 | 5 | import tabsSlice from './tabsSlice' 6 | 7 | /** Add new tab */ 8 | const addTab = tabsSlice.createAction('addTab') 9 | 10 | /** Remove tab */ 11 | const removeTab = tabsSlice.createAction('removeTab') 12 | 13 | /** Edit tab */ 14 | const editTab = tabsSlice.createAction>('editTab') 15 | 16 | /** Refresh tab */ 17 | const refreshTab = tabsSlice.createAction('refreshTab') 18 | 19 | export { addTab, editTab, refreshTab, removeTab } 20 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/api/settingsApi.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from '@newsdash/common/schema' 2 | 3 | import type { Settings } from '#types/types' 4 | 5 | import apiSlice from './apiSlice' 6 | 7 | const settingsApi = apiSlice.injectEndpoints({ 8 | endpoints: (builder) => ({ 9 | getSettings: builder.query({ 10 | query: () => 'state/settings', 11 | }), 12 | persistSettings: builder.mutation({ 13 | query: (body) => ({ 14 | body, 15 | method: 'POST', 16 | url: 'state/settings', 17 | }), 18 | }), 19 | }), 20 | }) 21 | 22 | export default settingsApi 23 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feedItem/init.ts: -------------------------------------------------------------------------------- 1 | import type { AppListenerEffectAPI } from '#store/middleware/types' 2 | 3 | import persistFeedItemsEffect from './persistFeedItemsEffect' 4 | import removeOldFeedItemsEffect from './removeOldFeedItemsEffect' 5 | import removeOrphanedFeedItems from './removeOrphanedFeedItems' 6 | import removeTabFeedItemsEffect from './removeTabFeedItemsEffect' 7 | 8 | function init(listenerApi: AppListenerEffectAPI) { 9 | removeOrphanedFeedItems(listenerApi) 10 | 11 | persistFeedItemsEffect(listenerApi) 12 | removeOldFeedItemsEffect(listenerApi) 13 | removeTabFeedItemsEffect(listenerApi) 14 | } 15 | 16 | export default init 17 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/boxes/boxesEntityAdapter.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter } from '@reduxjs/toolkit' 2 | 3 | import type { Box } from '@newsdash/common/schema' 4 | 5 | import { DOCKBOX_ID } from '#constants' 6 | import { orderSortComparer } from '#store/sortComparer' 7 | 8 | const boxesEntityAdapter = createEntityAdapter({ sortComparer: orderSortComparer }) 9 | 10 | const boxesInitialState = boxesEntityAdapter.getInitialState(undefined, [ 11 | { 12 | id: DOCKBOX_ID, 13 | mode: 'horizontal', 14 | order: 0, 15 | parentId: null, 16 | }, 17 | ]) 18 | 19 | export { boxesInitialState } 20 | export default boxesEntityAdapter 21 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feed/init.ts: -------------------------------------------------------------------------------- 1 | import type { AppListenerEffectAPI } from '#store/middleware/types' 2 | 3 | import editFeedEffect from './editFeedEffect' 4 | import periodicFetchEffect from './periodicFetchEffect' 5 | import refreshAllFeedsEffect from './refreshAllFeedsEffect' 6 | import refreshFeedEffect from './refreshFeedEffect' 7 | import removeTabEffect from './removeTabEffect' 8 | 9 | function init(listenerApi: AppListenerEffectAPI) { 10 | editFeedEffect(listenerApi) 11 | periodicFetchEffect(listenerApi) 12 | refreshAllFeedsEffect(listenerApi) 13 | refreshFeedEffect(listenerApi) 14 | removeTabEffect(listenerApi) 15 | } 16 | 17 | export default init 18 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/EditFeedForm/inputs/UrlInput.tsx: -------------------------------------------------------------------------------- 1 | import { TextInput } from '@mantine/core' 2 | 3 | import InputWrapper from '#ui/components/common/InputWrapper/InputWrapper' 4 | import type { TabEditMode } from '#types/layout' 5 | import type { InputProps } from '#ui/components/Feed/EditFeedForm/types' 6 | 7 | function UrlInput({ form, mode }: UrlInputProps) { 8 | return ( 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | interface UrlInputProps extends InputProps { 16 | mode: TabEditMode 17 | } 18 | 19 | export default UrlInput 20 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/display/ListFeedItem/ListFeedItem.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | color: light-dark(var(--mantine-color-text), var(--text-color-bright)); 3 | flex-grow: 1; 4 | font-size: var(--mantine-font-size-md); 5 | overflow: hidden; 6 | text-overflow: ellipsis; 7 | white-space: nowrap; 8 | 9 | transition-duration: var(--transition-duration); 10 | transition-property: color; 11 | transition-timing-function: var(--transition-timing-function); 12 | 13 | a:visited & { 14 | color: var(--mantine-color-dimmed); 15 | } 16 | 17 | a:hover & { 18 | color: light-dark(var(--mantine-color-text), var(--text-color-bright)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/listenerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { createListenerMiddleware } from '@reduxjs/toolkit' 2 | 3 | import { init as initAction } from '#store/slices/app/actions' 4 | import type { AppDispatch, RootState } from '#store/types' 5 | 6 | import init from './init' 7 | 8 | const listenerMiddleware = createListenerMiddleware() 9 | 10 | const startAppListening = listenerMiddleware.startListening.withTypes() 11 | 12 | startAppListening({ 13 | actionCreator: initAction, 14 | effect: async (action, listenerApi) => { 15 | listenerApi.unsubscribe() 16 | await init(listenerApi) 17 | }, 18 | }) 19 | 20 | export default listenerMiddleware 21 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/settings/settingsSlice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import createSlice from '#store/createSlice' 4 | import { settingsSchema } from '#types/schema' 5 | import type { Settings } from '#types/types' 6 | 7 | const settingsSlice = createSlice({ 8 | name: 'settings', 9 | initialState: settingsSchema.parse({}), 10 | 11 | reducers: { 12 | updateSettings: (state, { payload }: PayloadAction>) => ({ 13 | ...state, 14 | ...payload, 15 | }), 16 | restoreSettings: (state, { payload }: PayloadAction) => payload, 17 | }, 18 | }) 19 | 20 | export const { reducer } = settingsSlice 21 | export default settingsSlice 22 | -------------------------------------------------------------------------------- /packages/server/src/api/errors.ts: -------------------------------------------------------------------------------- 1 | import createError from '@fastify/error' 2 | 3 | const BadGateway = createError('BAD_GATEWAY', 'Bad Gateway:', 502) 4 | 5 | const NotFound = createError('FST_ERR_NOT_FOUND', 'Not Found', 404) 6 | 7 | const BadRequest = createError('BAD_REQUEST', 'Bad Request:', 400) 8 | 9 | const ParseError = createError('PARSE_ERROR', 'Parse Error:', 500) 10 | 11 | const ServerError = createError('INTERNAL_SERVER_ERROR', 'Internal Server Error:', 500) 12 | 13 | function isError(thing: unknown): thing is Error { 14 | return thing !== null && typeof thing === 'object' && typeof (thing as Error).message === 'string' 15 | } 16 | 17 | export { BadGateway, BadRequest, isError, NotFound, ParseError, ServerError } 18 | -------------------------------------------------------------------------------- /packages/client/src/store/makeStore.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | 3 | import listenerMiddleware from './middleware/listenerMiddleware' 4 | import reducer from './reducer' 5 | import apiSlice from './slices/api/apiSlice' 6 | 7 | /** Store factory */ 8 | function makeStore() { 9 | return configureStore({ 10 | devTools: import.meta.env.DEV, 11 | middleware: (getDefaultMiddleware) => 12 | getDefaultMiddleware() 13 | // Add before serializability check middleware 14 | .prepend(listenerMiddleware.middleware) 15 | // eslint-disable-next-line unicorn/prefer-spread 16 | .concat(apiSlice.middleware), 17 | reducer, 18 | }) 19 | } 20 | 21 | export default makeStore 22 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/panels/actions.ts: -------------------------------------------------------------------------------- 1 | import type { Update } from '@reduxjs/toolkit' 2 | 3 | import type { Panel } from '@newsdash/common/schema' 4 | 5 | import panelsSlice from './panelsSlice' 6 | 7 | /** Add panel to layout */ 8 | const addPanel = panelsSlice.createAction('addPanel') 9 | 10 | /** Remove panel */ 11 | const removePanel = panelsSlice.createAction('removePanel') 12 | 13 | /** Update panel */ 14 | const updatePanel = panelsSlice.createAction>('updatePanel') 15 | 16 | /** Set active tab */ 17 | const setActiveTab = panelsSlice.createAction<{ panelId: string; tabId: string }>('setActiveTab') 18 | 19 | export { addPanel, removePanel, setActiveTab, updatePanel } 20 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit' 2 | import reduceReducers from 'reduce-reducers' 3 | 4 | import boxesSlice from './entities/boxes/boxesSlice' 5 | import panelsSlice from './entities/panels/panelsSlice' 6 | import tabsSlice from './entities/tabs/tabsSlice' 7 | import layoutSlice from './layoutSlice' 8 | 9 | // Run layout reducer on whole layout slice, then sub-slice reducers on each sub-slice 10 | const reducer = reduceReducers( 11 | layoutSlice.reducer, 12 | combineReducers({ 13 | [boxesSlice.name]: boxesSlice.reducer, 14 | [panelsSlice.name]: panelsSlice.reducer, 15 | [tabsSlice.name]: tabsSlice.reducer, 16 | }) 17 | ) 18 | 19 | export default reducer 20 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedIcon/FeedIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconRss } from '@tabler/icons-react' 2 | import cx from 'clsx' 3 | 4 | import type { Tab } from '@newsdash/common/schema' 5 | 6 | import { makeTabLogoUrl } from '#ui/components/Feed/utils' 7 | 8 | import classes from './FeedIcon.module.css' 9 | 10 | function FeedIcon({ className, tab }: FeedIconProps) { 11 | const logoUrl = makeTabLogoUrl(tab) 12 | return logoUrl ? ( 13 | {tab.title} 14 | ) : ( 15 | 16 | ) 17 | } 18 | 19 | interface FeedIconProps { 20 | className?: string 21 | tab: Tab 22 | } 23 | 24 | export default FeedIcon 25 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/panels/panelsEntityAdapter.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter } from '@reduxjs/toolkit' 2 | import { nanoid } from 'nanoid' 3 | 4 | import type { Panel } from '@newsdash/common/schema' 5 | 6 | import { DOCKBOX_ID, TAB_GROUP } from '#constants' 7 | import { orderSortComparer } from '#store/sortComparer' 8 | 9 | const panelsEntityAdapter = createEntityAdapter({ 10 | sortComparer: orderSortComparer, 11 | }) 12 | 13 | const panelsInitialState = panelsEntityAdapter.getInitialState(undefined, [ 14 | { 15 | id: nanoid(), 16 | group: TAB_GROUP, 17 | order: 0, 18 | parentId: DOCKBOX_ID, 19 | }, 20 | ]) 21 | 22 | export { panelsInitialState } 23 | export default panelsEntityAdapter 24 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/app/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { RootState } from '#store/types' 2 | 3 | /** Select app slice */ 4 | const selectAppSlice = (state: RootState) => state.app 5 | 6 | /** Select computed color scheme */ 7 | const selectColorScheme = (state: RootState) => selectAppSlice(state).colorScheme 8 | 9 | /** Select header visible state */ 10 | const selectHeaderVisibile = (state: RootState) => selectAppSlice(state).headerVisible 11 | 12 | /** Select init state */ 13 | const selectInitDone = (state: RootState) => selectAppSlice(state).initDone 14 | 15 | /** Select modal */ 16 | const selectModal = (state: RootState) => selectAppSlice(state).modal 17 | 18 | export { selectColorScheme, selectHeaderVisibile, selectInitDone, selectModal } 19 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/feedItems/actions.ts: -------------------------------------------------------------------------------- 1 | import type { FeedItem } from '#types/feed' 2 | 3 | import feedItemsSlice from './feedItemsSlice' 4 | 5 | /** Add feed items */ 6 | const addFeedItems = feedItemsSlice.createAction('addFeedItems') 7 | 8 | /** Add feed items from a feed fetch */ 9 | const addFetchedFeedItems = 10 | feedItemsSlice.createAction('addFetchedFeedItems') 11 | 12 | /** Remove feed items */ 13 | const removeFeedItems = feedItemsSlice.createAction('removeFeedItems') 14 | 15 | interface AddFetchedFeedItemsPayload { 16 | items: Omit[] 17 | oldItemIds: string[] 18 | tabId: string 19 | } 20 | 21 | export { addFeedItems, addFetchedFeedItems, removeFeedItems } 22 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Dock/Panel/TabIcon/TabIcon.module.css: -------------------------------------------------------------------------------- 1 | .stackedIcon { 2 | border-radius: var(--mantine-radius-xs); 3 | display: inline-flex; 4 | height: rem(16); 5 | overflow: hidden; 6 | position: relative; 7 | width: rem(16); 8 | 9 | &.loading { 10 | .favicon { 11 | transform: scale(0.55); 12 | } 13 | } 14 | 15 | .favicon { 16 | transition-duration: var(--transition-duration); 17 | transition-property: transform; 18 | transition-timing-function: var(--transition-timing-function); 19 | } 20 | 21 | .loader:global { 22 | animation: spin-animation 2s infinite linear; 23 | } 24 | 25 | .error { 26 | color: var(--mantine-color-error); 27 | } 28 | 29 | > * { 30 | position: absolute; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/constants.ts: -------------------------------------------------------------------------------- 1 | import pkgData from '../../../package.json' with { type: 'json' } 2 | 3 | const PKG_NAME = pkgData.name 4 | const PKG_VERSION = pkgData.version 5 | 6 | const DEFAULT_HOST = 'localhost' 7 | const DEFAULT_PORT = 3000 8 | const DEFAULT_REDIS_URL = 'redis://127.0.0.1:6379' 9 | const FETCH_TIMEOUT = 10_000 10 | const USER_AGENT = 11 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.3' 12 | 13 | const MAX_CONTENT_LENGTH = 400 14 | const IMG_QUALITY = 85 15 | const IMG_MAX_AGE = 5_184_000 // 60d 16 | 17 | export { 18 | DEFAULT_HOST, 19 | DEFAULT_PORT, 20 | DEFAULT_REDIS_URL, 21 | FETCH_TIMEOUT, 22 | IMG_MAX_AGE, 23 | IMG_QUALITY, 24 | MAX_CONTENT_LENGTH, 25 | PKG_NAME, 26 | PKG_VERSION, 27 | USER_AGENT, 28 | } 29 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/feedItems/feedItemsSlice.ts: -------------------------------------------------------------------------------- 1 | import createSlice from '#store/createSlice' 2 | 3 | import { addFeedItems, addFetchedFeedItems, removeFeedItems } from './actions' 4 | import { 5 | addFeedItemsReducer, 6 | addFetchedFeedItemsReducer, 7 | removeFeedItemsReducer, 8 | } from './extraReducers' 9 | import { feedItemsInitialState } from './feedItemsEntityAdapter' 10 | 11 | export const feedItemsSlice = createSlice({ 12 | name: 'feedItems', 13 | initialState: feedItemsInitialState, 14 | reducers: {}, 15 | extraReducers: (builder) => { 16 | builder.addCase(addFeedItems, addFeedItemsReducer) 17 | builder.addCase(addFetchedFeedItems, addFetchedFeedItemsReducer) 18 | builder.addCase(removeFeedItems, removeFeedItemsReducer) 19 | }, 20 | }) 21 | 22 | export default feedItemsSlice 23 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/FeedHoverCard/FeedHoverCard.module.css: -------------------------------------------------------------------------------- 1 | .targetWrapper { 2 | height: 100%; 3 | } 4 | 5 | .hoverCard { 6 | padding: var(--mantine-spacing-sm); 7 | pointer-events: none; 8 | text-shadow: var(--text-shadow); 9 | 10 | .flexWrap { 11 | align-items: flex-start; 12 | 13 | .image { 14 | aspect-ratio: var(--feed-item-image-ar); 15 | width: var(--feed-item-image-width); 16 | border-radius: var(--mantine-radius-sm); 17 | border: 1px solid var(--mantine-color-default-border); 18 | flex-shrink: 0; 19 | } 20 | 21 | .title { 22 | font-weight: 700; 23 | line-height: 1.3; 24 | } 25 | 26 | .content { 27 | hyphens: auto; 28 | hyphenate-limit-chars: 6 3 3; 29 | word-break: break-word; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/Window/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'react' 2 | import type { CommonProps as ReactWindowProps } from 'react-window' 3 | 4 | import type { Tab } from '@newsdash/common/schema' 5 | 6 | import type { FeedItem } from '#types/feed' 7 | 8 | interface ListData { 9 | items: FeedItem[] 10 | tab: Tab 11 | } 12 | 13 | interface GridData extends ListData { 14 | columnCount: number 15 | rowCount: number 16 | } 17 | 18 | interface ListProps extends Pick { 19 | height: number 20 | 21 | items: FeedItem[] 22 | overscanCount: number 23 | rowHeight: number 24 | tab: Tab 25 | } 26 | 27 | interface InnerElementProps { 28 | style: CSSProperties 29 | } 30 | 31 | export type { GridData, InnerElementProps, ListData, ListProps } 32 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feed/refreshFeedEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { refreshTab } from '#store/slices/layout/entities/tabs/actions' 3 | import tabsSelectors from '#store/slices/layout/entities/tabs/selectors' 4 | import type { AppListenerEffectAPI } from '#store/middleware/types' 5 | 6 | import fetchFeed from './fetchFeed' 7 | 8 | // Force feed refresh. 9 | function refreshFeedEffect(listenerApi: AppListenerEffectAPI) { 10 | listenerApi.dispatch( 11 | addAppListener({ 12 | actionCreator: refreshTab, 13 | effect: async ({ payload: tabId }, listenerApi) => { 14 | const tab = tabsSelectors.selectById(listenerApi.getState(), tabId) 15 | await fetchFeed(listenerApi, tab) 16 | }, 17 | }) 18 | ) 19 | } 20 | 21 | export default refreshFeedEffect 22 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/Window/utils.ts: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'react' 2 | 3 | import type { GridData, ListData } from './types' 4 | 5 | function gridItemKey({ columnIndex, data: { columnCount, items }, rowIndex }: GridItemKeyArg) { 6 | try { 7 | return items[rowIndex * columnCount + columnIndex].id 8 | } catch { 9 | return `dummy${columnIndex}${rowIndex}` 10 | } 11 | } 12 | 13 | function listItemKey(index: number, { items }: ListData) { 14 | return items[index].id 15 | } 16 | 17 | function parseHeight(height: CSSProperties['height']): number { 18 | return (typeof height === 'string' ? Number.parseFloat(height) : height) ?? 0 19 | } 20 | 21 | interface GridItemKeyArg { 22 | columnIndex: number 23 | data: GridData 24 | rowIndex: number 25 | } 26 | 27 | export { gridItemKey, listItemKey, parseHeight } 28 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "useDefineForClassFields": true, 4 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 5 | "declaration": false, 6 | "target": "ES2022", 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "strict": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Path aliases */ 18 | "paths": { 19 | "#assets/*": ["./src/assets/*"], 20 | "#constants": ["./src/constants.ts"], 21 | "#pkg-info": ["../../package.json"], 22 | "#store/*": ["./src/store/*"], 23 | "#types/*": ["./src/types/*"], 24 | "#ui/*": ["./src/ui/*"], 25 | "#utils": ["./src/utils.ts"] 26 | } 27 | }, 28 | "include": ["src"] 29 | } 30 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/boxes/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | 3 | import type { RootState } from '#store/types' 4 | 5 | import boxesEntityAdapter from './boxesEntityAdapter' 6 | 7 | /** Adapter selectors */ 8 | const boxesSelectors = boxesEntityAdapter.getSelectors((state: RootState) => state.layout.boxes) 9 | 10 | /** Select child boxes */ 11 | const selectChildBoxes = createSelector( 12 | [boxesSelectors.selectAll, (_: RootState, parentId: string | null) => parentId], 13 | (boxes, parentId) => boxes.filter((box) => box.parentId === parentId) 14 | ) 15 | 16 | /** Select root box */ 17 | const selectDockbox = createSelector( 18 | [(state: RootState) => selectChildBoxes(state, null)], 19 | (boxes) => boxes.at(0) 20 | ) 21 | 22 | export { selectChildBoxes, selectDockbox } 23 | export default boxesSelectors 24 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/App/Modal/Modal.module.css: -------------------------------------------------------------------------------- 1 | .inner { 2 | --mantine-color-body: light-dark( 3 | alpha(var(--mantine-color-white), 0.9), 4 | alpha(var(--mantine-color-dark-7), 0.95) 5 | ); 6 | 7 | display: flex; 8 | border-radius: var(--mantine-radius-md); 9 | width: 100%; 10 | max-height: 100%; 11 | padding: var(--mantine-spacing-md); 12 | overflow: hidden; 13 | text-shadow: var(--text-shadow); 14 | 15 | .scrollWrapper { 16 | max-height: 100%; 17 | width: 100%; 18 | 19 | .content { 20 | .header { 21 | background-color: transparent; 22 | flex-grow: 0; 23 | margin-bottom: var(--mantine-spacing-sm); 24 | min-height: rem(32); 25 | padding: 0; 26 | position: static; 27 | } 28 | 29 | .body { 30 | padding: 0; 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Dock/Placeholder/Placeholder.module.css: -------------------------------------------------------------------------------- 1 | .placeholder { 2 | background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-filled)); 3 | 4 | .heading { 5 | @mixin dark { 6 | --text-gradient: linear-gradient( 7 | 45deg, 8 | var(--mantine-color-yellow-filled) 0%, 9 | var(--mantine-color-red-filled) 100% 10 | ) !important; 11 | } 12 | 13 | @mixin light { 14 | --text-gradient: linear-gradient( 15 | 45deg, 16 | darken(var(--mantine-color-yellow-filled), 0.15) 0%, 17 | darken(var(--mantine-color-red-filled), 0.1) 100% 18 | ) !important; 19 | } 20 | } 21 | 22 | .happyReading { 23 | color: light-dark(var(--mantine-primary-color-8), var(--mantine-primary-color-3)); 24 | font-size: var(--mantine-font-size-lg); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feed/refreshAllFeedsEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { refreashAllTabs } from '#store/slices/layout/actions' 3 | import { selectNonEditTabs } from '#store/slices/layout/entities/tabs/selectors' 4 | import type { AppListenerEffectAPI } from '#store/middleware/types' 5 | 6 | import fetchFeed from './fetchFeed' 7 | 8 | // Force refresh for all feeds. 9 | function refreshAllFeedsEffect(listenerApi: AppListenerEffectAPI) { 10 | listenerApi.dispatch( 11 | addAppListener({ 12 | actionCreator: refreashAllTabs, 13 | effect: async (action, listenerApi) => { 14 | const tabs = selectNonEditTabs(listenerApi.getState()) 15 | await Promise.all(tabs.map((tab) => fetchFeed(listenerApi, tab))) 16 | }, 17 | }) 18 | ) 19 | } 20 | 21 | export default refreshAllFeedsEffect 22 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/App/Header/HeaderButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon } from '@mantine/core' 2 | import type { ActionIconProps } from '@mantine/core' 3 | import type { MouseEventHandler } from 'react' 4 | 5 | import Tooltip from '#ui/components/common/Tooltip' 6 | 7 | function HeaderButton({ children, color, tooltip, variant, ...otherProps }: HeaderButtonProps) { 8 | const btn = ( 9 | 15 | {children} 16 | 17 | ) 18 | 19 | return tooltip ? {btn} : btn 20 | } 21 | 22 | interface HeaderButtonProps extends ActionIconProps { 23 | onClick?: MouseEventHandler 24 | tooltip?: string 25 | } 26 | 27 | export default HeaderButton 28 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feed/removeTabEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { removeFeedItems } from '#store/slices/feedItems/actions' 3 | import { selectIdsByTabId } from '#store/slices/feedItems/selectors' 4 | import { removeTab } from '#store/slices/layout/entities/tabs/actions' 5 | import type { AppListenerEffectAPI } from '#store/middleware/types' 6 | 7 | // Remove feed items when tab panel is removed. 8 | function removeTabEffect(listenerApi: AppListenerEffectAPI) { 9 | listenerApi.dispatch( 10 | addAppListener({ 11 | actionCreator: removeTab, 12 | effect: ({ payload: tabId }, listenerApi) => { 13 | const ids = selectIdsByTabId(listenerApi.getState(), tabId) 14 | listenerApi.dispatch(removeFeedItems([...ids])) 15 | }, 16 | }) 17 | ) 18 | } 19 | 20 | export default removeTabEffect 21 | -------------------------------------------------------------------------------- /packages/client/src/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit' 2 | 3 | import apiSlice from './slices/api/apiSlice' 4 | import appSlice from './slices/app/appSlice' 5 | import feedItemsSlice from './slices/feedItems/feedItemsSlice' 6 | import layoutSlice from './slices/layout/layoutSlice' 7 | import layoutReducer from './slices/layout/reducer' 8 | import notificationsSlice from './slices/notifications/notificationsSlice' 9 | import settingsSlice from './slices/settings/settingsSlice' 10 | 11 | const reducer = combineReducers({ 12 | [apiSlice.reducerPath]: apiSlice.reducer, 13 | [appSlice.name]: appSlice.reducer, 14 | [feedItemsSlice.name]: feedItemsSlice.reducer, 15 | [layoutSlice.name]: layoutReducer, 16 | [notificationsSlice.name]: notificationsSlice.reducer, 17 | [settingsSlice.name]: settingsSlice.reducer, 18 | }) 19 | 20 | export default reducer 21 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Dock/Panel/TabTitle.tsx: -------------------------------------------------------------------------------- 1 | import type { Tab } from '@newsdash/common/schema' 2 | 3 | import Tooltip from '#ui/components/common/Tooltip' 4 | import { getTitle } from '#utils' 5 | 6 | import TabIcon from './TabIcon/TabIcon' 7 | 8 | import classes from './Panel.module.css' 9 | 10 | function TabTitle({ feedItemCount, tab }: TabTitleProps) { 11 | const title = getTitle(tab, feedItemCount) 12 | 13 | return ( 14 | 15 | 16 | {tab.description ? ( 17 | 18 | {title} 19 | 20 | ) : ( 21 | {title} 22 | )} 23 | 24 | ) 25 | } 26 | 27 | interface TabTitleProps { 28 | feedItemCount?: number 29 | tab: Tab 30 | } 31 | 32 | export default TabTitle 33 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import 'simplebar-react/dist/simplebar.min.css' 2 | 3 | import { MantineProvider } from '@mantine/core' 4 | import { Provider as ReduxProvider } from 'react-redux' 5 | 6 | import Dock from '#ui/components/Dock/Dock' 7 | import type { Store } from '#store/types' 8 | 9 | import AppShell from './AppShell' 10 | import Notifications from './Notifications' 11 | import theme, { resolver } from './theme' 12 | 13 | function App({ store }: AppProps) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | interface AppProps { 27 | store: Store 28 | } 29 | 30 | export default App 31 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Dock/Panel/PanelButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon } from '@mantine/core' 2 | import type { ReactNode } from 'react' 3 | 4 | import Tooltip from '#ui/components/common/Tooltip' 5 | 6 | import classes from './Panel.module.css' 7 | 8 | function PanelButton({ children, disabled = false, label, onClick }: PanelButtonProps) { 9 | return ( 10 | 11 | 19 | {children} 20 | 21 | 22 | ) 23 | } 24 | 25 | interface PanelButtonProps { 26 | children: ReactNode 27 | disabled?: boolean 28 | label: string 29 | onClick: () => void 30 | } 31 | 32 | export default PanelButton 33 | -------------------------------------------------------------------------------- /packages/client/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod' 2 | 3 | import { UNKNOWN_ERROR_MESSAGE } from '@newsdash/common/constants' 4 | import type { Tab } from '@newsdash/common/schema' 5 | 6 | function getRandomHue() { 7 | return Math.floor(Math.random() * 360) 8 | } 9 | 10 | function getTitle({ customTitle, status, title }: Tab, feedItemCount?: number) { 11 | if (status === 'new') { 12 | return 'Add Feed' 13 | } 14 | 15 | let displayTitle = 'NO TITLE' 16 | if (customTitle.length > 0) { 17 | displayTitle = customTitle 18 | } else if (title !== undefined && title.length > 0) { 19 | displayTitle = title 20 | } 21 | return feedItemCount ? `${displayTitle} (${feedItemCount})` : displayTitle 22 | } 23 | 24 | function zodErrorToString(error: z.ZodError) { 25 | return error.issues.at(0)?.message ?? UNKNOWN_ERROR_MESSAGE 26 | } 27 | 28 | export { getRandomHue, getTitle, zodErrorToString } 29 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/layout/removePanelEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { removePanel } from '#store/slices/layout/entities/panels/actions' 3 | import { removeTab } from '#store/slices/layout/entities/tabs/actions' 4 | import { selectChildTabs } from '#store/slices/layout/entities/tabs/selectors' 5 | import type { AppListenerEffectAPI } from '#store/middleware/types' 6 | 7 | // Remove tabs when parent panel is removed. 8 | function removePanelEffect(listenerApi: AppListenerEffectAPI) { 9 | listenerApi.dispatch( 10 | addAppListener({ 11 | actionCreator: removePanel, 12 | effect: ({ payload: panelId }, listenerApi) => { 13 | for (const tab of selectChildTabs(listenerApi.getState(), panelId)) { 14 | listenerApi.dispatch(removeTab(tab.id)) 15 | } 16 | }, 17 | }) 18 | ) 19 | } 20 | 21 | export default removePanelEffect 22 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/layout/rcLayoutChangeEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { rcLayoutChange, updateLayout } from '#store/slices/layout/actions' 3 | import selectLayoutUpdate from '#store/slices/layout/selectors/selectLayoutUpdate' 4 | import type { AppListenerEffectAPI } from '#store/middleware/types' 5 | 6 | /** 7 | * Handle update from rc-dock component. 8 | * 9 | * Create normalized layout update and pass to reducer. 10 | */ 11 | function rcLayoutChangeEffect(listenerApi: AppListenerEffectAPI) { 12 | listenerApi.dispatch( 13 | addAppListener({ 14 | actionCreator: rcLayoutChange, 15 | effect: ({ payload: layout }, listenerApi) => { 16 | const update = selectLayoutUpdate(listenerApi.getState(), layout) 17 | listenerApi.dispatch(updateLayout(update)) 18 | }, 19 | }) 20 | ) 21 | } 22 | 23 | export default rcLayoutChangeEffect 24 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feedItem/removeOldFeedItemsEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { addFetchedFeedItems, removeFeedItems } from '#store/slices/feedItems/actions' 3 | import { selectOldFeedItemIds } from '#store/slices/feedItems/selectors' 4 | import type { AppListenerEffectAPI } from '#store/middleware/types' 5 | 6 | /** Remove old feed items. */ 7 | function removeOldFeedItemsEffect(listenerApi: AppListenerEffectAPI) { 8 | listenerApi.dispatch( 9 | addAppListener({ 10 | actionCreator: addFetchedFeedItems, 11 | effect: ({ payload: { tabId } }, listenerApi) => { 12 | const feedItemIds = selectOldFeedItemIds(listenerApi.getState(), tabId) 13 | if (feedItemIds.size > 0) { 14 | listenerApi.dispatch(removeFeedItems([...feedItemIds])) 15 | } 16 | }, 17 | }) 18 | ) 19 | } 20 | 21 | export default removeOldFeedItemsEffect 22 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/notifications/actions.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | 3 | import type { NotificationShow } from '#types/types' 4 | 5 | import notificationsSlice from './notificationsSlice' 6 | 7 | /** Request creation of new notification */ 8 | const showNotification = notificationsSlice.createAction( 9 | 'showErrorNotification', 10 | (data: NotificationShow['data']) => ({ 11 | payload: { 12 | id: nanoid(), 13 | command: 'show' as const, 14 | data, 15 | }, 16 | }) 17 | ) 18 | 19 | /** Hide a notification in Mantine notification system */ 20 | const hideNotification = notificationsSlice.createAction('hideNotification') 21 | 22 | /** Signal notification has been passed to Mantine notification system */ 23 | const notificationProcessed = notificationsSlice.createAction('notificationProcessed') 24 | 25 | export { hideNotification, notificationProcessed, showNotification } 26 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/settings/persistSettingsEffect.ts: -------------------------------------------------------------------------------- 1 | import { saveSettings } from '#store/middleware/db' 2 | import { addAppListener, debounce } from '#store/middleware/utils' 3 | import { updateSettings } from '#store/slices/settings/actions' 4 | import selectSettings from '#store/slices/settings/selectors' 5 | import type { AppListenerEffectAPI } from '#store/middleware/types' 6 | 7 | const PERSIST_DELAY = 500 8 | 9 | // Persist settings to IndexedDB. 10 | function persistSettingsEffect(listenerApi: AppListenerEffectAPI) { 11 | listenerApi.dispatch( 12 | addAppListener({ 13 | actionCreator: updateSettings, 14 | effect: async (action, listenerApi) => { 15 | await debounce(listenerApi, PERSIST_DELAY) 16 | 17 | const settings = selectSettings(listenerApi.getState()) 18 | await saveSettings(settings) 19 | }, 20 | }) 21 | ) 22 | } 23 | 24 | export default persistSettingsEffect 25 | -------------------------------------------------------------------------------- /packages/client/src/types/layout.ts: -------------------------------------------------------------------------------- 1 | import type { BoxData, PanelData, TabData } from 'rc-dock' 2 | 3 | import type { CustomTabFields } from '@newsdash/common/schema' 4 | 5 | type TabEditMode = 'edit' | 'new' 6 | 7 | interface Orderable { 8 | order: number 9 | } 10 | 11 | interface CustomBoxChildren { 12 | children: (CustomBoxData | CustomPanelData)[] 13 | } 14 | 15 | type CustomBoxData = Omit & CustomBoxChildren & Orderable 16 | 17 | interface CustomPanelTabs { 18 | tabs: CustomTabData[] 19 | } 20 | 21 | type CustomPanelData = Omit & CustomPanelTabs & Orderable 22 | 23 | type CustomTabData = Omit & CustomTabFields & Orderable 24 | 25 | interface DenormalizedLayout { 26 | dockbox: CustomBoxData 27 | } 28 | 29 | export type { 30 | CustomBoxData, 31 | CustomPanelData, 32 | CustomTabData, 33 | DenormalizedLayout, 34 | Orderable, 35 | TabEditMode, 36 | } 37 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feedItem/removeTabFeedItemsEffect.ts: -------------------------------------------------------------------------------- 1 | import { addAppListener } from '#store/middleware/utils' 2 | import { removeFeedItems } from '#store/slices/feedItems/actions' 3 | import { selectIdsByTabId } from '#store/slices/feedItems/selectors' 4 | import { removeTab } from '#store/slices/layout/entities/tabs/actions' 5 | import type { AppListenerEffectAPI } from '#store/middleware/types' 6 | 7 | // Remove feed items when tab is removed. 8 | function removeTabFeedItemsEffect(listenerApi: AppListenerEffectAPI) { 9 | listenerApi.dispatch( 10 | addAppListener({ 11 | actionCreator: removeTab, 12 | effect: ({ payload: tabId }, listenerApi) => { 13 | const feedIds = selectIdsByTabId(listenerApi.getState(), tabId) 14 | 15 | if (feedIds.size > 0) { 16 | listenerApi.dispatch(removeFeedItems([...feedIds])) 17 | } 18 | }, 19 | }) 20 | ) 21 | } 22 | 23 | export default removeTabFeedItemsEffect 24 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/init.ts: -------------------------------------------------------------------------------- 1 | import { initDone } from '#store/slices/app/actions' 2 | import type { AppListenerEffectAPI } from '#store/middleware/types' 3 | 4 | import feedInit from './feed/init' 5 | import feedItemInit from './feedItem/init' 6 | import restoreFeedItems from './feedItem/restoreFeedItems' 7 | import layoutInit from './layout/init' 8 | import restoreLayout from './layout/restoreLayout' 9 | import settingsInit from './settings/init' 10 | import restoreSettings from './settings/restoreSettings' 11 | 12 | async function init(listenerApi: AppListenerEffectAPI) { 13 | // Restore app state 14 | await restoreSettings(listenerApi) 15 | await restoreLayout(listenerApi) 16 | await restoreFeedItems(listenerApi) 17 | 18 | // Init sub-modules 19 | feedInit(listenerApi) 20 | feedItemInit(listenerApi) 21 | layoutInit(listenerApi) 22 | settingsInit(listenerApi) 23 | 24 | listenerApi.dispatch(initDone()) 25 | } 26 | 27 | export default init 28 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/panels/panelsSlice.ts: -------------------------------------------------------------------------------- 1 | import createSlice from '#store/createSlice' 2 | 3 | import { addPanel, removePanel, updatePanel } from './actions' 4 | import panelsEntityAdapter, { panelsInitialState } from './panelsEntityAdapter' 5 | 6 | const panelsSlice = createSlice({ 7 | name: 'panels', 8 | initialState: panelsInitialState, 9 | reducers: {}, 10 | extraReducers: (builder) => { 11 | // Add panel 12 | builder.addCase(addPanel, (state, { payload: panel }) => { 13 | panelsEntityAdapter.upsertOne(state, panel) 14 | }) 15 | 16 | // Remove panel 17 | builder.addCase(removePanel, (state, { payload: panelId }) => { 18 | panelsEntityAdapter.removeOne(state, panelId) 19 | }) 20 | 21 | // Update panel 22 | builder.addCase(updatePanel, (state, { payload: update }) => { 23 | panelsEntityAdapter.updateOne(state, update) 24 | }) 25 | }, 26 | }) 27 | 28 | export default panelsSlice 29 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/layout/init.ts: -------------------------------------------------------------------------------- 1 | import type { AppListenerEffectAPI } from '#store/middleware/types' 2 | 3 | import closeOtherFeedSettingsEffect from './closeOtherFeedSettingsEffect' 4 | import customPanelColorEffect, { updateStyleTag } from './customPanelColorEffect' 5 | import persistLayoutEffect from './persistLayoutEffect' 6 | import rcLayoutChangeEffect from './rcLayoutChangeEffect' 7 | import removePanelEffect from './removePanelEffect' 8 | import removeTabEffect from './removeTabEffect' 9 | import requestNewTabEffect from './requestNewTabEffect' 10 | 11 | function init(listenerApi: AppListenerEffectAPI) { 12 | updateStyleTag(listenerApi) 13 | 14 | closeOtherFeedSettingsEffect(listenerApi) 15 | customPanelColorEffect(listenerApi) 16 | persistLayoutEffect(listenerApi) 17 | rcLayoutChangeEffect(listenerApi) 18 | removePanelEffect(listenerApi) 19 | removeTabEffect(listenerApi) 20 | requestNewTabEffect(listenerApi) 21 | } 22 | 23 | export default init 24 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@newsdash/common", 3 | "description": "Common code for newsdash", 4 | "version": "0.0.0", 5 | "license": "AGPL-3.0-or-later", 6 | "private": true, 7 | "type": "module", 8 | "sideEffects": false, 9 | "author": { 10 | "name": "buzz", 11 | "url": "https://github.com/buzz" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/buzz/newsdash.git", 16 | "directory": "packages/common" 17 | }, 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts" 21 | }, 22 | "./constants": "./dist/constants.js", 23 | "./schema": "./dist/schema/index.js" 24 | }, 25 | "scripts": { 26 | "build": "tsc --declaration --declarationMap --outDir dist --project .", 27 | "dev": "tsc --watch --declaration --declarationMap --outDir dist --project .", 28 | "typecheck": "tsc --noEmit --project ." 29 | }, 30 | "dependencies": { 31 | "zod": "^3.23.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/server/src/startServer.ts: -------------------------------------------------------------------------------- 1 | import Fastify from 'fastify' 2 | 3 | import { DEFAULT_HOST, DEFAULT_PORT } from '#constants' 4 | 5 | import apiPlugin from './api/api.js' 6 | 7 | const host = process.env.NEWSDASH_HOST ?? DEFAULT_HOST 8 | const port = process.env.NEWSDASH_PORT ? Number.parseInt(process.env.NEWSDASH_PORT) : DEFAULT_PORT 9 | 10 | const logger = 11 | process.env.NODE_ENV === 'production' 12 | ? { 13 | level: 'warn', 14 | } 15 | : { 16 | transport: { 17 | target: 'pino-pretty', 18 | options: { 19 | translateTime: 'HH:MM:ss Z', 20 | ignore: 'pid,hostname', 21 | }, 22 | }, 23 | } 24 | 25 | const fastify = Fastify({ logger }) 26 | 27 | await fastify.register(apiPlugin, { prefix: '/api' }) 28 | 29 | fastify.listen({ host, port }, function (err, address) { 30 | if (err) { 31 | fastify.log.error(err) 32 | } else { 33 | fastify.log.info(`Server listening on ${address}`) 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/display/components.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react' 2 | 3 | import type { Display } from '@newsdash/common/schema' 4 | 5 | import type { FeedItemComponentProps } from '#ui/components/Feed/FeedItem/types' 6 | 7 | import DetailFeedItem from './DetailFeedItem/DetailFeedItem' 8 | import ListFeedItem from './ListFeedItem/ListFeedItem' 9 | import TileFeedItem from './TileFeedItem/TileFeedItem' 10 | 11 | const DISPLAY_COMPONENTS: Record< 12 | Display, 13 | { component: ComponentType; className: string } 14 | > = { 15 | condensedList: { 16 | component: ListFeedItem, 17 | className: 'condensedList', 18 | }, 19 | list: { 20 | component: ListFeedItem, 21 | className: 'list', 22 | }, 23 | detailed: { 24 | component: DetailFeedItem, 25 | className: 'detail', 26 | }, 27 | tiles: { 28 | component: TileFeedItem, 29 | className: 'tile', 30 | }, 31 | } 32 | 33 | export default DISPLAY_COMPONENTS 34 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/notifications/notificationsSlice.ts: -------------------------------------------------------------------------------- 1 | import createSlice from '#store/createSlice' 2 | 3 | import { hideNotification, notificationProcessed, showNotification } from './actions' 4 | import notificationsEntityAdapter, { notificationsInitialState } from './notificationsEntityAdapter' 5 | 6 | export const notificationsSlice = createSlice({ 7 | name: 'notifications', 8 | initialState: notificationsInitialState, 9 | reducers: {}, 10 | extraReducers: (builder) => { 11 | builder.addCase(showNotification, (state, { payload: notification }) => { 12 | notificationsEntityAdapter.addOne(state, notification) 13 | }) 14 | builder.addCase(hideNotification, (state, { payload: id }) => { 15 | notificationsEntityAdapter.addOne(state, { id, command: 'hide' }) 16 | }) 17 | builder.addCase(notificationProcessed, (state, { payload: id }) => { 18 | notificationsEntityAdapter.removeOne(state, id) 19 | }) 20 | }, 21 | }) 22 | 23 | export default notificationsSlice 24 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/modals/AboutModal/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon } from '@mantine/core' 2 | import type { ActionIconVariant } from '@mantine/core' 3 | import type { ReactNode } from 'react' 4 | 5 | import Tooltip from '#ui/components/common/Tooltip' 6 | 7 | function IconButton({ 8 | children, 9 | className, 10 | href, 11 | onClick, 12 | tooltip, 13 | variant = 'subtle', 14 | }: IconButtonProps) { 15 | const actionIcon = ( 16 | 25 | {children} 26 | 27 | ) 28 | 29 | return tooltip ? {actionIcon} : actionIcon 30 | } 31 | 32 | interface IconButtonProps { 33 | children: ReactNode 34 | className?: string 35 | href?: string 36 | onClick?: () => void 37 | tooltip?: string 38 | variant?: ActionIconVariant 39 | } 40 | 41 | export default IconButton 42 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feedItem/persistFeedItemsEffect.ts: -------------------------------------------------------------------------------- 1 | import { isAnyOf } from '@reduxjs/toolkit' 2 | 3 | import { saveFeedItems } from '#store/middleware/db' 4 | import { addAppListener, debounce } from '#store/middleware/utils' 5 | import { addFetchedFeedItems, removeFeedItems } from '#store/slices/feedItems/actions' 6 | import feedItemsSelectors from '#store/slices/feedItems/selectors' 7 | import type { AppListenerEffectAPI } from '#store/middleware/types' 8 | 9 | const PERSIST_DELAY = 2000 10 | 11 | // Persist feed items to IndexedDB. 12 | function persistFeedItemsEffect(listenerApi: AppListenerEffectAPI) { 13 | listenerApi.dispatch( 14 | addAppListener({ 15 | matcher: isAnyOf(addFetchedFeedItems, removeFeedItems), 16 | effect: async (action, listenerApi) => { 17 | await debounce(listenerApi, PERSIST_DELAY) 18 | 19 | const feedItems = feedItemsSelectors.selectAll(listenerApi.getState()) 20 | await saveFeedItems(feedItems) 21 | }, 22 | }) 23 | ) 24 | } 25 | 26 | export default persistFeedItemsEffect 27 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/feedItem/restoreFeedItems.ts: -------------------------------------------------------------------------------- 1 | import { isError } from 'lodash-es' 2 | 3 | import { UNKNOWN_ERROR_MESSAGE } from '@newsdash/common/constants' 4 | 5 | import { restoreFeedItems as dbRestoreFeedItems } from '#store/middleware/db' 6 | import { addFeedItems } from '#store/slices/feedItems/actions' 7 | import { showNotification } from '#store/slices/notifications/actions' 8 | import type { AppListenerEffectAPI } from '#store/middleware/types' 9 | 10 | /** Restore feed items from IndexedDB */ 11 | async function restoreFeedItems(listenerApi: AppListenerEffectAPI) { 12 | try { 13 | const feedItems = await dbRestoreFeedItems() 14 | if (feedItems.length > 0) { 15 | listenerApi.dispatch(addFeedItems(feedItems)) 16 | } 17 | } catch (error) { 18 | listenerApi.dispatch( 19 | showNotification({ 20 | title: 'Failed to load layout', 21 | message: isError(error) ? error.message : UNKNOWN_ERROR_MESSAGE, 22 | type: 'error', 23 | }) 24 | ) 25 | } 26 | } 27 | 28 | export default restoreFeedItems 29 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/App/Modal/ModalInner.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from '@mantine/core' 2 | import type { ReactNode } from 'react' 3 | 4 | import Scroller from '#ui/components/common/Scroller/Scroller' 5 | 6 | import classes from './Modal.module.css' 7 | 8 | function ModalInner({ children, icon = null, title }: ModalInnerProps) { 9 | return ( 10 |
11 | 12 |
13 | 14 | {icon} 15 | {title ? ( 16 | 17 | {title} 18 | 19 | ) : undefined} 20 | 21 | 22 | {children} 23 |
24 |
25 |
26 | ) 27 | } 28 | 29 | interface ModalInnerProps { 30 | children: ReactNode 31 | icon?: ReactNode 32 | title?: string 33 | } 34 | 35 | export default ModalInner 36 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/tabs/tabsSlice.ts: -------------------------------------------------------------------------------- 1 | import { TAB_GROUP } from '#constants' 2 | import createSlice from '#store/createSlice' 3 | 4 | import { addTab, editTab, removeTab } from './actions' 5 | import tabsEntityAdapter, { tabsInitialState } from './tabsEntityAdapter' 6 | 7 | const tabsSlice = createSlice({ 8 | name: 'tabs', 9 | initialState: tabsInitialState, 10 | reducers: {}, 11 | extraReducers: (builder) => { 12 | // Add tab 13 | builder.addCase(addTab, (state, { payload: tab }) => { 14 | tabsEntityAdapter.upsertOne(state, tab) 15 | }) 16 | 17 | // Remove tab 18 | builder.addCase(removeTab, (state, { payload: tabId }) => { 19 | tabsEntityAdapter.removeOne(state, tabId) 20 | }) 21 | 22 | // Edit tab 23 | builder.addCase(editTab, (state, { payload: { id, changes } }) => { 24 | tabsEntityAdapter.updateOne(state, { 25 | id, 26 | changes: { 27 | group: TAB_GROUP, 28 | ...changes, 29 | }, 30 | }) 31 | }) 32 | }, 33 | }) 34 | 35 | export default tabsSlice 36 | -------------------------------------------------------------------------------- /packages/client/src/store/middleware/settings/restoreSettings.ts: -------------------------------------------------------------------------------- 1 | import { isError } from 'lodash-es' 2 | 3 | import { UNKNOWN_ERROR_MESSAGE } from '@newsdash/common/constants' 4 | 5 | import { restoreSettings as dbRestoreSettings } from '#store/middleware/db' 6 | import { showNotification } from '#store/slices/notifications/actions' 7 | import { restoreSettings as restoreSettingsAction } from '#store/slices/settings/actions' 8 | import type { AppListenerEffectAPI } from '#store/middleware/types' 9 | 10 | /** Restore settings from IndexedDB */ 11 | async function restoreSettings(listenerApi: AppListenerEffectAPI) { 12 | try { 13 | const settings = await dbRestoreSettings() 14 | if (settings) { 15 | listenerApi.dispatch(restoreSettingsAction(settings)) 16 | } 17 | } catch (error) { 18 | listenerApi.dispatch( 19 | showNotification({ 20 | type: 'error', 21 | title: 'Failed to restore settings', 22 | message: `IndexedDB error: ${isError(error) ? error.message : UNKNOWN_ERROR_MESSAGE}`, 23 | }) 24 | ) 25 | } 26 | } 27 | 28 | export default restoreSettings 29 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/actions.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutBase } from 'rc-dock' 2 | 3 | import type { NormalizedEntities } from '#types/types' 4 | 5 | import layoutSlice from './layoutSlice' 6 | 7 | /** Handle rc-dock layout update */ 8 | const rcLayoutChange = layoutSlice.createAction('rcLayoutChange') 9 | 10 | /** Update layout */ 11 | const updateLayout = layoutSlice.createAction('updateLayout') 12 | 13 | /** Restore layout */ 14 | const restoreLayout = layoutSlice.createAction('restoreLayout') 15 | 16 | /** Request new tab */ 17 | const requestNewTab = layoutSlice.createAction('requestNewTab') 18 | 19 | /** Refresh all tabs */ 20 | const refreashAllTabs = layoutSlice.createAction('refreshAllTabs') 21 | 22 | interface UpdateLayoutPayload { 23 | entities: NormalizedEntities 24 | removeIds: { 25 | boxIds: string[] 26 | panelIds: string[] 27 | tabIds: string[] 28 | } 29 | } 30 | 31 | export type { UpdateLayoutPayload } 32 | export { rcLayoutChange, refreashAllTabs, requestNewTab, restoreLayout, updateLayout } 33 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/display/TileFeedItem/TileFeedItem.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | 3 | import Tooltip from '#ui/components/common/Tooltip' 4 | import TimeAgoBadge from '#ui/components/Feed/FeedItem/TimeAgoBadge/TimeAgoBadge' 5 | import type { FeedItemComponentProps } from '#ui/components/Feed/FeedItem/types' 6 | 7 | import classes from './TileFeedItem.module.css' 8 | 9 | function TileFeedItem({ feedItem, imageUrl, language }: FeedItemComponentProps) { 10 | return ( 11 |
12 | {feedItem.title} 13 |
14 | 15 |
16 | 17 | {feedItem.title} 18 |
19 |
20 |
{feedItem.content}
21 |
22 |
23 | ) 24 | } 25 | 26 | export default TileFeedItem 27 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/app/appSlice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import createSlice from '#store/createSlice' 4 | import type { AppState, ModalName } from '#types/types' 5 | 6 | const initialState: AppState = { 7 | colorScheme: 'dark', 8 | headerVisible: false, 9 | initDone: false, 10 | modal: null, 11 | } 12 | 13 | const appSlice = createSlice({ 14 | name: 'app', 15 | initialState: initialState, 16 | 17 | reducers: { 18 | changeColorScheme(state, { payload }: PayloadAction) { 19 | state.colorScheme = payload 20 | }, 21 | 22 | changeHeaderVisibile(state, { payload }: PayloadAction) { 23 | state.headerVisible = payload 24 | }, 25 | 26 | initDone(state) { 27 | state.initDone = true 28 | }, 29 | 30 | openModal(state, { payload }: PayloadAction) { 31 | if (state.modal === null) { 32 | state.modal = payload 33 | } 34 | }, 35 | 36 | closeModal(state) { 37 | state.modal = null 38 | }, 39 | }, 40 | }) 41 | 42 | export const { reducer } = appSlice 43 | export default appSlice 44 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Dock/loadTab.tsx: -------------------------------------------------------------------------------- 1 | import type { Tab } from '@newsdash/common/schema' 2 | 3 | import Feed from '#ui/components/Feed/Feed' 4 | import type { FeedItem } from '#types/feed' 5 | import type { CustomTabData } from '#types/layout' 6 | 7 | import TabTitle from './Panel/TabTitle' 8 | import type { TabDataCache } from './makeTabDataCache' 9 | 10 | /** Get tab data and components */ 11 | function loadTab( 12 | tabs: Tab[], 13 | allFeedItems: FeedItem[], 14 | { selectFeedItems, selectFilters }: TabDataCache, 15 | itemCount: boolean, 16 | tabData: CustomTabData 17 | ) { 18 | const tab = tabs.find((tab) => tab.id === tabData.id) 19 | if (!tab) { 20 | throw new Error('Tab not found') 21 | } 22 | 23 | const filters = selectFilters(tab.filters) 24 | const feedItems = selectFeedItems(allFeedItems, filters, tab.id) 25 | 26 | const { title, ...tabDataWithoutTitle } = tabData 27 | 28 | return { 29 | ...tabDataWithoutTitle, 30 | content: , 31 | title: , 32 | } 33 | } 34 | 35 | export default loadTab 36 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/Feed.tsx: -------------------------------------------------------------------------------- 1 | import type { Tab } from '@newsdash/common/schema' 2 | 3 | import { DISPLAY_PARAMS } from '#constants' 4 | import type { FeedItem } from '#types/feed' 5 | 6 | import EditFeedFormOverlay from './EditFeedFormOverlay' 7 | import EmptyList from './EmptyList/EmptyList' 8 | import Window from './Window/Window' 9 | 10 | import classes from './Feed.module.css' 11 | 12 | function Feed({ feedItems, tab }: FeedProps) { 13 | if (feedItems.length === 0) { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | const { height: rowHeight, overscanCount } = DISPLAY_PARAMS[tab.display] 23 | 24 | return ( 25 |
26 | 32 | 33 |
34 | ) 35 | } 36 | 37 | interface FeedProps { 38 | feedItems: FeedItem[] 39 | tab: Tab 40 | } 41 | 42 | export default Feed 43 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/Feed/FeedItem/TimeAgoBadge/TimeAgoBadge.module.css: -------------------------------------------------------------------------------- 1 | .badge { 2 | --badge-padding-x-sm: calc(var(--mantine-spacing-xs) * 0.5); 3 | background-color: light-dark( 4 | alpha(var(--mantine-color-dark-4), 70%), 5 | alpha(var(--mantine-color-gray-6), 60%) 6 | ); 7 | box-shadow: var(--mantine-shadow-xs); 8 | color: var(--mantine-color-white); 9 | font-weight: 400; 10 | overflow: visible; 11 | text-shadow: none; 12 | text-transform: lowercase; 13 | transition-duration: var(--transition-duration); 14 | transition-property: background-color; 15 | transition-timing-function: var(--transition-timing-function); 16 | 17 | &.new { 18 | background-color: light-dark( 19 | alpha(var(--mantine-primary-color-filled), 70%), 20 | alpha(var(--mantine-primary-color-filled), 60%) 21 | ); 22 | 23 | > span { 24 | color: var(--mantine-color-white); 25 | text-shadow: 0 1px 0 rgba(0 0 0 / 28%); 26 | } 27 | } 28 | 29 | a:visited > & { 30 | /* Can't change alpha value of :visited styles */ 31 | background-color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-7)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/client/src/store/slices/layout/entities/panels/selectors.ts: -------------------------------------------------------------------------------- 1 | import { placeHolderStyle as placeholderGroup } from 'rc-dock' 2 | 3 | import type { RootState } from '#store/types' 4 | 5 | import panelsEntityAdapter from './panelsEntityAdapter' 6 | 7 | /** Adapter selectors */ 8 | const panelsSelectors = panelsEntityAdapter.getSelectors((state: RootState) => state.layout.panels) 9 | 10 | /** Select child panels */ 11 | const selectChildPanels = (state: RootState, parentId: string) => 12 | panelsSelectors.selectAll(state).filter((panel) => panel.parentId === parentId) 13 | 14 | /** Select placeholder panel */ 15 | const selectPlaceholderPanel = (state: RootState) => 16 | panelsSelectors.selectAll(state).find((panel) => panel.group === placeholderGroup) 17 | 18 | /** Select panel for tab insertion */ 19 | const selectPanelForTab = (state: RootState) => panelsSelectors.selectAll(state).at(0) 20 | 21 | const selectPanelByActiveTabId = (state: RootState, tabId: string) => 22 | panelsSelectors.selectAll(state).find((panel) => panel.activeId === tabId) 23 | 24 | export { selectChildPanels, selectPanelByActiveTabId, selectPanelForTab, selectPlaceholderPanel } 25 | export default panelsSelectors 26 | -------------------------------------------------------------------------------- /packages/client/src/ui/components/modals/SettingsModal/ColorSchemeSettings.tsx: -------------------------------------------------------------------------------- 1 | import { SegmentedControl, useMantineColorScheme } from '@mantine/core' 2 | import { IconDeviceDesktop, IconMoonStars, IconSun } from '@tabler/icons-react' 3 | 4 | import { isColorScheme } from '#types/typeGuards' 5 | import InputWrapper from '#ui/components/common/InputWrapper/InputWrapper' 6 | 7 | import Label from './Label' 8 | 9 | const data = [ 10 | { 11 | label: