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) => (
- -
-
-
+
))}
{bottomNavLinks.map((navLink) => (
- -
-
-
+
))}
@@ -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 {}