diff --git a/src/App.tsx b/src/App.tsx index a6844a8..0aac255 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,15 @@ import { NavLinksProvider, type NavLink, } from '@/router/hooks/use-nav-links.tsx'; +import { route } from '@/router/types/route.ts'; import { SubscriptionUpsertProvider } from '@/subscriptions/hooks/use-subscription-upsert.tsx'; import { SubscriptionsProvider } from '@/subscriptions/hooks/use-subscriptions.tsx'; import { DefaultLayoutProvider } from '@/ui/hooks/use-default-layout.tsx'; -import { faChartSimple, faCreditCard } from '@fortawesome/free-solid-svg-icons'; +import { + faChartSimple, + faClockRotateLeft, + faCreditCard, +} from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { memo } from 'react'; import { Outlet } from 'react-router-dom'; @@ -29,20 +34,20 @@ export const App = memo(() => { const topNavLinks = [ { label: 'Dashboard', - path: '/dashboard', + path: `/${route.dashboard}`, icon: , }, { label: 'Subscriptions', - path: '/subscriptions', + path: `/${route.subscriptions}`, icon: , }, ] as const satisfies Array; const bottomNavLinks = [ { - label: 'Bulk Actions', - path: '/subscriptions-bulk', - icon: , + label: 'Recovery', + path: `/${route.recovery}`, + icon: , }, ] as const satisfies Array; diff --git a/src/db/globals/db.ts b/src/db/globals/db.ts index 284229b..895e83d 100644 --- a/src/db/globals/db.ts +++ b/src/db/globals/db.ts @@ -3,13 +3,15 @@ import type { SubscriptionModel } from '@/subscriptions/models/subscription.mode import type { TagModel } from '@/tags/models/tag.model.ts'; import Dexie, { type EntityTable, type Table } from 'dexie'; +export const dbVersion = 3; + export const db = new Dexie('subs-savvy') as Dexie & { subscriptions: EntityTable, 'id'>; tags: EntityTable; subscriptionsTags: Table; }; -db.version(3).stores({ +db.version(dbVersion).stores({ subscriptions: '++id', tags: '++id', subscriptionsTags: '[subscriptionId+tagId],subscriptionId,tagId', diff --git a/src/main.tsx b/src/main.tsx index 74cda8a..a246a9d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import { recoveryRoute } from '@/recovery/types/recovery-route.ts'; import { route } from '@/router/types/route.ts'; import { MantineProvider } from '@mantine/core'; import { StrictMode } from 'react'; @@ -28,36 +29,56 @@ const router = createBrowserRouter([ Component: App, children: [ { - path: route.root, + path: '/', element: ( ), }, { - path: route.dashboard, + path: `/${route.dashboard}`, lazy: () => import(`@/dashboard/pages/dashboard.page.tsx`).then((m) => ({ Component: m.DashboardPage, })), }, { - path: route.subscriptions, + path: `/${route.subscriptions}`, lazy: () => import(`@/subscriptions/pages/subscriptions.page.tsx`).then((m) => ({ Component: m.SubscriptionsPage, })), }, { - path: route.subscriptionsBulk, + path: `/${route.recovery}`, lazy: () => - import(`@/subscriptions/pages/subscriptions-bulk.page.tsx`).then( - (m) => ({ - Component: m.SubscriptionsBulkPage, - }), - ), + import(`@/recovery/pages/recovery.page.tsx`).then((m) => ({ + Component: m.RecoveryPage, + })), + children: [ + { + path: `/${route.recovery}`, + element: ( + + ), + }, + { + path: `/${route.recovery}/${recoveryRoute.import}`, + lazy: () => + import(`@/recovery/pages/recovery-import.page.tsx`).then((m) => ({ + Component: m.RecoveryImportPage, + })), + }, + { + path: `/${route.recovery}/${recoveryRoute.export}`, + lazy: () => + import(`@/recovery/pages/recovery-export.page.tsx`).then((m) => ({ + Component: m.RecoveryExportPage, + })), + }, + ], }, ], }, diff --git a/src/recovery/models/recovery.model.ts b/src/recovery/models/recovery.model.ts new file mode 100644 index 0000000..0f88df1 --- /dev/null +++ b/src/recovery/models/recovery.model.ts @@ -0,0 +1,14 @@ +import { subscriptionSchema } from '@/subscriptions/models/subscription.model.ts'; +import { z } from 'zod'; + +// TODO add support for exporting with tags as well +export const recoverySchema = z.object({ + dbVersion: z.number(), + subscriptions: z.array( + subscriptionSchema.omit({ + id: true, + tags: true, + }), + ), +}); +export type RecoveryModel = z.infer; diff --git a/src/recovery/pages/recovery-export.page.tsx b/src/recovery/pages/recovery-export.page.tsx new file mode 100644 index 0000000..37b1768 --- /dev/null +++ b/src/recovery/pages/recovery-export.page.tsx @@ -0,0 +1,65 @@ +import { dbVersion } from '@/db/globals/db.ts'; +import { SubscriptionsSelectTable } from '@/subscriptions/components/subscriptions-select-table.tsx'; +import { findSubscriptions } from '@/subscriptions/models/subscription.table.ts'; +import { cn } from '@/ui/utils/cn.ts'; +import { Button, Switch } from '@mantine/core'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { memo, useCallback, useState } from 'react'; +import { recoverySchema } from '../models/recovery.model.ts'; + +export const RecoveryExportPage = memo(() => { + const subscriptions = useLiveQuery(() => findSubscriptions(), [], []); + const [selectedIds, setSelectedIds] = useState([]); + + const [isPrettify, setIsPrettify] = useState(true); + const toggleIsPrettify = useCallback(() => { + setIsPrettify(!isPrettify); + }, [isPrettify]); + + const exportSubscriptions = useCallback(() => { + const subscriptionsToExport = subscriptions.filter((subscription) => + selectedIds.includes(subscription.id), + ); + const subscriptionsExport = recoverySchema.parse({ + dbVersion, + subscriptions: subscriptionsToExport, + }); + const jsonToExport = isPrettify + ? JSON.stringify(subscriptionsExport, null, 2) + : JSON.stringify(subscriptionsExport); + const blobToExport = new Blob([jsonToExport], { type: 'application/json' }); + + const exportLink = document.createElement('a'); + exportLink.href = URL.createObjectURL(blobToExport); + exportLink.download = 'subscriptions.json'; + document.body.appendChild(exportLink); + exportLink.click(); + document.body.removeChild(exportLink); + }, [isPrettify, selectedIds, subscriptions]); + + return ( +
+ + +
+ + +
+ + +
+
+ ); +}); diff --git a/src/recovery/pages/recovery-import.page.tsx b/src/recovery/pages/recovery-import.page.tsx new file mode 100644 index 0000000..b9b8a02 --- /dev/null +++ b/src/recovery/pages/recovery-import.page.tsx @@ -0,0 +1,6 @@ +import { cn } from '@/ui/utils/cn.ts'; +import { memo } from 'react'; + +export const RecoveryImportPage = memo(() => { + return
Work is in progress
; +}); diff --git a/src/recovery/pages/recovery.page.tsx b/src/recovery/pages/recovery.page.tsx new file mode 100644 index 0000000..2afb664 --- /dev/null +++ b/src/recovery/pages/recovery.page.tsx @@ -0,0 +1,53 @@ +import { route } from '@/router/types/route.ts'; +import { + DefaultLayout, + DefaultLayoutHeader, +} from '@/ui/layouts/default.layout.tsx'; +import { faDownload, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Card, Tabs } from '@mantine/core'; +import { memo, useCallback, useMemo } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { recoveryRoute } from '../types/recovery-route.ts'; + +export const RecoveryPage = memo(() => { + const { pathname } = useLocation(); + const activeTab = useMemo(() => { + return pathname.split('/').at(-1); + }, [pathname]); + + const navigate = useNavigate(); + const navigateToTab = useCallback( + (tab: string | null) => navigate(`/${route.recovery}/${tab}`), + [navigate], + ); + + return ( + }> + + + + }> + Import + + }> + Export + + + + + + + + ); +}); diff --git a/src/recovery/types/recovery-route.ts b/src/recovery/types/recovery-route.ts new file mode 100644 index 0000000..62d8169 --- /dev/null +++ b/src/recovery/types/recovery-route.ts @@ -0,0 +1,6 @@ +export const recoveryRoute = { + import: 'import', + export: 'export', +} as const; + +export type RecoveryRoute = (typeof recoveryRoute)[keyof typeof recoveryRoute]; diff --git a/src/router/hooks/use-nav-links.tsx b/src/router/hooks/use-nav-links.tsx index c55ca35..748e6de 100644 --- a/src/router/hooks/use-nav-links.tsx +++ b/src/router/hooks/use-nav-links.tsx @@ -5,7 +5,6 @@ import { type PropsWithChildren, type ReactNode, } from 'react'; -import type { Route } from '../types/route.ts'; export function useNavLinks(): UseNavLinks { return useContext(navLinksContext); @@ -18,7 +17,7 @@ export interface UseNavLinks { export interface NavLink { label: string; - path: Route | string; + path: string; icon: ReactNode; } diff --git a/src/router/types/route.ts b/src/router/types/route.ts index 5ae14ba..89c2632 100644 --- a/src/router/types/route.ts +++ b/src/router/types/route.ts @@ -1,8 +1,7 @@ export const route = { - root: '/', - dashboard: '/dashboard', - subscriptions: '/subscriptions', - subscriptionsBulk: '/subscriptions-bulk', + dashboard: 'dashboard', + subscriptions: 'subscriptions', + recovery: 'recovery', } as const; export type Route = (typeof route)[keyof typeof route]; diff --git a/src/subscriptions/components/export-subscriptions.tsx b/src/subscriptions/components/subscriptions-select-table.tsx similarity index 52% rename from src/subscriptions/components/export-subscriptions.tsx rename to src/subscriptions/components/subscriptions-select-table.tsx index 3581303..bac5c25 100644 --- a/src/subscriptions/components/export-subscriptions.tsx +++ b/src/subscriptions/components/subscriptions-select-table.tsx @@ -1,54 +1,30 @@ -import { cn } from '@/ui/utils/cn.ts'; -import { Button, Checkbox, Switch, Table } from '@mantine/core'; +import { Checkbox, Table } from '@mantine/core'; import dayjs from 'dayjs'; -import { useLiveQuery } from 'dexie-react-hooks'; -import { memo, useCallback, useState } from 'react'; -import { exportedSubscriptionSchema } from '../models/subscription.model.ts'; -import { findSubscriptions } from '../models/subscription.table.ts'; +import { memo, type Dispatch, type SetStateAction } from 'react'; +import type { SubscriptionModel } from '../models/subscription.model.ts'; import { subscriptionCyclePeriodToLabel } from '../types/subscription-cycle-period.ts'; import { subscriptionIconToLabel } from '../types/subscription-icon.tsx'; -export const ExportSubscriptions = memo(() => { - const subscriptions = useLiveQuery(() => findSubscriptions(), [], []); +export const SubscriptionsSelectTable = memo( + ({ + subscriptions, + selectedIds, + setSelectedIds, + }: SubscriptionsSelectTableProps) => { + const toggleAll = () => { + selectedIds.length === subscriptions.length + ? setSelectedIds([]) + : setSelectedIds(subscriptions.map((subscription) => subscription.id)); + }; + const toggleSelectedId = (id: number): void => { + setSelectedIds( + !selectedIds.includes(id) + ? [...selectedIds, id] + : selectedIds.filter((selectedId) => selectedId !== id), + ); + }; - const [selectedIds, setSelectedIds] = useState([]); - const toggleAll = () => { - selectedIds.length === subscriptions.length - ? setSelectedIds([]) - : setSelectedIds(subscriptions.map((subscription) => subscription.id)); - }; - const toggleSelectedId = (id: number): void => { - setSelectedIds( - !selectedIds.includes(id) - ? [...selectedIds, id] - : selectedIds.filter((selectedId) => selectedId !== id), - ); - }; - - const [isPrettify, setIsPrettify] = useState(true); - const toggleIsPrettify = useCallback(() => { - setIsPrettify(!isPrettify); - }, [isPrettify]); - - const exportSubscriptions = () => { - const subscriptionsToExport = subscriptions - .filter((subscription) => selectedIds.includes(subscription.id)) - .map((subscription) => exportedSubscriptionSchema.parse(subscription)); - const jsonToExport = isPrettify - ? JSON.stringify(subscriptionsToExport, null, 2) - : JSON.stringify(subscriptionsToExport); - const blobToExport = new Blob([jsonToExport], { type: 'application/json' }); - - const exportLink = document.createElement('a'); - exportLink.href = URL.createObjectURL(blobToExport); - exportLink.download = 'subscriptions.json'; - document.body.appendChild(exportLink); - exportLink.click(); - document.body.removeChild(exportLink); - }; - - return ( -
+ return ( @@ -108,22 +84,12 @@ export const ExportSubscriptions = memo(() => {
+ ); + }, +); -
- - -
- - -
-
- ); -}); +export interface SubscriptionsSelectTableProps { + subscriptions: Array; + selectedIds: Array; + setSelectedIds: Dispatch>>; +} diff --git a/src/subscriptions/models/subscription.model.ts b/src/subscriptions/models/subscription.model.ts index c1841c9..b28ffa9 100644 --- a/src/subscriptions/models/subscription.model.ts +++ b/src/subscriptions/models/subscription.model.ts @@ -31,12 +31,3 @@ export type UpdateSubscriptionModel = z.infer; export type UpsertSubscriptionModel = | InsertSubscriptionModel | UpdateSubscriptionModel; - -// TODO add support for exporting with tags as well -export const exportedSubscriptionSchema = subscriptionSchema.omit({ - id: true, - tags: true, -}); -export type ExportedSubscriptionModel = z.infer< - typeof exportedSubscriptionSchema ->; diff --git a/src/subscriptions/pages/subscriptions-bulk.page.tsx b/src/subscriptions/pages/subscriptions-bulk.page.tsx deleted file mode 100644 index 14a67da..0000000 --- a/src/subscriptions/pages/subscriptions-bulk.page.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { - DefaultLayout, - DefaultLayoutHeader, -} from '@/ui/layouts/default.layout.tsx'; -import { faDownload, faUpload } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Tabs } from '@mantine/core'; -import { memo, useCallback, useEffect, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { ExportSubscriptions } from '../components/export-subscriptions'; - -export const SubscriptionsBulkPage = memo(() => { - const [searchParams, setSearchParams] = useSearchParams(); - - const tab = useMemo(() => { - const tabFromSearchParams = searchParams.get(tabSearchParam); - if (!tabFromSearchParams || !(tabFromSearchParams in bulkTab)) { - return bulkTab.import; - } - - return tabFromSearchParams as BulkTab; - }, [searchParams]); - - const changeTab = useCallback( - (newTab: string | null) => { - if (!newTab || !(newTab in bulkTab)) { - setSearchParams((prevSearchParams) => { - prevSearchParams.set(tabSearchParam, bulkTab.import); - return prevSearchParams; - }); - } - - setSearchParams((prevSearchParams) => { - prevSearchParams.set(tabSearchParam, bulkTab[newTab as BulkTab]); - return prevSearchParams; - }); - }, - [setSearchParams], - ); - - useEffect(() => { - const tabFromSearchParams = searchParams.get(tabSearchParam); - if (tabFromSearchParams && !(tabFromSearchParams in bulkTab)) { - setSearchParams((prevSearchParams) => { - prevSearchParams.delete(tabSearchParam); - return prevSearchParams; - }); - } - }, [searchParams, setSearchParams]); - - return ( - }> - - - }> - Import - - }> - Export - - - - Import Content - - - - - - ); -}); - -const bulkTab = { - import: 'import', - export: 'export', -} as const; -type BulkTab = (typeof bulkTab)[keyof typeof bulkTab]; - -const tabSearchParam = 'tab'; diff --git a/src/ui/layouts/default.layout.tsx b/src/ui/layouts/default.layout.tsx index 2e7fc0b..5c8d7d2 100644 --- a/src/ui/layouts/default.layout.tsx +++ b/src/ui/layouts/default.layout.tsx @@ -1,6 +1,12 @@ -import { useNavLinks } from '@/router/hooks/use-nav-links.tsx'; +import { useNavLinks, type NavLink } from '@/router/hooks/use-nav-links.tsx'; import { cn } from '@/ui/utils/cn.ts'; -import { Affix, AppShell, Burger, Drawer, NavLink } from '@mantine/core'; +import { + Affix, + AppShell, + Burger, + Drawer, + NavLink as MantineNavLink, +} from '@mantine/core'; import { memo, type PropsWithChildren, type ReactElement } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useBreakpoint } from '../hooks/use-breakpoint.ts'; @@ -15,7 +21,6 @@ export const DefaultLayout = memo( }: PropsWithChildren) => { const { topNavLinks, bottomNavLinks } = useNavLinks(); const { isDrawerOpened, isNavOpened, drawer, nav } = useDefaultLayout(); - const { pathname } = useLocation(); const isMd = useBreakpoint('md'); return ( @@ -41,29 +46,19 @@ export const DefaultLayout = memo(
    {topNavLinks.map((navLink) => ( -
  1. - -
  2. + ))}
    {bottomNavLinks.map((navLink) => ( -
  3. - -
  4. + ))}
@@ -120,3 +115,23 @@ export const DefaultLayoutHeader = memo( export interface DefaultLayoutHeaderProps { actions?: ReactElement; } + +const DefaultLayoutNavLink = memo( + ({ path, label, icon }: DefaultLayoutNavLinkProps) => { + const { pathname } = useLocation(); + + return ( +
  • + +
  • + ); + }, +); + +interface DefaultLayoutNavLinkProps extends NavLink {}