diff --git a/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx b/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
new file mode 100644
index 00000000..8e6b4e44
--- /dev/null
+++ b/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
@@ -0,0 +1,140 @@
+import {
+ Button,
+ Container,
+ Drawer,
+ Grid,
+ Group,
+ Stack,
+ Textarea,
+ TextInput,
+} from '@mantine/core';
+import { useForm } from '@mantine/form';
+
+import { notifications } from '@mantine/notifications';
+import useAddCard from '../../lib/mutations/useAddCard';
+import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector';
+import { Suspense, useState } from 'react';
+import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector';
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ selectedCollection?: { id: string; name: string; description?: string };
+}
+
+export default function AddCardDrawer(props: Props) {
+ const initialCollections = props.selectedCollection
+ ? [props.selectedCollection]
+ : [];
+ const [selectedCollections, setSelectedCollections] =
+ useState<{ id: string; name: string; description?: string }[]>(
+ initialCollections,
+ );
+ const addCard = useAddCard();
+
+ const form = useForm({
+ initialValues: {
+ url: '',
+ note: '',
+ collections: initialCollections,
+ },
+ });
+
+ const handleCreateCollection = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ addCard.mutate(
+ {
+ url: form.getValues().url,
+ note: form.getValues().note,
+ collectionIds: selectedCollections.map((c) => c.id),
+ },
+ {
+ onSuccess: () => {
+ notifications.show({
+ message: 'Added card.',
+ });
+ props.onClose();
+ },
+ onError: () => {
+ notifications.show({
+ message: 'Could not add card.',
+ });
+ },
+ onSettled: () => {
+ form.reset();
+ },
+ },
+ );
+ };
+
+ return (
+
+
+
+ New Card
+
+
+
+
+
+
+ );
+}
diff --git a/src/webapp/features/cards/lib/mutations/useAddCard.tsx b/src/webapp/features/cards/lib/mutations/useAddCard.tsx
new file mode 100644
index 00000000..3b340910
--- /dev/null
+++ b/src/webapp/features/cards/lib/mutations/useAddCard.tsx
@@ -0,0 +1,35 @@
+import { ApiClient } from '@/api-client/ApiClient';
+import { getAccessToken } from '@/services/auth';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+export default function useAddCard() {
+ const apiClient = new ApiClient(
+ process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
+ () => getAccessToken(),
+ );
+
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: (newCard: {
+ url: string;
+ note?: string;
+ collectionIds?: string[];
+ }) => {
+ return apiClient.addUrlToLibrary({
+ url: newCard.url,
+ note: newCard.note,
+ collectionIds: newCard.collectionIds,
+ });
+ },
+
+ // 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: ['my cards'] });
+ },
+ });
+
+ return mutation;
+}
diff --git a/src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx b/src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
new file mode 100644
index 00000000..c991a0df
--- /dev/null
+++ b/src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
@@ -0,0 +1,107 @@
+import { ScrollArea, Stack, Tabs } from '@mantine/core';
+import useCollections from '../../lib/queries/useCollections';
+import CollectionSelectorError from './Error.CollectionSelector';
+import CollectionSelectorItem from '../collectionSelectorItem/CollectionSelectorItem';
+
+interface Props {
+ selectedCollections: { id: string; name: string; description?: string }[];
+ onSelectedCollectionsChange: (
+ collectionIds: { id: string; name: string; description?: string }[],
+ ) => void;
+}
+
+export default function CollectionSelector(props: Props) {
+ const { data, error } = useCollections();
+
+ if (error) {
+ return ;
+ }
+
+ return (
+
+ {/* setSearch(e.currentTarget.value)}
+ size="sm"
+ variant="filled"
+ id="search"
+ label="Collections"
+ /> */}
+
+
+ Recent Collections
+
+ Selected ({props.selectedCollections.length})
+
+
+
+
+
+
+ {data.collections.map((c) => (
+ col.id === c.id)
+ }
+ onChange={(checked, item) => {
+ if (checked) {
+ // Add the item if it's not already selected
+ if (
+ !props.selectedCollections.some(
+ (col) => col.id === item.id,
+ )
+ ) {
+ props.onSelectedCollectionsChange([
+ ...props.selectedCollections,
+ item,
+ ]);
+ }
+ } else {
+ // Remove the item
+ props.onSelectedCollectionsChange(
+ props.selectedCollections.filter(
+ (col) => col.id !== item.id,
+ ),
+ );
+ }
+ }}
+ />
+ ))}
+
+
+
+
+
+
+ {props.selectedCollections.map((c) => (
+ {
+ if (!checked) {
+ // Deselect the item
+ props.onSelectedCollectionsChange(
+ props.selectedCollections.filter(
+ (col) => col.id !== item.id,
+ ),
+ );
+ }
+ // If re-selecting in "selected" tab, do nothing
+ }}
+ />
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/webapp/features/collections/components/collectionSelector/Error.CollectionSelector.tsx b/src/webapp/features/collections/components/collectionSelector/Error.CollectionSelector.tsx
new file mode 100644
index 00000000..c4143db8
--- /dev/null
+++ b/src/webapp/features/collections/components/collectionSelector/Error.CollectionSelector.tsx
@@ -0,0 +1,5 @@
+import { Alert } from '@mantine/core';
+
+export default function CollectionSelectorError() {
+ return ;
+}
diff --git a/src/webapp/features/collections/components/collectionSelector/Skeleton.CollectionSelector.tsx b/src/webapp/features/collections/components/collectionSelector/Skeleton.CollectionSelector.tsx
new file mode 100644
index 00000000..084d4e7a
--- /dev/null
+++ b/src/webapp/features/collections/components/collectionSelector/Skeleton.CollectionSelector.tsx
@@ -0,0 +1,13 @@
+import { Loader, Skeleton, Stack } from '@mantine/core';
+
+export default function CollectionSelectorSkeleton() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.module.css b/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.module.css
new file mode 100644
index 00000000..113c027f
--- /dev/null
+++ b/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.module.css
@@ -0,0 +1,15 @@
+.root {
+ border-width: 1px;
+ border-style: solid;
+ border-color: var(--mantine-color-default-border);
+ border-radius: var(--mantine-radius-lg);
+
+ &:hover {
+ background-color: var(--mantine-color-gray-light);
+ }
+
+ &[data-checked] {
+ border-color: var(--mantine-color-blue-6);
+ background-color: var(--mantine-color-blue-light);
+ }
+}
diff --git a/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx b/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx
new file mode 100644
index 00000000..b2306e47
--- /dev/null
+++ b/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx
@@ -0,0 +1,52 @@
+import {
+ CheckboxCard,
+ CheckboxIndicator,
+ Group,
+ Stack,
+ Text,
+} from '@mantine/core';
+import classes from './CollectionSelectorItem.module.css';
+
+interface Props {
+ value: string;
+ name: string;
+ description?: string;
+ checked: boolean;
+ onChange: (
+ checked: boolean,
+ item: { id: string; name: string; description?: string },
+ ) => void;
+}
+
+export default function CollectionSelectorItem(props: Props) {
+ return (
+
+ props.onChange(checked, {
+ id: props.value,
+ name: props.name,
+ description: props.description,
+ })
+ }
+ >
+
+
+
+ {props.name}
+
+ {props.description && (
+
+ {props.description}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx b/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
index 1a7c7c73..fb993173 100644
--- a/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
+++ b/src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
@@ -73,8 +73,7 @@ export default function ComposerDrawer() {
py={0}
>
drawers.open({ drawer: 'addCard' })}
p={0}
style={{ cursor: 'pointer' }}
>
diff --git a/src/webapp/providers/drawers.tsx b/src/webapp/providers/drawers.tsx
index 58a08f4c..60caea14 100644
--- a/src/webapp/providers/drawers.tsx
+++ b/src/webapp/providers/drawers.tsx
@@ -1,3 +1,4 @@
+import AddCardDrawer from '@/features/cards/components/addCardDrawer/AddCardDrawer';
import CreateCollectionDrawer from '@/features/collections/components/createCollectionDrawer/CreateCollectionDrawer';
import React, {
@@ -8,10 +9,11 @@ import React, {
ReactNode,
} from 'react';
-type DrawerName = 'createCollection';
+type DrawerName = 'createCollection' | 'addCard';
type DrawerPropsMap = {
createCollection: {};
+ addCard: {};
};
interface DrawerState {
@@ -79,6 +81,11 @@ export const DrawersProvider = ({ children }: { children: ReactNode }) => {
onClose={close}
{...state.innerProps}
/>
+
);
};
diff --git a/src/webapp/styles/theme.tsx b/src/webapp/styles/theme.tsx
index 341d83fd..7e7b8dad 100644
--- a/src/webapp/styles/theme.tsx
+++ b/src/webapp/styles/theme.tsx
@@ -4,6 +4,9 @@ import {
Anchor,
Avatar,
Button,
+ Checkbox,
+ CheckboxCard,
+ CheckboxIndicator,
createTheme,
MenuItem,
NavLink,
@@ -91,5 +94,10 @@ export const theme = createTheme({
fw: 600,
},
}),
+ CheckboxIndicator: CheckboxIndicator.extend({
+ defaultProps: {
+ radius: 'xl',
+ },
+ }),
},
});