diff --git a/server/lib/favorite/all.ts b/server/lib/favorite/all.ts new file mode 100644 index 000000000..7016628a7 --- /dev/null +++ b/server/lib/favorite/all.ts @@ -0,0 +1,7 @@ +import DB from "../storage/db"; + +export default function all(owner: number) { + return DB("favorites").select("*").where({ + owner, + }); +} \ No newline at end of file diff --git a/server/lib/favorite/create.ts b/server/lib/favorite/create.ts new file mode 100644 index 000000000..6c22f0611 --- /dev/null +++ b/server/lib/favorite/create.ts @@ -0,0 +1,9 @@ +import DB from "../storage/db"; + +export default function create(id: string, owner: number, type: string) { + return DB("favorites").insert({ + object_id: id, + type, + owner, + }); +} \ No newline at end of file diff --git a/server/lib/favorite/remove.ts b/server/lib/favorite/remove.ts new file mode 100644 index 000000000..36494dcbc --- /dev/null +++ b/server/lib/favorite/remove.ts @@ -0,0 +1,8 @@ +import DB from '../storage/db'; + +export default function remove(id: string, owner: number) { + return DB('favorites').delete().where({ + object_id: id, + owner, + }); +} diff --git a/server/migrations/20220423114610_user-favourites.js b/server/migrations/20220423114610_user-favourites.js new file mode 100644 index 000000000..45fefbdd3 --- /dev/null +++ b/server/migrations/20220423114610_user-favourites.js @@ -0,0 +1,10 @@ +module.exports.up = function(knex) { + return knex.schema.createTable('favorites', function(table) { + table.integer("owner").references("id").inTable("users").notNullable(); + table.text("object_id").notNullable(); + }) +}; + +module.exports.down = function(knex) { + return knex.schema.dropTable('favorites'); +}; diff --git a/server/migrations/20220424145342_user-favourites-add-type.js b/server/migrations/20220424145342_user-favourites-add-type.js new file mode 100644 index 000000000..b53751bdd --- /dev/null +++ b/server/migrations/20220424145342_user-favourites-add-type.js @@ -0,0 +1,13 @@ +module.exports.up = (knex) => { + return knex.schema.table("favorites", (table) => { + table.string("type").notNullable(); + }); +}; + + +module.exports.down = (knex) => { + return knex.schema.table("favorites", (table) => { + table.dropColumn("type"); + }); +}; + diff --git a/server/package-lock.json b/server/package-lock.json index 022acb0f0..8000b42b3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "notion2anki-server", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "notion2anki-server", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { "@notionhq/client": "^0.4.12", diff --git a/server/package.json b/server/package.json index 0d6a6697c..886e598c9 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "notion2anki" ], "author": "Alexander Alemayhu", - "version": "0.13.0", + "version": "0.14.0", "engines": { "node": ">=12.0.0" }, diff --git a/server/routes/favorite/addFavorite.ts b/server/routes/favorite/addFavorite.ts new file mode 100644 index 000000000..2b64c044c --- /dev/null +++ b/server/routes/favorite/addFavorite.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; + +import create from '../../lib/favorite/create'; +import ensureResponse from '../notion/helpers/ensureResponse'; + +export default async function addFavorite(req: Request, res: Response) { + ensureResponse(async () => { + const { id, type } = req.body; + if (!id || !type) { + return res.status(400).send(); + } + const { owner } = res.locals; + await create(id, owner, type); + return res.status(200).send(); + }, res); +} diff --git a/server/routes/favorite/deleteFavorite.ts b/server/routes/favorite/deleteFavorite.ts new file mode 100644 index 000000000..a39ccfeb8 --- /dev/null +++ b/server/routes/favorite/deleteFavorite.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; + +import remove from '../../lib/favorite/remove'; +import ensureResponse from '../notion/helpers/ensureResponse'; + +export default async function deleteFavorite(req: Request, res: Response) { + ensureResponse(async () => { + const { id } = req.body; + if (!id) { + return res.status(400).send(); + } + const { owner } = res.locals; + await remove(id, owner); + res.status(200).send(); + }, res); +} diff --git a/server/routes/favorite/getAllFavourites.ts b/server/routes/favorite/getAllFavourites.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/routes/favorite/getFavorites.ts b/server/routes/favorite/getFavorites.ts new file mode 100644 index 000000000..1842906b2 --- /dev/null +++ b/server/routes/favorite/getFavorites.ts @@ -0,0 +1,8 @@ +import { Request, Response } from "express"; +import all from "../../lib/favorite/all"; + +export default async function getFavorites(_req: Request, res: Response) { + const {owner} = res.locals; + const favorites = await all(owner); + res.json(favorites); +} \ No newline at end of file diff --git a/server/routes/favorite/index.ts b/server/routes/favorite/index.ts new file mode 100644 index 000000000..fc0a30855 --- /dev/null +++ b/server/routes/favorite/index.ts @@ -0,0 +1,14 @@ +import express from "express"; + +import RequireAuthentication from "../../middleware/RequireAuthentication"; +import addFavorite from "./addFavorite"; +import getFavorites from "./getFavorites"; +import deleteFavorite from "./deleteFavorite"; + +const router = express.Router(); + +router.post("/create", RequireAuthentication, addFavorite); +router.post("/remove", RequireAuthentication, deleteFavorite); +router.get("/", RequireAuthentication, getFavorites); + +export default router; diff --git a/server/server.ts b/server/server.ts index 527652415..590c6d0e9 100644 --- a/server/server.ts +++ b/server/server.ts @@ -26,6 +26,7 @@ import users from "./routes/users"; import notion from "./routes/notion"; import rules from "./routes/rules"; import download from "./routes/download/u"; +import favorite from "./routes/favorite"; import DB from "./lib/storage/db"; import KnexConfig from "./KnexConfig"; @@ -80,6 +81,7 @@ function serve() { app.use("/rules", rules); app.use("/settings", _settings); app.use("/download", download); + app.use("/favorite", favorite); // Note: this has to be the last handler app.get("*", (_req, res) => { diff --git a/web/public/icons/settings.svg b/web/public/icons/settings.svg deleted file mode 100644 index ad7240251..000000000 --- a/web/public/icons/settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index 4282f7138..a9c32072a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,8 @@ import CardOptionsStore from './store/CardOptionsStore'; import StoreContext from './store/StoreContext'; import GlobalStyle from './GlobalStyle'; import { NavigationBar } from './components/NavigationBar/NavigationBar'; +import SettingsPage from './pages/Settings'; +import ImportPage from './pages/Import/ImportPage'; const TemplatePage = lazy(() => import('./pages/Templates')); const PreSignupPage = lazy(() => import('./pages/Register')); @@ -19,7 +21,7 @@ const LoginPage = lazy(() => import('./pages/Login')); const NewPasswordPage = lazy(() => import('./pages/NewPassword')); const LearnPage = lazy(() => import('./pages/Learn')); const VerifyPage = lazy(() => import('./pages/Verify')); -const ListUploadsPage = lazy(() => import('./pages/Uploads')); +const MyUploadsPage = lazy(() => import('./pages/MyUploads')); const Layout = styled.div` display: flex; @@ -59,7 +61,7 @@ function App() { )} - + @@ -85,6 +87,12 @@ function App() { + + + + + + diff --git a/web/src/components/NavigationBar/NavigationBar.tsx b/web/src/components/NavigationBar/NavigationBar.tsx index e2fc124e9..69c1505f8 100644 --- a/web/src/components/NavigationBar/NavigationBar.tsx +++ b/web/src/components/NavigationBar/NavigationBar.tsx @@ -1,31 +1,27 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import { useState } from 'react'; -import NotionWorkspace from '../../lib/interfaces/NotionWorkspace'; +import getNavbarStartNewUser from './helpers/getNavbarStartNewUser'; import NavButtonCTA from '../buttons/NavButtonCTA'; import getCookie from './helpers/getCookie'; import Backend from '../../lib/Backend'; import NavbarItem from './NavbarItem'; import { Navbar } from './styled'; - -interface NavigationBarProps { - workspaces?: NotionWorkspace[]; - activeWorkspace?: string; - connectLink?: string; -} +import getNavbarStartRegularUser from './helpers/getNavbarStartRegularUser'; +import getNavbarEnd from './helpers/getNavbarEnd'; const backend = new Backend(); // eslint-disable-next-line import/prefer-default-export -export function NavigationBar({ - activeWorkspace, - workspaces, - connectLink, -}: NavigationBarProps) { +export function NavigationBar() { const isSignedIn = getCookie('token'); const [active, setHamburgerMenu] = useState(false); const path = window.location.pathname; const { hash } = window.location; + const navbarStart = isSignedIn + ? getNavbarStartRegularUser(hash) + : getNavbarStartNewUser(hash, path); + return (
@@ -51,53 +47,11 @@ export function NavigationBar({
diff --git a/web/src/components/modals/SettingsModal.tsx b/web/src/components/modals/SettingsModal.tsx index 67ac45995..386fbfacd 100644 --- a/web/src/components/modals/SettingsModal.tsx +++ b/web/src/components/modals/SettingsModal.tsx @@ -47,12 +47,11 @@ interface Props { pageId?: string; isActive: boolean; onClickClose: React.MouseEventHandler; - setError: (error: string) => void; } const backend = new Backend(); function SettingsModal({ - pageTitle, pageId, isActive, onClickClose, setError, + pageTitle, pageId, isActive, onClickClose, }: Props) { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(!!pageId); @@ -105,7 +104,7 @@ function SettingsModal({ } setLoading(false); }) - .catch((error) => setError(error.data.response.message)); + .catch((error) => { store.error = error; }); } }, [pageId]); @@ -149,7 +148,9 @@ function SettingsModal({ .then(() => { onClickClose(event); }) - .catch((error) => setError(error.data.response.message)); + .catch((error) => { + store.error = error; + }); }; return (
diff --git a/web/src/components/styled.tsx b/web/src/components/styled.tsx index 1081dccaf..1ab89690c 100644 --- a/web/src/components/styled.tsx +++ b/web/src/components/styled.tsx @@ -15,5 +15,9 @@ export const Main = styled.main` `; export const HomeContainer = styled(Container)` -padding: 0; + padding: 0; +`; + +export const PageContainer = styled.div` + margin: 0 auto; `; diff --git a/web/src/lib/Backend.ts b/web/src/lib/Backend.ts index 4ca6c2ac8..2a3df32c5 100644 --- a/web/src/lib/Backend.ts +++ b/web/src/lib/Backend.ts @@ -6,6 +6,7 @@ import UserJob from './interfaces/UserJob'; import getObjectTitle from './notion/getObjectTitle'; import getObjectIcon from './notion/getObjectIcon'; +import FavoriteObject from './interfaces/FavoriteObject'; class Backend { baseURL: string; @@ -18,6 +19,7 @@ class Backend { async logout() { localStorage.clear(); + sessionStorage.clear(); const endpoint = `${this.baseURL}users/logout`; await axios.get(endpoint, { withCredentials: true }); window.location.href = '/'; @@ -90,6 +92,7 @@ class Backend { 'You are making too many requests. Please wait a few seconds before searching.', ); } + const favorites = await this.getFavorites(); const isObjectId = query.replace(/-/g, '').length === 32; let data; @@ -123,12 +126,16 @@ class Backend { icon: getObjectIcon(p), url: p.url as string, id: p.id, + isFavorite: favorites.some((f) => f.id === p.id), })); } return []; } - async getPage(pageId: string): Promise { + async getPage( + pageId: string, + isFavorite: boolean = false, + ): Promise { try { const response = await axios.get(`${this.baseURL}notion/page/${pageId}`, { withCredentials: true, @@ -140,13 +147,17 @@ class Backend { url: response.data.url as string, id: response.data.id, data: response.data, + isFavorite, }; } catch (error) { return null; } } - async getDatabase(id: string): Promise { + async getDatabase( + id: string, + isFavorite: boolean = false, + ): Promise { try { const response = await axios.get(`${this.baseURL}notion/database/${id}`, { withCredentials: true, @@ -158,6 +169,7 @@ class Backend { url: response.data.url as string, id: response.data.id, data: response.data, + isFavorite, }; } catch (error) { return null; @@ -218,6 +230,36 @@ class Backend { }); return response.data.patreon; } + + async addFavorite(id: string, type: string): Promise { + return axios.post( + `${this.baseURL}favorite/create`, + { id, type }, + { + withCredentials: true, + }, + ); + } + + async deleteFavorite(id: string): Promise { + return axios.post( + `${this.baseURL}favorite/remove`, + { id }, + { + withCredentials: true, + }, + ); + } + + async getFavorites(): Promise { + const response = await axios.get(`${this.baseURL}favorite`, { + withCredentials: true, + }); + const getObject = (f: FavoriteObject) => (f.type === 'page' + ? this.getPage(f.object_id, true) + : this.getDatabase(f.object_id, true)); + return Promise.all(response.data.map(async (f) => getObject(f))); + } } export default Backend; diff --git a/web/src/lib/interfaces/FavoriteObject.ts b/web/src/lib/interfaces/FavoriteObject.ts new file mode 100644 index 000000000..f27d4b505 --- /dev/null +++ b/web/src/lib/interfaces/FavoriteObject.ts @@ -0,0 +1,5 @@ +export default interface FavoriteObject { + object_id: string; + owner: string; + type: string; +} \ No newline at end of file diff --git a/web/src/lib/interfaces/NotionObject.ts b/web/src/lib/interfaces/NotionObject.ts index 6d77a2590..eafac8d9f 100644 --- a/web/src/lib/interfaces/NotionObject.ts +++ b/web/src/lib/interfaces/NotionObject.ts @@ -5,6 +5,7 @@ interface NotionObject { icon?: string; id: string; data?: any; + isFavorite?: boolean; } export default NotionObject; diff --git a/web/src/pages/Import/ImportPage.tsx b/web/src/pages/Import/ImportPage.tsx new file mode 100644 index 000000000..c5d2c6a3e --- /dev/null +++ b/web/src/pages/Import/ImportPage.tsx @@ -0,0 +1,21 @@ +import { PageContainer } from '../../components/styled'; + +export default function ImportPage() { + return ( + +
+

Import

+
+

Our main focus right now is to get better support for Notion.

+

More formats are on the roadmap like:

+
    +
  • APKG → Notion
  • +
  • CSV → Anki
  • +
  • TSV → Anki
  • +
  • PDF → Anki
  • +
  • YouTube → Anki
  • +
+
+
+ ); +} diff --git a/web/src/pages/Import/index.tsx b/web/src/pages/Import/index.tsx new file mode 100644 index 000000000..5d194d550 --- /dev/null +++ b/web/src/pages/Import/index.tsx @@ -0,0 +1,3 @@ +import ImportPage from './ImportPage'; + +export default ImportPage; diff --git a/web/src/pages/Learn/index.tsx b/web/src/pages/Learn/index.tsx index 17a549ef8..96e628730 100644 --- a/web/src/pages/Learn/index.tsx +++ b/web/src/pages/Learn/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Container } from '../../components/styled'; +import { Container, PageContainer } from '../../components/styled'; import Backend from '../../lib/Backend'; import Wrapper from './Wrapper'; @@ -62,46 +62,49 @@ function LearnPage({ setError }: Props) { return
insert loading screen.
; } return ( - - - {page && ( - - )} -
- {block && ( - <> -

{block.id}

-
{JSON.stringify(block, null, 4)}
-
-
{JSON.stringify(grandChild, null, 2)}
- + +

Learn

+ + + {page && ( + )} - - - {location + 1} - {' '} - / - {children.length} - -
-
-
+
+ {block && ( + <> +

{block.id}

+
{JSON.stringify(block, null, 4)}
+
+
{JSON.stringify(grandChild, null, 2)}
+ + )} + + + {location + 1} + {' '} + / + {children.length} + +
+ + + ); } diff --git a/web/src/pages/Uploads/components/ActiveJobs.tsx b/web/src/pages/MyUploads/components/ActiveJobs.tsx similarity index 100% rename from web/src/pages/Uploads/components/ActiveJobs.tsx rename to web/src/pages/MyUploads/components/ActiveJobs.tsx diff --git a/web/src/pages/Uploads/components/UploadObjectEntry.tsx b/web/src/pages/MyUploads/components/UploadObjectEntry.tsx similarity index 100% rename from web/src/pages/Uploads/components/UploadObjectEntry.tsx rename to web/src/pages/MyUploads/components/UploadObjectEntry.tsx diff --git a/web/src/pages/Uploads/hooks/useActiveJobs.tsx b/web/src/pages/MyUploads/hooks/useActiveJobs.tsx similarity index 100% rename from web/src/pages/Uploads/hooks/useActiveJobs.tsx rename to web/src/pages/MyUploads/hooks/useActiveJobs.tsx diff --git a/web/src/pages/Uploads/hooks/usePatreon.tsx b/web/src/pages/MyUploads/hooks/usePatreon.tsx similarity index 100% rename from web/src/pages/Uploads/hooks/usePatreon.tsx rename to web/src/pages/MyUploads/hooks/usePatreon.tsx diff --git a/web/src/pages/Uploads/hooks/useQuota.tsx b/web/src/pages/MyUploads/hooks/useQuota.tsx similarity index 100% rename from web/src/pages/Uploads/hooks/useQuota.tsx rename to web/src/pages/MyUploads/hooks/useQuota.tsx diff --git a/web/src/pages/Uploads/hooks/useUploads.tsx b/web/src/pages/MyUploads/hooks/useUploads.tsx similarity index 100% rename from web/src/pages/Uploads/hooks/useUploads.tsx rename to web/src/pages/MyUploads/hooks/useUploads.tsx diff --git a/web/src/pages/MyUploads/index.tsx b/web/src/pages/MyUploads/index.tsx new file mode 100644 index 000000000..ae9bb62ab --- /dev/null +++ b/web/src/pages/MyUploads/index.tsx @@ -0,0 +1,133 @@ +import BecomeAPatron from '../../components/BecomeAPatron'; +import UploadObjectEntry from './components/UploadObjectEntry'; +import LoadingScreen from '../../components/LoadingScreen'; +import Backend from '../../lib/Backend'; +import ActiveJobs from './components/ActiveJobs'; + +import useUploads from './hooks/useUploads'; +import usePatreon from './hooks/usePatreon'; +import useQuota from './hooks/useQuota'; +import useActiveJobs from './hooks/useActiveJobs'; +import { Container, PageContainer } from '../../components/styled'; + +const backend = new Backend(); + +interface MyUploadsPageProps { + setError: (error: string) => void; +} + +function MyUploadsPage({ setError }: MyUploadsPageProps) { + const [ + loading, uploads, deleteUpload, deleteAllUploads, + isDeletingAll, + ] = useUploads(backend, setError); + const [activeJobs, deleteJob] = useActiveJobs(backend, setError); + const [isPatreon] = usePatreon(backend, setError); + const [quota] = useQuota(uploads); + + if (loading) return ; + + return ( + + + {activeJobs.length > 0 && ( + deleteJob(id)} /> + )} +

My Uploads

+ {uploads.length === 0 && !loading && ( +

+ You have no uploads! Make some from the + {' '} + + search + + {' '} + page. +

+ )} + {uploads.length > 0 && ( + <> + {uploads + && uploads.map((u) => ( + deleteUpload(u.key)} + /> + ))} +
+ {!isPatreon && ( +
+
+
+ You have used + {' '} + {quota.toFixed(2)} + {' '} + MB + {!isPatreon && ' of your quota (21MB)'} + . +
+ +
+ 16 ? 'is-danger' : 'is-info'}`} + value={quota} + max={21} + > + 15% + +
+
+
+

Imposed limitations

+

+ We have set quota limits on non-patrons to avoid increasing + server load. The limitations are: +

+
    +
  • + You can only make conversions totalling 21MB but this is + not permanent. You can for example delete previous uploads + to reclaim your space when using it all up. +
  • +
  • + You can only convert at most 21 subpages (applies to + database entries as well) per conversion job. +
  • +
  • + Max 1 conversion job but you can start new ones as soon as + the last one is completed. +
  • +
  • You can only load 21 blocks total per page.
  • +
+

+ If you want the limits removed you can do so by becoming a + patron and they will removed for your account. +

+
+ +
+
+ )} + + )} +
+
+ ); +} + +export default MyUploadsPage; diff --git a/web/src/pages/Search/components/ConnectNotion.tsx b/web/src/pages/Search/components/ConnectNotion.tsx new file mode 100644 index 000000000..ed7a9c4f1 --- /dev/null +++ b/web/src/pages/Search/components/ConnectNotion.tsx @@ -0,0 +1,25 @@ +interface Props { + connectionLink: string; +} + +export default function ConnectNotion({ connectionLink }: Props) { + return ( + + ); +} diff --git a/web/src/pages/Search/components/DefineRules.tsx b/web/src/pages/Search/components/DefineRules.tsx index 925c5a30a..0112ef7ab 100644 --- a/web/src/pages/Search/components/DefineRules.tsx +++ b/web/src/pages/Search/components/DefineRules.tsx @@ -1,16 +1,22 @@ -import { useEffect, useState } from 'react'; +import { + Dispatch, SetStateAction, useContext, useEffect, useState, +} from 'react'; import Switch from '../../../components/input/Switch'; import SettingsModal from '../../../components/modals/SettingsModal'; import TemplateSelect from '../../../components/TemplateSelect'; import Backend from '../../../lib/Backend'; +import NotionObject from '../../../lib/interfaces/NotionObject'; +import StoreContext from '../../../store/StoreContext'; import FlashcardType from './FlashcardType'; interface Props { + type: string; id: string; setDone: () => void; parent: string; - setError: (error: string) => void; + isFavorite: boolean; + setFavorites: Dispatch>; } const flashCardOptions = [ @@ -23,9 +29,10 @@ const flashCardOptions = [ const tagOptions = ['heading', 'strikethrough']; const backend = new Backend(); -function DefineRules({ - id, setDone, parent, setError, -}: Props) { +function DefineRules(props: Props) { + const { + type, id, setDone, parent, isFavorite, setFavorites, + } = props; const [rules, setRules] = useState({ flashcard_is: ['toggle'], sub_deck_is: 'child_page', @@ -40,6 +47,9 @@ function DefineRules({ const [tags, setTags] = useState(rules.tags_is); const [sendEmail, setSendEmail] = useState(rules.email_notification); const [more, setMore] = useState(false); + const [favorite, setFavorite] = useState(isFavorite); + + const store = useContext(StoreContext); useEffect(() => { backend @@ -54,7 +64,7 @@ function DefineRules({ setIsloading(false); }) .catch((error) => { - setError(error.response.data.message); + store.error = error; }); }, [id]); @@ -75,9 +85,8 @@ function DefineRules({ ); setDone(); } catch (error) { - setError(error.response.data.message); + store.error = error; } - setIsloading(false); }; const onSelectedFlashcardTypes = (fco: string) => { @@ -90,6 +99,17 @@ function DefineRules({ setFlashcard((prevState) => Array.from(new Set([...prevState, ...rules.flashcard_is]))); }; + const toggleFavorite = async () => { + if (favorite) { + await backend.deleteFavorite(id); + } else { + await backend.addFavorite(id, type); + } + const favorites = await backend.getFavorites(); + setFavorites(favorites); + setFavorite(!favorite); + }; + return (
@@ -127,7 +147,6 @@ function DefineRules({ onClickClose={() => { setMore(false); }} - setError={setError} /> )}
@@ -136,6 +155,7 @@ function DefineRules({
{flashCardOptions.map((fco) => ( onSelectedFlashcardTypes(name)} @@ -165,6 +185,13 @@ function DefineRules({ setSendEmail(rules.email_notification); }} /> +

-
- 16 ? 'is-danger' : 'is-info'}`} - value={quota} - max={21} - > - 15% - -
-
-
-

Imposed limitations

-

- We have set quota limits on non-patrons to avoid increasing - server load. The limitations are: -

-
    -
  • - You can only make conversions totalling 21MB but this is - not permanent. You can for example delete previous uploads - to reclaim your space when using it all up. -
  • -
  • - You can only convert at most 21 subpages (applies to - database entries as well) per conversion job. -
  • -
  • - Max 1 conversion job but you can start new ones as soon as - the last one is completed. -
  • -
  • You can only load 21 blocks total per page.
  • -
-

- If you want the limits removed you can do so by becoming a - patron and they will removed for your account. -

-
- -
-
- )} - - )} - - ); -} - -export default ListUploadsPage; diff --git a/web/src/store/CardOptionsStore.ts b/web/src/store/CardOptionsStore.ts index aefc7f7ed..c4ef6d086 100644 --- a/web/src/store/CardOptionsStore.ts +++ b/web/src/store/CardOptionsStore.ts @@ -4,6 +4,8 @@ import CardOption from './CardOption'; class CardOptionsStore { public options: CardOption[]; + public error: Error | null; + constructor() { this.options = supportedOptions(); }