diff --git a/src/webapp/app/(authenticated)/collections/create/page.tsx b/src/webapp/app/(authenticated)/collections/create/page.tsx
deleted file mode 100644
index ee0dbd35..00000000
--- a/src/webapp/app/(authenticated)/collections/create/page.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { getAccessToken } from '@/services/auth';
-import { ApiClient } from '@/api-client/ApiClient';
-import {
- Stack,
- Title,
- Text,
- Container,
- Card,
- Button,
- TextInput,
- Textarea,
- Group,
- Alert,
-} from '@mantine/core';
-import { useForm } from '@mantine/form';
-
-export default function CreateCollectionPage() {
- const form = useForm({
- initialValues: {
- name: '',
- description: '',
- },
- });
-
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
- const router = useRouter();
-
- // Create API client instance
- const apiClient = new ApiClient(
- process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
- () => getAccessToken(),
- );
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!form.getValues().name.trim()) {
- setError('Collection name is required');
- return;
- }
-
- setLoading(true);
- setError('');
-
- try {
- await apiClient.createCollection({
- name: form.getValues().name.trim(),
- description: form.getValues().description.trim() || undefined,
- });
-
- // Redirect to collections page on success
- router.push('/collections');
- } catch (error: any) {
- console.error('Error creating collection:', error);
- setError(
- error.message || 'Failed to create collection. Please try again.',
- );
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
-
- Create Collection
-
- Create a new collection to organize your cards.
-
-
-
-
-
- Collection Details
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/webapp/components/navigation/appLayout/AppLayout.tsx b/src/webapp/components/navigation/appLayout/AppLayout.tsx
index 6e59f49e..6d6dd1b4 100644
--- a/src/webapp/components/navigation/appLayout/AppLayout.tsx
+++ b/src/webapp/components/navigation/appLayout/AppLayout.tsx
@@ -23,7 +23,7 @@ export default function AppLayout(props: Props) {
- {props.children}
+ {props.children}
diff --git a/src/webapp/features/collections/components/collectionsNavList/CollectionsNavList.tsx b/src/webapp/features/collections/components/collectionsNavList/CollectionsNavList.tsx
index 87e85a6a..53c7ee0f 100644
--- a/src/webapp/features/collections/components/collectionsNavList/CollectionsNavList.tsx
+++ b/src/webapp/features/collections/components/collectionsNavList/CollectionsNavList.tsx
@@ -1,10 +1,11 @@
import Link from 'next/link';
-import { Alert, NavLink, Stack } from '@mantine/core';
-import { BiCollection, BiPlus, BiRightArrowAlt } from 'react-icons/bi';
+import { NavLink, Stack } from '@mantine/core';
+import { BiCollection, BiRightArrowAlt } from 'react-icons/bi';
import CollectionNavItem from '../collectionNavItem/CollectionNavItem';
import useCollections from '../../lib/queries/useCollections';
import { useToggle } from '@mantine/hooks';
import CollectionsNavListError from './Error.CollectionsNavList';
+import CreateCollectionShortcut from '../createCollectionShortcut/CreateCollectionShortcut';
export default function CollectionsNavList() {
const { data, error } = useCollections();
@@ -32,14 +33,8 @@ export default function CollectionsNavList() {
leftSection={}
/>
- }
- />
+ {/* Self-contained shortcut to prevent won't re-render this whole list when interacted with */}
+
{data.collections.map((c) => (
diff --git a/src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx b/src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx
new file mode 100644
index 00000000..4aae43a4
--- /dev/null
+++ b/src/webapp/features/collections/components/createCollectionDrawer/CreateCollectionDrawer.tsx
@@ -0,0 +1,106 @@
+import {
+ Button,
+ Container,
+ Drawer,
+ Group,
+ Stack,
+ Textarea,
+ TextInput,
+} from '@mantine/core';
+import { useForm } from '@mantine/form';
+import useCreateCollection from '../../lib/mutations/useCreateCollection';
+import { notifications } from '@mantine/notifications';
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function createCollectionDrawer(props: Props) {
+ const createCollection = useCreateCollection();
+ const form = useForm({
+ initialValues: {
+ name: '',
+ description: '',
+ },
+ });
+
+ const handleCreateCollection = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ createCollection.mutate(
+ {
+ name: form.getValues().name,
+ description: form.getValues().description,
+ },
+ {
+ onSuccess: () => {
+ notifications.show({
+ message: `Created collection "${form.getValues().name}"`,
+ });
+ props.onClose();
+ },
+ onError: () => {
+ notifications.show({
+ message: 'Could not create collection.',
+ });
+ },
+ onSettled: () => {
+ form.reset();
+ },
+ },
+ );
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/webapp/features/collections/components/createCollectionShortcut/CreateCollectionShortcut.tsx b/src/webapp/features/collections/components/createCollectionShortcut/CreateCollectionShortcut.tsx
new file mode 100644
index 00000000..c210c22d
--- /dev/null
+++ b/src/webapp/features/collections/components/createCollectionShortcut/CreateCollectionShortcut.tsx
@@ -0,0 +1,18 @@
+import { useContextDrawers } from '@/providers/drawers';
+import { NavLink } from '@mantine/core';
+import { BiPlus } from 'react-icons/bi';
+
+export default function CreateCollectionShortcut() {
+ const drawers = useContextDrawers();
+
+ return (
+ }
+ onClick={() => drawers.open({ drawer: 'createCollection' })}
+ />
+ );
+}
diff --git a/src/webapp/features/collections/lib/mutations/useCreateCollection.tsx b/src/webapp/features/collections/lib/mutations/useCreateCollection.tsx
new file mode 100644
index 00000000..f85df8c8
--- /dev/null
+++ b/src/webapp/features/collections/lib/mutations/useCreateCollection.tsx
@@ -0,0 +1,27 @@
+import { ApiClient } from '@/api-client/ApiClient';
+import { getAccessToken } from '@/services/auth';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+export default function useCreateCollection() {
+ const apiClient = new ApiClient(
+ process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
+ () => getAccessToken(),
+ );
+
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: (newCollection: { name: string; description: string }) => {
+ return apiClient.createCollection(newCollection);
+ },
+
+ // Do things that are absolutely necessary and logic related (like query invalidation) in the useMutation callbacks
+ // Do UI related things like redirects or showing toast notifications in mutate callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire
+ // https://tkdodo.eu/blog/mastering-mutations-in-react-query#some-callbacks-might-not-fire
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['collections'] });
+ },
+ });
+
+ return mutation;
+}
diff --git a/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx b/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
index ddd48b96..4cdfbf38 100644
--- a/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
+++ b/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
@@ -7,8 +7,8 @@ import {
Stack,
Transition,
Card,
- AspectRatio,
} from '@mantine/core';
+import { useContextDrawers } from '@/providers/drawers';
import { Fragment, useState } from 'react';
import { FiPlus, FiX } from 'react-icons/fi';
import { FaRegNoteSticky } from 'react-icons/fa6';
@@ -17,6 +17,7 @@ import Link from 'next/link';
export default function ComposerDrawer() {
const [opened, setOpened] = useState(false);
+ const drawers = useContextDrawers();
return (
@@ -28,7 +29,7 @@ export default function ComposerDrawer() {
radius={'lg'}
>
-
+
drawers.open({ drawer: 'createCollection' })}
p={0}
style={{ cursor: 'pointer' }}
>
diff --git a/src/webapp/package-lock.json b/src/webapp/package-lock.json
index 2dab6d60..29007b52 100644
--- a/src/webapp/package-lock.json
+++ b/src/webapp/package-lock.json
@@ -13,6 +13,7 @@
"@mantine/dropzone": "^8.1.3",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
+ "@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.1.3",
"@tanstack/react-query": "^5.85.5",
"@vercel/analytics": "^1.5.0",
@@ -2039,6 +2040,17 @@
"react": "^18.x || ^19.x"
}
},
+ "node_modules/@mantine/modals": {
+ "version": "8.1.3",
+ "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.1.3.tgz",
+ "integrity": "sha512-PTLquO7OuYHrbezhjqf1fNwxU1NKZJmNYDOll6RHp6FPQ80xCVWQqVFsj3R8XsLluu2b5ygTYi+avWrUr1GvGg==",
+ "peerDependencies": {
+ "@mantine/core": "8.1.3",
+ "@mantine/hooks": "8.1.3",
+ "react": "^18.x || ^19.x",
+ "react-dom": "^18.x || ^19.x"
+ }
+ },
"node_modules/@mantine/notifications": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.1.3.tgz",
diff --git a/src/webapp/package.json b/src/webapp/package.json
index 8b960a40..7edb477b 100644
--- a/src/webapp/package.json
+++ b/src/webapp/package.json
@@ -29,6 +29,7 @@
"@mantine/dropzone": "^8.1.3",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
+ "@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.1.3",
"@tanstack/react-query": "^5.85.5",
"@vercel/analytics": "^1.5.0",
diff --git a/src/webapp/providers/drawers.tsx b/src/webapp/providers/drawers.tsx
new file mode 100644
index 00000000..a02d7f9b
--- /dev/null
+++ b/src/webapp/providers/drawers.tsx
@@ -0,0 +1,83 @@
+import CreateCollectionDrawer from '@/features/collections/components/createCollectionDrawer/CreateCollectionDrawer';
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ ReactNode,
+} from 'react';
+
+type DrawerName = 'createCollection';
+
+type DrawerPropsMap = {
+ createCollection: {};
+};
+
+interface DrawerState {
+ name: DrawerName | null;
+ innerProps: T;
+}
+
+interface DrawersContextType {
+ open: (args: {
+ drawer: T;
+ innerProps?: DrawerPropsMap[T];
+ }) => void;
+ close: () => void;
+ isOpen: (name: DrawerName) => boolean;
+ innerProps: any;
+}
+
+const DrawersContext = createContext(undefined);
+
+export const useContextDrawers = () => {
+ const ctx = useContext(DrawersContext);
+ if (!ctx) {
+ throw new Error('useContextDrawers must be used within a DrawersProvider');
+ }
+ return ctx;
+};
+
+export const DrawersProvider = ({ children }: { children: ReactNode }) => {
+ const [state, setState] = useState({
+ name: null,
+ innerProps: {},
+ });
+
+ const open = useCallback(
+ ({
+ drawer,
+ innerProps = {},
+ }: {
+ drawer: T;
+ innerProps?: DrawerPropsMap[T];
+ }) => {
+ setState({ name: drawer, innerProps });
+ },
+ [],
+ );
+
+ const close = useCallback(() => {
+ setState({ name: null, innerProps: {} });
+ }, []);
+
+ const isOpen = useCallback(
+ (name: DrawerName) => state.name === name,
+ [state.name],
+ );
+
+ return (
+
+ {children}
+
+ {/* Render */}
+
+
+ );
+};
diff --git a/src/webapp/providers/index.tsx b/src/webapp/providers/index.tsx
index 63271be2..450f1213 100644
--- a/src/webapp/providers/index.tsx
+++ b/src/webapp/providers/index.tsx
@@ -4,6 +4,8 @@ import { AuthProvider } from '@/hooks/useAuth';
import MantineProvider from './mantine';
import TanStackQueryProvider from './tanstack';
import { NavbarProvider } from './navbar';
+import { DrawersProvider } from './drawers';
+import { Notifications } from '@mantine/notifications';
interface Props {
children: React.ReactNode;
@@ -14,7 +16,9 @@ export default function Providers(props: Props) {
- {props.children}
+
+ {props.children}
+
diff --git a/src/webapp/providers/mantine.tsx b/src/webapp/providers/mantine.tsx
index fcefc633..d2a3da85 100644
--- a/src/webapp/providers/mantine.tsx
+++ b/src/webapp/providers/mantine.tsx
@@ -3,11 +3,18 @@
import { theme } from '@/styles/theme';
import { MantineProvider as BaseProvider } from '@mantine/core';
import '@mantine/core/styles.css';
+import { Notifications } from '@mantine/notifications';
+import '@mantine/notifications/styles.css';
interface Props {
children: React.ReactNode;
}
export default function MantineProvider(props: Props) {
- return {props.children};
+ return (
+
+
+ {props.children}
+
+ );
}