Repository: nebari-dev/jupyterlab-gallery

Issue:
Improve the design of the gallery tiles
The gallery tiles do not look great yet:
- when the icons are of varied sizes, the tiles are of different dimensions
- the buttons are taking up a lot of space

One idea to solve the second point is to show the buttons on hover:

![buttons-on-hover](https://github.com/nebari-dev/jupyterlab-gallery/assets/5832902/27daa434-7936-4147-87b8-2a4413e985fd)


Discussion:
github-actions[bot]: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/nebari-dev/jupyterlab-gallery/improve-tile?urlpath=lab) :point_left: Launch a Binder on branch _nebari-dev/jupyterlab-gallery/improve-tile_

Patch:
diff --git a/jupyterlab_gallery/handlers.py b/jupyterlab_gallery/handlers.py
index ab11de8..c02c568 100644
--- a/jupyterlab_gallery/handlers.py
+++ b/jupyterlab_gallery/handlers.py
@@ -27,6 +27,7 @@ class GalleryHandler(BaseHandler):
                 {
                     "title": self.gallery_manager.title,
                     "exhibitsConfigured": len(self.gallery_manager.exhibits) != 0,
+                    "hideGalleryWithoutExhibits": self.gallery_manager.hide_gallery_without_exhibits,
                     "apiVersion": "1.0",
                 }
             )
@@ -63,7 +64,12 @@ class PullHandler(BaseHandler, SyncHandlerBase):
     async def post(self):
         data = self.get_json_body()
         exhibit_id = data["exhibit_id"]
-        exhibit = self.gallery_manager.exhibits[exhibit_id]
+        try:
+            exhibit = self.gallery_manager.exhibits[exhibit_id]
+        except IndexError:
+            self.set_status(406)
+            self.finish(json.dumps({"message": f"exhibit_id {exhibit_id} not found"}))
+            return
         return await super()._pull(
             repo=exhibit["git"],
             exhibit_id=exhibit_id,
diff --git a/jupyterlab_gallery/manager.py b/jupyterlab_gallery/manager.py
index c7dece6..0f3298d 100644
--- a/jupyterlab_gallery/manager.py
+++ b/jupyterlab_gallery/manager.py
@@ -5,7 +5,7 @@ from typing import Optional
 from threading import Thread
 
 from traitlets.config.configurable import LoggingConfigurable
-from traitlets import Dict, List, Unicode
+from traitlets import Dict, List, Unicode, Bool
 
 from .git_utils import (
     extract_repository_owner,
@@ -69,6 +69,12 @@ class GalleryManager(LoggingConfigurable):
         config=True,
     )
 
+    hide_gallery_without_exhibits = Bool(
+        help="Hide Gallery if no exhibits are configured",
+        default_value=False,
+        config=True
+    )
+
     def get_local_path(self, exhibit) -> Path:
         clone_destination = Path(self.destination)
         repository_name = extract_repository_name(exhibit["git"])
diff --git a/src/gallery.tsx b/src/gallery.tsx
index c069877..5848664 100644
--- a/src/gallery.tsx
+++ b/src/gallery.tsx
@@ -53,9 +53,16 @@ export class GalleryWidget extends ReactWidget {
           };
           this._stream.connect(promiseResolver);
         });
+        const xsrfTokenMatch = document.cookie.match('\\b_xsrf=([^;]*)\\b');
+        const args: Record<string, string | number> = {
+          exhibit_id: exhibit.id
+        };
+        if (xsrfTokenMatch) {
+          args['_xsrf'] = xsrfTokenMatch[1];
+        }
         await requestAPI('pull', {
           method: 'POST',
-          body: JSON.stringify({ exhibit_id: exhibit.id })
+          body: JSON.stringify(args)
         });
         await done;
       }
