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>