From a8a5c87ef761f92821d49681e3d32fddec2c98e3 Mon Sep 17 00:00:00 2001
From: Yo'av Moshe <github@yoavmoshe.com>
Date: Sat, 30 Nov 2024 22:01:33 +0100
Subject: [PATCH] new grid view

we now show results in a grid, and we immediately show what the DB
contains after choosing it. it's also possible to see all items by
language now, or make an empty search
---
 app.vue                       | 158 ++++++++++++++++++++++------------
 components/AdvancedSearch.vue |  15 ++--
 components/BookCard.vue       | 109 +++++++++++++++++++++++
 components/BooksList.vue      |  95 +++-----------------
 components/Header.vue         |  10 ++-
 components/Settings.vue       |  39 +++++++--
 6 files changed, 270 insertions(+), 156 deletions(-)
 create mode 100644 components/BookCard.vue

diff --git a/app.vue b/app.vue
index e8a82a1..8ad349e 100644
--- a/app.vue
+++ b/app.vue
@@ -10,10 +10,11 @@
       @dragover.prevent="isDragging = true"
       @dragleave.prevent="isDragging = false"
       @drop.prevent="handleDrop"
+      @scroll="fetchMoreBooks"
       :class="{ 'drag-over': isDragging }"
     >
       <AdvancedSearch v-if="view === 'search'" :onFetchResults="fetchResults" />
-      <Settings v-if="view === 'settings'" />
+      <Settings v-if="view === 'settings'" :onFetchResults="fetchResults" />
       <Instances v-if="view === 'instances'" />
 
       <Error
@@ -35,7 +36,7 @@
         :isLoading="isLoading"
         :onBookClick="handleClick"
         :paginate="true"
-        :onFetchResults="fetchResults"
+        :onFetchMoreBooks="fetchMoreBooks"
         v-if="view === 'results'"
       />
       <BooksList
@@ -49,22 +50,17 @@
       </div>
     </div>
     <footer>
-      <span
-        >{{ title }} is an instance of
-        <a href="https://github.com/teatime-library/teatime" target="_blank"
-          >TeaTime</a
-        >, a distributed, static, cookie-less system for reading books over
-        IPFS. There are also
-        <a href="#" @click="() => (view = 'instances')">other instances</a>.
-      </span>
+      {{ title }} is an instance of
+      <a href="https://github.com/teatime-library/teatime" target="_blank"
+        >TeaTime</a
+      >, a distributed, static, cookie-less system for reading books over IPFS.
+      There are also
+      <a href="#" @click="() => (view = 'instances')">other instances</a>.
     </footer>
   </main>
 </template>
 
 <style>
-dialog {
-  top: 30%;
-}
 * {
   font-family: sans-serif;
 }
@@ -72,6 +68,9 @@ body,
 html {
   margin: 0;
 }
