diff --git a/client/src/store/action-creators/app.ts b/client/src/store/action-creators/app.ts new file mode 100644 index 0000000..6f1b505 --- /dev/null +++ b/client/src/store/action-creators/app.ts @@ -0,0 +1,170 @@ +import { ActionType } from '../action-types'; +import { Dispatch } from 'redux'; +import { ApiResponse, App, Config, NewApp } from '../../interfaces'; +import { + AddAppAction, + DeleteAppAction, + GetAppsAction, + PinAppAction, + ReorderAppsAction, + SortAppsAction, + UpdateAppAction, +} from '../actions/app'; +import axios from 'axios'; +import { createNotification } from '.'; + +export const getApps = + () => async (dispatch: Dispatch>) => { + dispatch({ + type: ActionType.getApps, + payload: undefined, + }); + + try { + const res = await axios.get>('/api/apps'); + + dispatch({ + type: ActionType.getAppsSuccess, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +export const pinApp = + (app: App) => async (dispatch: Dispatch) => { + try { + const { id, isPinned, name } = app; + const res = await axios.put>(`/api/apps/${id}`, { + isPinned: !isPinned, + }); + + const status = isPinned + ? 'unpinned from Homescreen' + : 'pinned to Homescreen'; + + createNotification({ + title: 'Success', + message: `App ${name} ${status}`, + }); + + dispatch({ + type: ActionType.pinApp, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +export const addApp = + (formData: NewApp | FormData) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>('/api/apps', formData); + + createNotification({ + title: 'Success', + message: `App added`, + }); + + await dispatch({ + type: ActionType.addAppSuccess, + payload: res.data.data, + }); + + // Sort apps + // dispatch(sortApps()); + sortApps(); + } catch (err) { + console.log(err); + } + }; + +export const deleteApp = + (id: number) => async (dispatch: Dispatch) => { + try { + await axios.delete>(`/api/apps/${id}`); + + createNotification({ + title: 'Success', + message: 'App deleted', + }); + + dispatch({ + type: ActionType.deleteApp, + payload: id, + }); + } catch (err) { + console.log(err); + } + }; + +export const updateApp = + (id: number, formData: NewApp | FormData) => + async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/apps/${id}`, + formData + ); + + createNotification({ + title: 'Success', + message: `App updated`, + }); + + await dispatch({ + type: ActionType.updateApp, + payload: res.data.data, + }); + + // Sort apps + dispatch(sortApps()); + } catch (err) { + console.log(err); + } + }; + +export const reorderApps = + (apps: App[]) => async (dispatch: Dispatch) => { + interface ReorderQuery { + apps: { + id: number; + orderId: number; + }[]; + } + + try { + const updateQuery: ReorderQuery = { apps: [] }; + + apps.forEach((app, index) => + updateQuery.apps.push({ + id: app.id, + orderId: index + 1, + }) + ); + + await axios.put>('/api/apps/0/reorder', updateQuery); + + dispatch({ + type: ActionType.reorderApps, + payload: apps, + }); + } catch (err) { + console.log(err); + } + }; + +export const sortApps = () => async (dispatch: Dispatch) => { + try { + const res = await axios.get>('/api/config'); + + dispatch({ + type: ActionType.sortApps, + payload: res.data.data.useOrdering, + }); + } catch (err) { + console.log(err); + } +}; diff --git a/client/src/store/action-creators/bookmark.ts b/client/src/store/action-creators/bookmark.ts new file mode 100644 index 0000000..ac7c420 --- /dev/null +++ b/client/src/store/action-creators/bookmark.ts @@ -0,0 +1,289 @@ +import axios from 'axios'; +import { Dispatch } from 'redux'; +import { createNotification } from '.'; +import { + ApiResponse, + Bookmark, + Category, + Config, + NewBookmark, + NewCategory, +} from '../../interfaces'; +import { ActionType } from '../action-types'; +import { + AddBookmarkAction, + AddCategoryAction, + DeleteBookmarkAction, + DeleteCategoryAction, + GetCategoriesAction, + PinCategoryAction, + ReorderCategoriesAction, + SortCategoriesAction, + UpdateBookmarkAction, + UpdateCategoryAction, +} from '../actions/bookmark'; + +export const getCategories = + () => + async (dispatch: Dispatch>) => { + dispatch({ + type: ActionType.getCategories, + payload: undefined, + }); + + try { + const res = await axios.get>('/api/categories'); + + dispatch({ + type: ActionType.getCategoriesSuccess, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +export const addCategory = + (formData: NewCategory) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>( + '/api/categories', + formData + ); + + createNotification({ + title: 'Success', + message: `Category ${formData.name} created`, + }); + + dispatch({ + type: ActionType.addCategory, + payload: res.data.data, + }); + + // dispatch(sortCategories()); + sortCategories(); + } catch (err) { + console.log(err); + } + }; + +export const addBookmark = + (formData: NewBookmark | FormData) => + async (dispatch: Dispatch) => { + try { + const res = await axios.post>( + '/api/bookmarks', + formData + ); + + createNotification({ + title: 'Success', + message: `Bookmark created`, + }); + + dispatch({ + type: ActionType.addBookmark, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +export const pinCategory = + (category: Category) => async (dispatch: Dispatch) => { + try { + const { id, isPinned, name } = category; + const res = await axios.put>( + `/api/categories/${id}`, + { isPinned: !isPinned } + ); + + const status = isPinned + ? 'unpinned from Homescreen' + : 'pinned to Homescreen'; + + createNotification({ + title: 'Success', + message: `Category ${name} ${status}`, + }); + + dispatch({ + type: ActionType.pinCategory, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +export const deleteCategory = + (id: number) => async (dispatch: Dispatch) => { + try { + await axios.delete>(`/api/categories/${id}`); + + createNotification({ + title: 'Success', + message: `Category deleted`, + }); + + dispatch({ + type: ActionType.deleteCategory, + payload: id, + }); + } catch (err) { + console.log(err); + } + }; + +export const updateCategory = + (id: number, formData: NewCategory) => + async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/categories/${id}`, + formData + ); + createNotification({ + title: 'Success', + message: `Category ${formData.name} updated`, + }); + + dispatch({ + type: ActionType.updateCategory, + payload: res.data.data, + }); + + // dispatch(sortCategories()); + sortCategories(); + } catch (err) { + console.log(err); + } + }; + +export const deleteBookmark = + (bookmarkId: number, categoryId: number) => + async (dispatch: Dispatch) => { + try { + await axios.delete>(`/api/bookmarks/${bookmarkId}`); + + createNotification({ + title: 'Success', + message: 'Bookmark deleted', + }); + + dispatch({ + type: ActionType.deleteBookmark, + payload: { + bookmarkId, + categoryId, + }, + }); + } catch (err) { + console.log(err); + } + }; + +export const updateBookmark = + ( + bookmarkId: number, + formData: NewBookmark | FormData, + category: { + prev: number; + curr: number; + } + ) => + async ( + dispatch: Dispatch< + DeleteBookmarkAction | AddBookmarkAction | UpdateBookmarkAction + > + ) => { + try { + const res = await axios.put>( + `/api/bookmarks/${bookmarkId}`, + formData + ); + + createNotification({ + title: 'Success', + message: `Bookmark updated`, + }); + + // Check if category was changed + const categoryWasChanged = category.curr !== category.prev; + + if (categoryWasChanged) { + // Delete bookmark from old category + dispatch({ + type: ActionType.deleteBookmark, + payload: { + bookmarkId, + categoryId: category.prev, + }, + }); + + // Add bookmark to the new category + dispatch({ + type: ActionType.addBookmark, + payload: res.data.data, + }); + } else { + // Else update only name/url/icon + dispatch({ + type: ActionType.updateBookmark, + payload: res.data.data, + }); + } + } catch (err) { + console.log(err); + } + }; + +export const sortCategories = + () => async (dispatch: Dispatch) => { + try { + const res = await axios.get>('/api/config'); + + dispatch({ + type: ActionType.sortCategories, + payload: res.data.data.useOrdering, + }); + } catch (err) { + console.log(err); + } + }; + +export const reorderCategories = + (categories: Category[]) => + async (dispatch: Dispatch) => { + interface ReorderQuery { + categories: { + id: number; + orderId: number; + }[]; + } + + try { + const updateQuery: ReorderQuery = { categories: [] }; + + categories.forEach((category, index) => + updateQuery.categories.push({ + id: category.id, + orderId: index + 1, + }) + ); + + await axios.put>( + '/api/categories/0/reorder', + updateQuery + ); + + dispatch({ + type: ActionType.reorderCategories, + payload: categories, + }); + } catch (err) { + console.log(err); + } + }; diff --git a/client/src/store/action-creators/index.ts b/client/src/store/action-creators/index.ts index d7b5e45..ae038b0 100644 --- a/client/src/store/action-creators/index.ts +++ b/client/src/store/action-creators/index.ts @@ -1,3 +1,5 @@ export * from './theme'; export * from './config'; export * from './notification'; +export * from './app'; +export * from './bookmark'; diff --git a/client/src/store/action-types/index.ts b/client/src/store/action-types/index.ts index eb32118..ab7d5ef 100644 --- a/client/src/store/action-types/index.ts +++ b/client/src/store/action-types/index.ts @@ -4,6 +4,7 @@ export enum ActionType { // CONFIG getConfig = 'GET_CONFIG', updateConfig = 'UPDATE_CONFIG', + // QUERIES addQuery = 'ADD_QUERY', deleteQuery = 'DELETE_QUERY', fetchQueries = 'FETCH_QUERIES', @@ -11,4 +12,29 @@ export enum ActionType { // NOTIFICATIONS createNotification = 'CREATE_NOTIFICATION', clearNotification = 'CLEAR_NOTIFICATION', + // APPS + getApps = 'GET_APPS', + getAppsSuccess = 'GET_APPS_SUCCESS', + getAppsError = 'GET_APPS_ERROR', + pinApp = 'PIN_APP', + addApp = 'ADD_APP', + addAppSuccess = 'ADD_APP_SUCCESS', + deleteApp = 'DELETE_APP', + updateApp = 'UPDATE_APP', + reorderApps = 'REORDER_APPS', + sortApps = 'SORT_APPS', + // CATEGORES + getCategories = 'GET_CATEGORIES', + getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS', + getCategoriesError = 'GET_CATEGORIES_ERROR', + addCategory = 'ADD_CATEGORY', + pinCategory = 'PIN_CATEGORY', + deleteCategory = 'DELETE_CATEGORY', + updateCategory = 'UPDATE_CATEGORY', + sortCategories = 'SORT_CATEGORIES', + reorderCategories = 'REORDER_CATEGORIES', + // BOOKMARKS + addBookmark = 'ADD_BOOKMARK', + deleteBookmark = 'DELETE_BOOKMARK', + updateBookmark = 'UPDATE_BOOKMARK', } diff --git a/client/src/store/actions/app.ts b/client/src/store/actions/app.ts new file mode 100644 index 0000000..37f5419 --- /dev/null +++ b/client/src/store/actions/app.ts @@ -0,0 +1,38 @@ +import { ActionType } from '../action-types'; +import { App } from '../../interfaces'; + +export interface GetAppsAction { + type: + | ActionType.getApps + | ActionType.getAppsSuccess + | ActionType.getAppsError; + payload: T; +} +export interface PinAppAction { + type: ActionType.pinApp; + payload: App; +} + +export interface AddAppAction { + type: ActionType.addAppSuccess; + payload: App; +} +export interface DeleteAppAction { + type: ActionType.deleteApp; + payload: number; +} + +export interface UpdateAppAction { + type: ActionType.updateApp; + payload: App; +} + +export interface ReorderAppsAction { + type: ActionType.reorderApps; + payload: App[]; +} + +export interface SortAppsAction { + type: ActionType.sortApps; + payload: string; +} diff --git a/client/src/store/actions/bookmark.ts b/client/src/store/actions/bookmark.ts new file mode 100644 index 0000000..e4cfcfd --- /dev/null +++ b/client/src/store/actions/bookmark.ts @@ -0,0 +1,58 @@ +import { Bookmark, Category } from '../../interfaces'; +import { ActionType } from '../action-types'; + +export interface GetCategoriesAction { + type: + | ActionType.getCategories + | ActionType.getCategoriesSuccess + | ActionType.getCategoriesError; + payload: T; +} + +export interface AddCategoryAction { + type: ActionType.addCategory; + payload: Category; +} + +export interface AddBookmarkAction { + type: ActionType.addBookmark; + payload: Bookmark; +} + +export interface PinCategoryAction { + type: ActionType.pinCategory; + payload: Category; +} + +export interface DeleteCategoryAction { + type: ActionType.deleteCategory; + payload: number; +} + +export interface UpdateCategoryAction { + type: ActionType.updateCategory; + payload: Category; +} + +export interface DeleteBookmarkAction { + type: ActionType.deleteBookmark; + payload: { + bookmarkId: number; + categoryId: number; + }; +} + +export interface UpdateBookmarkAction { + type: ActionType.updateBookmark; + payload: Bookmark; +} + +export interface SortCategoriesAction { + type: ActionType.sortCategories; + payload: string; +} + +export interface ReorderCategoriesAction { + type: ActionType.reorderCategories; + payload: Category[]; +} diff --git a/client/src/store/actions/index.ts b/client/src/store/actions/index.ts index d6bea13..af999a6 100644 --- a/client/src/store/actions/index.ts +++ b/client/src/store/actions/index.ts @@ -1,4 +1,5 @@ import { SetThemeAction } from './theme'; + import { AddQueryAction, DeleteQueryAction, @@ -7,11 +8,37 @@ import { UpdateConfigAction, UpdateQueryAction, } from './config'; + import { ClearNotificationAction, CreateNotificationAction, } from './notification'; +import { + GetAppsAction, + PinAppAction, + AddAppAction, + DeleteAppAction, + UpdateAppAction, + ReorderAppsAction, + SortAppsAction, +} from './app'; + +import { App } from '../../interfaces'; + +import { + GetCategoriesAction, + AddCategoryAction, + PinCategoryAction, + DeleteCategoryAction, + UpdateCategoryAction, + SortCategoriesAction, + ReorderCategoriesAction, + AddBookmarkAction, + DeleteBookmarkAction, + UpdateBookmarkAction, +} from './bookmark'; + export type Action = // Theme | SetThemeAction @@ -24,4 +51,24 @@ export type Action = | UpdateQueryAction // Notifications | CreateNotificationAction - | ClearNotificationAction; + | ClearNotificationAction + // Apps + | GetAppsAction + | PinAppAction + | AddAppAction + | DeleteAppAction + | UpdateAppAction + | ReorderAppsAction + | SortAppsAction + // Categories + | GetCategoriesAction + | AddCategoryAction + | PinCategoryAction + | DeleteCategoryAction + | UpdateCategoryAction + | SortCategoriesAction + | ReorderCategoriesAction + // Bookmarks + | AddBookmarkAction + | DeleteBookmarkAction + | UpdateBookmarkAction; diff --git a/client/src/store/reducers/app.ts b/client/src/store/reducers/app.ts new file mode 100644 index 0000000..e6da902 --- /dev/null +++ b/client/src/store/reducers/app.ts @@ -0,0 +1,92 @@ +import { ActionType } from '../action-types'; +import { Action } from '../actions/index'; +import { App } from '../../interfaces'; +import { sortData } from '../../utility'; + +interface AppsState { + loading: boolean; + apps: App[]; + errors: string | undefined; +} + +const initialState: AppsState = { + loading: true, + apps: [], + errors: undefined, +}; + +export const appsReducer = ( + state: AppsState = initialState, + action: Action +): AppsState => { + switch (action.type) { + case ActionType.getApps: + return { + ...state, + loading: true, + errors: undefined, + }; + + case ActionType.getAppsSuccess: + return { + ...state, + loading: false, + apps: action.payload || [], + }; + + case ActionType.pinApp: + const pinnedAppIdx = state.apps.findIndex( + (app) => app.id === action.payload.id + ); + + return { + ...state, + apps: [ + ...state.apps.slice(0, pinnedAppIdx), + action.payload, + ...state.apps.slice(pinnedAppIdx + 1), + ], + }; + + case ActionType.addAppSuccess: + return { + ...state, + apps: [...state.apps, action.payload], + }; + + case ActionType.deleteApp: + return { + ...state, + apps: [...state.apps].filter((app) => app.id !== action.payload), + }; + + case ActionType.updateApp: + const updatedAppIdx = state.apps.findIndex( + (app) => app.id === action.payload.id + ); + + return { + ...state, + apps: [ + ...state.apps.slice(0, updatedAppIdx), + action.payload, + ...state.apps.slice(updatedAppIdx + 1), + ], + }; + + case ActionType.reorderApps: + return { + ...state, + apps: action.payload, + }; + + case ActionType.sortApps: + return { + ...state, + apps: sortData(state.apps, action.payload), + }; + + default: + return state; + } +}; diff --git a/client/src/store/reducers/bookmark.ts b/client/src/store/reducers/bookmark.ts new file mode 100644 index 0000000..ed4cea2 --- /dev/null +++ b/client/src/store/reducers/bookmark.ts @@ -0,0 +1,166 @@ +import { Category } from '../../interfaces'; +import { sortData } from '../../utility'; +import { ActionType } from '../action-types'; +import { Action } from '../actions'; + +interface BookmarksState { + loading: boolean; + errors: string | undefined; + categories: Category[]; +} + +const initialState: BookmarksState = { + loading: true, + errors: undefined, + categories: [], +}; + +export const bookmarksReducer = ( + state: BookmarksState = initialState, + action: Action +): BookmarksState => { + switch (action.type) { + case ActionType.getCategories: + return { + ...state, + loading: true, + errors: undefined, + }; + + case ActionType.getCategoriesSuccess: + return { + ...state, + loading: false, + categories: action.payload, + }; + + case ActionType.addCategory: + return { + ...state, + categories: [...state.categories, { ...action.payload, bookmarks: [] }], + }; + + case ActionType.addBookmark: + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.categoryId + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + { + ...state.categories[categoryIdx], + bookmarks: [ + ...state.categories[categoryIdx].bookmarks, + action.payload, + ], + }, + ...state.categories.slice(categoryIdx + 1), + ], + }; + + case ActionType.pinCategory: + const pinnedCategoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.id + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, pinnedCategoryIdx), + action.payload, + ...state.categories.slice(pinnedCategoryIdx + 1), + ], + }; + + case ActionType.deleteCategory: + const deletedCategoryIdx = state.categories.findIndex( + (category) => category.id === action.payload + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, deletedCategoryIdx), + ...state.categories.slice(deletedCategoryIdx + 1), + ], + }; + + case ActionType.updateCategory: + const updatedCategoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.id + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, updatedCategoryIdx), + action.payload, + ...state.categories.slice(updatedCategoryIdx + 1), + ], + }; + + case ActionType.deleteBookmark: + const categoryInUpdateIdx = state.categories.findIndex( + (category) => category.id === action.payload.categoryId + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryInUpdateIdx), + { + ...state.categories[categoryInUpdateIdx], + bookmarks: state.categories[categoryInUpdateIdx].bookmarks.filter( + (bookmark) => bookmark.id !== action.payload.bookmarkId + ), + }, + ...state.categories.slice(categoryInUpdateIdx + 1), + ], + }; + + case ActionType.updateBookmark: + const parentCategoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.categoryId + ); + const updatedBookmarkIdx = state.categories[ + parentCategoryIdx + ].bookmarks.findIndex((bookmark) => bookmark.id === action.payload.id); + + return { + ...state, + categories: [ + ...state.categories.slice(0, parentCategoryIdx), + { + ...state.categories[parentCategoryIdx], + bookmarks: [ + ...state.categories[parentCategoryIdx].bookmarks.slice( + 0, + updatedBookmarkIdx + ), + action.payload, + ...state.categories[parentCategoryIdx].bookmarks.slice( + updatedBookmarkIdx + 1 + ), + ], + }, + ...state.categories.slice(parentCategoryIdx + 1), + ], + }; + + case ActionType.sortCategories: + return { + ...state, + categories: sortData(state.categories, action.payload), + }; + + case ActionType.reorderCategories: + return { + ...state, + categories: action.payload, + }; + default: + return state; + } +}; diff --git a/client/src/store/reducers/index.ts b/client/src/store/reducers/index.ts index f30ccd8..1eed183 100644 --- a/client/src/store/reducers/index.ts +++ b/client/src/store/reducers/index.ts @@ -3,11 +3,15 @@ import { combineReducers } from 'redux'; import { themeReducer } from './theme'; import { configReducer } from './config'; import { notificationReducer } from './notification'; +import { appsReducer } from './app'; +import { bookmarksReducer } from './bookmark'; export const reducers = combineReducers({ theme: themeReducer, config: configReducer, notification: notificationReducer, + apps: appsReducer, + bookmarks: bookmarksReducer, }); export type State = ReturnType;