@@ -224,75 +231,23 @@ function Exhibit(props: {
   return (
     <div className="jp-Exhibit">
       <h4 className="jp-Exhibit-title">{exhibit.title}</h4>
-      <div className="jp-Exhibit-icon">
-        <img src={exhibit.icon} alt={exhibit.title} />
-      </div>
-      <div className="jp-Exhibit-description">{exhibit.description}</div>
-      {progress ? (
-        <div
-          className={
-            'jp-Exhibit-progressbar' +
-            (progress.state === 'error' ? ' jp-Exhibit-progressbar-error' : '')
-          }
-        >
-          <div
-            className="jp-Exhibit-progressbar-filler"
-            style={{ width: progress.progress * 100 + '%' }}
-          ></div>
-          <div className="jp-Exhibit-progressMessage">{progressMessage}</div>
+      <div className="jp-Exhibit-middle">
+        <div className="jp-Exhibit-icon">
+          <img src={exhibit.icon} alt={exhibit.title} />
         </div>
-      ) : null}
-      <div className="jp-Exhibit-buttons">
-        {!exhibit.isCloned ? (
-          <Button
-            title={props.trans.__('Set up')}
-            onClick={async () => {
-              setProgressMessage('Downloading');
-              setProgress({
-                progress: 0.0,
-                message: 'Initializing'
-              });
-              try {
-                await actions.download(exhibit);
-              } catch {
-                setProgress({
-                  ...(progress as any),
-                  state: 'error'
-                });
-                setProgressMessage('');
-              }
-            }}
-          >
-            <downloadIcon.react />
-          </Button>
-        ) : (
-          <>
+        <div className="jp-Exhibit-buttons">
+          {!exhibit.isCloned ? (
             <Button
               minimal={true}
-              title={props.trans.__('Open')}
-              onClick={() => {
-                actions.open(exhibit);
-              }}
-            >
-              <folderIcon.react />
-            </Button>
-            <Button
-              disabled={!exhibit.updatesAvailable}
-              minimal={true}
-              title={
-                updateStatusKnown
-                  ? props.trans.__('Fetch latest changes')
-                  : props.trans.__('Checking upstream status')
-              }
+              title={props.trans.__('Set up')}
               onClick={async () => {
-                setProgressMessage('Refreshing');
+                setProgressMessage('Downloading');
                 setProgress({
-                  progress: 0.25,
-                  message: 'Refreshing'
+                  progress: 0.0,
+                  message: 'Initializing'
                 });
                 try {
                   await actions.download(exhibit);
-                  setProgress(null);
                 } catch {
                   setProgress({
                     ...(progress as any),
@@ -302,14 +257,73 @@ function Exhibit(props: {
                 }
               }}
             >
-              {updateStatusKnown ? (
-                <downloadIcon.react />
-              ) : (
-                <refreshIcon.react className="jp-spinningIcon" />
-              )}
+              <downloadIcon.react />
             </Button>
-          </>
-        )}
+          ) : (
+            <>
+              <Button
+                minimal={true}
+                title={props.trans.__('Open')}
+                onClick={() => {
+                  actions.open(exhibit);
+                }}
+              >
+                <folderIcon.react />
+              </Button>
+              <Button
+                disabled={!exhibit.updatesAvailable}
+                minimal={true}
+                title={
+                  updateStatusKnown
+                    ? props.trans.__('Fetch latest changes')
+                    : props.trans.__('Checking upstream status')
+                }
+                onClick={async () => {
+                  setProgressMessage('Refreshing');
+                  setProgress({
+                    progress: 0.25,
+                    message: 'Refreshing'
+                  });
+                  try {
+                    await actions.download(exhibit);
+                    setProgress(null);
+                  } catch {
+                    setProgress({
+                      ...(progress as any),
+                      state: 'error'
+                    });
+                    setProgressMessage('');
+                  }
+                }}
+              >
+                {updateStatusKnown ? (
+                  <downloadIcon.react />
+                ) : (
+                  <refreshIcon.react className="jp-spinningIcon" />
+                )}
+              </Button>
+            </>
+          )}
+        </div>
+      </div>
+      <div className="jp-Exhibit-bottom">
+        {progress ? (
+          <div
+            className={
+              'jp-Exhibit-progressbar' +
+              (progress.state === 'error'
+                ? ' jp-Exhibit-progressbar-error'
+                : '')
+            }
+          >
+            <div
+              className="jp-Exhibit-progressbar-filler"
+              style={{ width: progress.progress * 100 + '%' }}
+            ></div>
+            <div className="jp-Exhibit-progressMessage">{progressMessage}</div>
+          </div>
+        ) : null}
+        <div className="jp-Exhibit-description">{exhibit.description}</div>
       </div>
     </div>
   );
diff --git a/src/handler.ts b/src/handler.ts
index 7df8492..8b3b43e 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -70,11 +70,17 @@ export function eventStream(
   onError: (error: Event) => void
 ): EventSource {
   const settings = ServerConnection.makeSettings();
-  const requestUrl = URLExt.join(
+  let requestUrl = URLExt.join(
     settings.baseUrl,
     'jupyterlab-gallery', // API Namespace
     endPoint
   );
+  const xsrfTokenMatch = document.cookie.match('\\b_xsrf=([^;]*)\\b');
+  if (xsrfTokenMatch) {
+    const fullUrl = new URL(requestUrl);
+    fullUrl.searchParams.append('_xsrf', xsrfTokenMatch[1]);
+    requestUrl = fullUrl.toString();
+  }
   const eventSource = new EventSource(requestUrl);
   eventSource.addEventListener('message', event => {
     const data = JSON.parse(event.data);
diff --git a/src/index.ts b/src/index.ts
index c9dd2e6..27ed4d6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -61,10 +61,17 @@ const plugin: JupyterFrontEndPlugin<void> = {
         `jupyter-gallery API version out of sync, expected ${expectedVersion}, got ${data.apiVersion}`
       );
     }
-
     const title = data.title === 'Gallery' ? trans.__('Gallery') : data.title;
+
+    // hide the widget if no exhibits are configured
+    if (data.hideGalleryWithoutExhibits && !data.exhibitsConfigured) {
+      console.log(
+        'Gallery extension will not add any UI elements because no exhibits are configured'
+      );
+      return;
+    }
     // add the widget to sidebar before waiting for server reply to reduce UI jitter
-    if (launcher && isNewLauncher(launcher) && data.exhibitsConfigured) {
+    if (launcher && isNewLauncher(launcher)) {
       launcher.addSection({
         title,
         className: 'jp-Launcher-openExample',
diff --git a/src/types.ts b/src/types.ts
index 628f0fc..8bc67bc 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -2,6 +2,7 @@ export interface IGalleryReply {
   title: string;
   apiVersion: string;
   exhibitsConfigured: boolean;
+  hideGalleryWithoutExhibits: boolean;
 }
 
 export interface IExhibitReply {
diff --git a/style/base.css b/style/base.css
index 7ea7952..c2caa89 100644
--- a/style/base.css
+++ b/style/base.css
@@ -3,12 +3,29 @@
 
     https://jupyterlab.readthedocs.io/en/stable/developer/css.html
 */
+.jp-Gallery {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(100px, 200px));
+  grid-auto-rows: 1fr;
+  justify-content: center;
+}
+
 .jp-Exhibit {
+  --jp-exhibit-bottom-reserved: 2.6lh;
+  --jp-exhibit-bottom-collapsed: 2.3lh;
+  --jp-exhibit-bottom-expanded: 3.2lh;
+
   border: 1px solid var(--jp-border-color1);
   border-radius: 4px;
-  margin: 12px;
+  margin: 6px;
   padding: 4px 8px;
   max-width: 200px;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding-bottom: var(--jp-exhibit-bottom-reserved);
+  min-width: 150px;
 }
 
 .jp-Exhibit-title {
@@ -17,8 +34,8 @@
 
 .jp-Exhibit-description {
   padding: 2px;
-  height: 2.15em;
-  overflow: hidden;
+  min-height: 1.6lh;
+  cursor: default;
 }
 
 .jp-Exhibit-icon > img {
@@ -29,10 +46,17 @@
   display: flex;
   justify-content: center;
   align-items: center;
+  height: 100%;
+}
+
+.jp-Exhibit-middle {
+  flex-grow: 1;
+  position: relative;
+  margin: 4px 0;
 }
 
 .jp-Launcher-openExample .jp-Gallery {
-  display: contents;
+  width: 100%;
 }
 
 .jp-Exhibit-progressbar-filler {
@@ -45,7 +69,6 @@
   border: 1px solid var(--jp-layout-color3);
   background: var(--jp-layout-color2);
   width: 100%;
-  margin-bottom: 10px;
   border-radius: 2px;
   position: relative;
 }
@@ -65,7 +88,62 @@
   text-overflow: ellipsis;
 }
 
-.jp-Exhibit-buttons .jp-Button:disabled {
+.jp-Exhibit-buttons {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  display: flex;
+  opacity: 0;
+  box-sizing: border-box;
+  transition-property: opacity;
+  transition-duration: 200ms;
+  border-radius: inherit;
+  padding: 0 40px;
+}
+
+.jp-Exhibit .jp-Exhibit-icon {
+  transition-property: filter;
+  transition-duration: 200ms;
+}
+
+.jp-Exhibit:hover .jp-Exhibit-icon {
+  filter: blur(10px);
+}
+
+.jp-Exhibit:hover .jp-Exhibit-buttons {
+  opacity: 1;
+}
+
+.jp-Exhibit-bottom {
+  position: absolute;
+  transition-property: max-height;
+  transition-duration: 200ms;
+  max-height: var(--jp-exhibit-bottom-collapsed);
+  bottom: 0;
+  margin-left: -8px;
+  padding: 0 4px;
+}
+
+.jp-Exhibit:hover .jp-Exhibit-bottom {
+  max-height: var(--jp-exhibit-bottom-expanded);
+}
+
+.jp-Exhibit-buttons > .jp-Button {
+  margin: auto;
+  outline: 2px solid transparent;
+  background: var(--jp-layout-color2);
+  border: 1px solid var(--jp-border-color1);
+  height: 35px;
+  width: 35px;
+}
+
+.jp-Exhibit-buttons > .jp-Button:hover {
+  background: var(--jp-layout-color0);
+}
+
+.jp-Exhibit-buttons > .jp-Button:disabled {
   opacity: 0.3;
 }
 


Test patch:
diff --git a/jupyterlab_gallery/tests/test_handlers.py b/jupyterlab_gallery/tests/test_handlers.py
index 6403dae..fe63342 100644
--- a/jupyterlab_gallery/tests/test_handlers.py
+++ b/jupyterlab_gallery/tests/test_handlers.py
@@ -1,5 +1,7 @@
 import json
 
+from jupyter_server.utils import url_path_join
+
 
 async def test_exhibits(jp_fetch):
     response = await jp_fetch("jupyterlab-gallery", "exhibits")
@@ -13,3 +15,20 @@ async def test_gallery(jp_fetch):
     assert response.code == 200
     payload = json.loads(response.body)
     assert payload["apiVersion"] == "1.0"
+
+
+async def test_pull_token_can_be_used_instead_of_xsrf(jp_serverapp, jp_base_url, http_server_client):
+    token = jp_serverapp.identity_provider.token
+    response = await http_server_client.fetch(
+        url_path_join(jp_base_url, "jupyterlab-gallery", "pull"),
+        body=b'{"exhibit_id": 100}',
+        method="POST",
+        headers={
+            "Authorization": f"token {token}",
+            "Cookie": ""
+        },
+        raise_error=False,
+    )
+    assert response.code == 406
+    payload = json.loads(response.body)
+    assert payload["message"] == "exhibit_id 100 not found"