+</style>
+
+<style scoped>
 main {
   display: grid;
   grid-template-columns: 1fr;
@@ -113,15 +112,6 @@ footer {
   color: gray;
   grid-area: 3 / 1 / 4 / 2;
 }
-
-@keyframes move-stripes {
-  0% {
-    background-position: 0 0;
-  }
-  100% {
-    background-position: 100% 0; /* Adjust as needed for the speed */
-  }
-}
 </style>
 
 <script setup lang="ts">
@@ -162,6 +152,7 @@ const remoteConfig = useLocalStorage("remoteConfig", null);
 const ipfsGateway = useLocalStorage("ipfsGateway", "ipfs.io");
 
 const searchQuery = useState("searchQuery", () => "");
+const offset = useState("offset", () => 0);
 const isLoading = useState("isLoading", () => false);
 const directLink = useState("directLink", () => false);
 const view = useState("view", () =>
@@ -171,6 +162,7 @@ const darkMode = useState("darkMode", () => false);
 const bookURL = useState("bookURL", () => "");
 const title = useState("title", () => appConfig.title);
 const icon = useState("icon", () => appConfig.icon);
+const moreResults = useState("moreResults", () => false);
 
 const results = ref([]);
 const lastResult = ref(null);
@@ -185,16 +177,19 @@ const booksList = computed(() => {
     : results;
 });
 
-const setResults = (books) => {
+const setResults = (books, append) => {
   const { images = "", columns = {} } = JSON.parse(remoteConfig.value);
-  results.value = books.map((b) => {
-    for (const key of Object.keys(columns)) {
-      b[key] = b[columns[key]];
-      delete b[columns[key]];
-    }
-    b.image_url = liquid.parseAndRenderSync(images, b);
-    return b;
-  });
+  results.value = [
+    ...(append ? results.value : []),
+    ...books.map((b) => {
+      for (const key of Object.keys(columns)) {
+        b[key] = b[columns[key]];
+        delete b[columns[key]];
+      }
+      b.image_url = liquid.parseAndRenderSync(images, b);
+      return b;
+    }),
+  ];
 };
 const downloadBook = () => {
   const link = document.createElement("a");
@@ -217,23 +212,33 @@ const handleDrop = (event) => {
   }
 };
 
-const fetchResults = async (query, offset = 0) => {
+const fetchResults = async (query, reset, setRemote) => {
+  console.log("Running query", query, setRemote);
   lastQuery.value = JSON.stringify(query);
+  if (setRemote) {
+    const [owner, repo] = setRemote.split("/");
+    const response = await fetch(
+      `https://${owner}.github.io/${repo}/config.json`,
+    );
+    const config = await response.json();
+    remoteConfig.value = JSON.stringify(config);
+    remote.value = setRemote;
+    offset.value = 0;
+  }
+  if (reset) offset.value = 0;
   if (!remote.value) {
     console.log("you have to choose a remote");
     view.value = "settings";
     return;
   }
-  let complexSearch = false;
   if (view === "search") {
-    complexSearch.value = true;
     searchQuery.value = "";
   }
 
   view.value = "results";
 
   isLoading.value = true;
-  results.value = [];
+  if (!offset.value) results.value = [];
   let dbResult = [];
 
   let maxBytesToRead = 10 * 1024 * 1024;
@@ -258,41 +263,60 @@ const fetchResults = async (query, offset = 0) => {
       maxBytesToRead, // optional, defaults to Infinity
     );
 
-    const perPage = 10;
-    const limitOffset = `LIMIT ${perPage * offset}, ${perPage}`;
+    const perPage = 5;
+    const limitOffset = `LIMIT ${perPage * offset.value}, ${perPage}`;
 
     console.log("Making the query...");
-    if (query.length > 3) {
-      dbResult = await worker.db.exec(
-        `SELECT * FROM ${tableName}
+    if (typeof query === "string") {
+      if (query) {
+        dbResult = await worker.db.exec(
+          `SELECT * FROM ${tableName}
           WHERE id IN (
             SELECT rowid FROM ${tableName}_fts
             WHERE ${tableName}_fts MATCH ?
             ${limitOffset}
           );`,
-        [`${columns.title}:${query}* OR ${columns.author}:${query}*`],
-      );
+          [`${columns.title}:${query}* OR ${columns.author}:${query}*`],
+        );
+      } else {
+        dbResult = await worker.db.exec(
+          `SELECT * FROM ${tableName}
+            ${limitOffset}
+          ;`,
+          [],
+        );
+      }
     } else {
+      const whereQueries = [];
       const params = [];
-      if (query.title && query.author)
+      if (query.title && query.author) {
+        whereQueries.push(`${tableName}_fts MATCH ?`);
         params.push(
           `${columns.title}:${query.title} AND ${columns.author}:${query.author}`,
         );
-      else if (query.author) params.push(`${columns.author}:${query.author}`);
-      else params.push(`${columns.title}:${query.title}`); // we must have either title or author
+      } else if (query.author) {
+        whereQueries.push(`${tableName}_fts MATCH ?`);
+        params.push(`${columns.author}:${query.author}`);
+      } else if (query.title) {
+        whereQueries.push(`${tableName}_fts MATCH ?`);
+        params.push(`${columns.title}:${query.title}`);
+      }
 
-      if (query.lang) params.push(query.lang);
-      if (query.ext) params.push(query.ext);
-      dbResult = await worker.db.exec(
-        `
+      if (query.lang) {
+        whereQueries.push(columns.lang + " = ?");
+        params.push(query.lang);
+      }
+      if (query.ext) {
+        whereQueries.push(columns.ext + " = ?");
+        params.push(query.ext);
+      }
+      const where = params.join(" AND ");
+      const sqlQuery = `
           SELECT ${tableName}.* FROM ${tableName}
           JOIN ${tableName}_fts ON ${tableName}.id = ${tableName}_fts.rowid
-          WHERE ${tableName}_fts MATCH ?
-          ${query.lang ? ` AND ${columns.lang} = ? ` : ""}
-          ${query.ext ? ` AND ${columns.ext} = ? ` : ""}
-          ${limitOffset};`,
-        params,
-      );
+          WHERE ${whereQueries.join(" AND ")}
+          ${limitOffset};`;
+      dbResult = await worker.db.exec(sqlQuery, params);
     }
 
     console.log("Gathering results...");
@@ -304,12 +328,36 @@ const fetchResults = async (query, offset = 0) => {
           ...dbResult[0].columns.map((n, index) => ({ [n]: line[index] })),
         ),
       ),
+      !!offset.value,
     );
   } catch (e) {
     console.error("Error while searching: ", e);
   } finally {
     isLoading.value = false;
   }
+
+  if (dbResult[0].values.length) {
+    moreResults.value = true;
+  } else {
+    moreResults.value = false;
+  }
+
+  fetchMoreBooks();
+};
+
+const fetchMoreBooks = () => {
+  const obj = document.querySelector("#content");
+
+  if (
+    (obj.scrollTopMax === 0 || // There's no scroll
+      obj.scrollTop + 1 >= obj.scrollHeight - obj.offsetHeight) && // We're at the bottom
+    !isLoading.value &&
+    view.value === "results" &&
+    moreResults.value
+  ) {
+    offset.value++;
+    fetchResults(JSON.parse(lastQuery.value));
+  }
 };
 
 const downloadUrls = (urls: string[], contentLength: number): Promise<Blob> => {
diff --git a/components/AdvancedSearch.vue b/components/AdvancedSearch.vue
index 6b3f8cb..40ca0d4 100644
--- a/components/AdvancedSearch.vue
+++ b/components/AdvancedSearch.vue
@@ -129,11 +129,14 @@ const csExt = ref("");
 const csLang = ref("");
 
 const makeAdvancedSearch = () => {
-  props.onFetchResults({
-    title: csTitle.value,
-    author: csAuthor.value,
-    ext: csExt.value,
-    lang: csLang.value,
-  });
+  props.onFetchResults(
+    {
+      title: csTitle.value,
+      author: csAuthor.value,
+      ext: csExt.value,
+      lang: csLang.value,
+    },
+    true,
+  );
 };
 </script>
diff --git a/components/BookCard.vue b/components/BookCard.vue
new file mode 100644
index 0000000..f60e63d
--- /dev/null
+++ b/components/BookCard.vue
@@ -0,0 +1,109 @@
+<template>
+  <li class="book">
+    <div
+      class="titles"
+      :style="'background-image: url(' + book.image_url + ')'"
+    >
+      <h2 :title="book.title">
+        <span>{{ book.title }}</span>
+      </h2>
+      <h3 v-if="book.author || book.year">
+        <span
+          >{{ book.author
+          }}<template v-if="book.author && book.year">, </template
+          >{{ book.year }}</span
+        >
+      </h3>
+    </div>
+    <footer
+      :style="`background: linear-gradient(to right, #9f9 ${(book.fraction || 0) * 100}%, #ebebeb ${(book.fraction || 0) * 100}%);`"
+    >
+      <span class="ext"
+        ><LucideFile :size="12" /> {{ book.ext?.toUpperCase() }}</span
+      >
+      <span class="ext"><LucideLanguages :size="12" />{{ book.lang }}</span>
+      <span v-if="book.size" class="ext"
+        ><LucideArrowBigDownDash :size="12" />
+        {{ prettyBytes(book.size) }}</span
+      >
+    </footer>
+  </li>
+</template>
+
+<style scoped>
+footer {
+  display: flex;
+}
+
+main.dark {
+  .book {
+    filter: invert(1);
+  }
+}
+
+.book {
+  width: 15rem;
+  margin: 1rem;
+  border: 1px solid lightgrey;
+  box-shadow: 2px 2px 0.3rem #eee;
+  margin-bottom: 1rem;
+  list-style-type: none;
+
+  .titles {
+    height: 20rem;
+    display: flex;
+    transition: 0.1s all ease;
+    background-size: cover;
+    flex-direction: column;
+    justify-content: flex-end;
+    padding-bottom: 1rem;
+
+    h2,
+    h3 {
+      margin: 0;
+      font-size: 1rem;
+      display: inline-block;
+      span {
+        background: rgba(255, 255, 255, 0.95);
+        padding: 0 0.4rem;
+        box-decoration-break: clone;
+      }
+    }
+
+    h3 {
+      font-size: 0.8rem;
+    }
+  }
+
+  &:hover {
+    cursor: pointer;
+
+    .titles {
+      h2,
+      h3 {
+        display: none;
+      }
+    }
+  }
+}
+
+span.ext {
+  font-size: 0.8rem;
+  padding: 0 0.5rem;
+  text-wrap: nowrap;
+  flex-grow: 1;
+  svg {
+    margin-right: 0.3rem;
+  }
+}
+</style>
+
+<script setup>
+import prettyBytes from "pretty-bytes";
+defineProps({
+  book: {
+    type: Object,
+    required: true,
+  },
+});
+</script>
diff --git a/components/BooksList.vue b/components/BooksList.vue
index c7f489c..3c9af4c 100644
--- a/components/BooksList.vue
+++ b/components/BooksList.vue
@@ -1,41 +1,15 @@
 <template>
   <div id="results">
     <ul v-if="booksList.length">
-      <li
+      <BookCard
         v-for="(result, index) in booksList"
         :key="index"
+        :book="result"
         @click="onBookClick(result)"
-      >
-        <img
-          v-if="result.image_url"
-          :src="result.image_url"
-          referrerpolicy="no-referrer"
-        />
-        <div>
-          <h2 :title="result.title">{{ result.title }}</h2>
-          <h3>{{ result.author }}, {{ result.year }}</h3>
-          <span class="ext"><LucideFile :size="12" /> {{ result.ext }}</span>
-          <span class="ext"
-            ><LucideLanguages :size="12" />{{ result.lang }}</span
-          >
-          <span v-if="result.size" class="ext"
-            ><LucideArrowBigDownDash :size="12" />
-            {{ prettyBytes(result.size) }}</span
-          >
-          <meter min="0" max="1" :value="result.fraction" />
-        </div>
-      </li>
+      />
     </ul>
-    <p v-else-if="isLoading">Searching {{ remote }}...</p>
-    <p v-else>No results</p>
-    <button
-      v-if="booksList.length && paginate"
-      @click="
-        () => onFetchResults(JSON.parse(lastQuery), offset + 1) && offset++
-      "
-    >
-      More
-    </button>
+    <p v-if="isLoading">Searching {{ remote }}...</p>
+    <p v-if="!isLoading && !booksList.length">No results</p>
   </div>
 </template>
 
@@ -43,51 +17,9 @@
 ul {
   margin: auto;
   padding: 0;
-  max-width: 45rem;
-
-  li {
-    display: flex;
-    padding: 1rem;
-    img {
-      width: 4.5rem;
-      margin-right: 1rem;
-      object-fit: cover;
-      height: 8.5rem;
-    }
-
-    h2 {
-      margin: 0;
-      font-size: 1.2rem;
-      @media (min-width: 600px) {
-        font-size: 1.5rem;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        margin: 0;
-        overflow: hidden;
-        width: 35rem;
-      }
-    }
-
-    h3 {
-      font-size: 1rem;
-      @media (min-width: 600px) {
-        font-size: 1.2rem;
-      }
-    }
-    list-style-type: none;
-    border: 1px solid lightgrey;
-    box-shadow: 2px 2px 0.3rem #eee;
-    border-radius: 1rem;
-    margin-bottom: 1rem;
-
-    &:hover {
-      background: rgba(245, 245, 245);
-      cursor: pointer;
-    }
-    meter {
-      width: 100%;
-    }
-  }
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+  gap: 1rem;
 }
 
 button {
@@ -106,7 +38,6 @@ p {
   font-weight: bold;
   color: lightgray;
 }
-
 span.ext {
   background: #ebebeb;
   font-size: 0.8rem;
@@ -142,17 +73,11 @@ defineProps({
     type: Boolean,
     required: false,
   },
-  onFetchResults: {
+  onFetchMoreBooks: {
     type: Function,
-    required: false,
+    required: true,
   },
 });
 
 const remote = useLocalStorage("remote", null);
-const lastQuery = useLocalStorage("lastQuery", null);
-const offset = useState("offset", () => 0);
-
-onMounted(async () => {
-  offset.value = 0;
-});
 </script>
diff --git a/components/Header.vue b/components/Header.vue
index 0429dad..5b0575a 100644
--- a/components/Header.vue
+++ b/components/Header.vue
@@ -122,6 +122,14 @@ header {
     }
   }
 }
+@keyframes move-stripes {
+  0% {
+    background-position: 0 0;
+  }
+  100% {
+    background-position: 100% 0; /* Adjust as needed for the speed */
+  }
+}
 </style>
 <script setup>
 const props = defineProps({
@@ -144,7 +152,7 @@ const title = useState("title");
 const bookURL = useState("bookURL");
 
 const search = (event) => {
-  if (searchQuery.value.length > 3) props.onFetchResults(searchQuery.value);
+  props.onFetchResults(searchQuery.value, true);
 };
 
 const setFullScreen = () => {
diff --git a/components/Settings.vue b/components/Settings.vue
index 9a35f8d..ed3363b 100644
--- a/components/Settings.vue
+++ b/components/Settings.vue
@@ -40,7 +40,7 @@
             {{ item.description }}
           </td>
           <td>
-            {{ item.updated_at.slice(0, 10) }}
+            {{ item.updated_at?.slice(0, 10) }}
           </td>
           <td class="right">
             <a :href="'https://github.com/' + item.full_name" target="_blank">
@@ -128,6 +128,13 @@
 import { useLocalStorage } from "@vueuse/core";
 const appConfig = useAppConfig();
 
+const props = defineProps({
+  onFetchResults: {
+    type: Function,
+    required: true,
+  },
+});
+
 const showAllRemotes = ref(false);
 const remotes = ref([]);
 const remote = useLocalStorage("remote");
@@ -142,7 +149,27 @@ const updateRemotes = async () => {
     "https://api.github.com/search/repositories?q=topic:teatime-database&sort=stars&order=desc",
     // "/repos.json", // localdev
   );
-  const { items } = await response.json();
+  let { items } = await response.json();
+  // if (!items.length) {
+  //   items = [
+  //     {
+  //       full_name: "yourmargin/libgen-db",
+  //       description:
+  //         "Library Genesis Non-Fiction, metadata snapshot from archive.org",
+  //       stargazers_count: 10,
+  //     },
+  //     {
+  //       full_name: "bjesus/teatime-datase",
+  //       description: "Public domain library",
+  //       stargazers_count: 5,
+  //     },
+  //     {
+  //       full_name: "bjesus/teatime-json-datase",
+  //       description: "An example database with The Communist Manifesto",
+  //       stargazers_count: 1,
+  //     },
+  //   ];
+  // }
   remotes.value = items;
 };
 
@@ -151,12 +178,6 @@ onMounted(async () => {
 });
 
 const setRemote = async (selection) => {
-  const [owner, repo] = selection.full_name.split("/");
-  const response = await fetch(
-    `https://${owner}.github.io/${repo}/config.json`,
-  );
-  const config = await response.json();
-  remoteConfig.value = JSON.stringify(config);
-  remote.value = selection.full_name;
+  props.onFetchResults("", true, selection.full_name);
 };
 </script>