Merge branch '22-integrate-api-and-frontend' into 7-add-page-to-view-dataset-api

This commit is contained in:
Elias Schriefer
2024-07-05 17:18:09 +02:00
27 changed files with 710 additions and 329 deletions

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DataDash – Add dataset/API</title>
<link rel="stylesheet" href="add.css">
<script type="module" src="add.js"></script>
</head>
<body>
<main>
<header>
<h1>Add new DataDash content</h1>
</header>
<section>
<p>
Please add the following information about your dataset/API to
help users better find it and understand what it is about and
how to use it.
</p>
<form>
<span>
<label for="title">Title</label>
<input type="text" name="title" id="title" minlength="1" maxlength="50" required>
</span>
<span id="author-box">
<label for="author">Author</label>
<input type="text" name="author" id="author" required>
</span>
<label>
Dataset
<input type="checkbox" name="is-dataset" id="is-dataset">
<span id="is-dataset-toggle"></span>
API
</label>
<span>
<label for="short-description">Short description</label>
<input type="text" name="short-description" id="short-description" maxlength="100" required spellcheck="true">
</span>
<span>
<label for="url">URL</label>
<input type="url" name="url" id="url" required>
</span>
<span id="full-description-box">
<label for="full-description">Full description</label>
<textarea name="full-description" id="full-description" spellcheck="true"></textarea>
</span>
<span id="btn-bar">
<input type="submit" value="Add" id="btn-add" class="btn suggested" disabled>
<input type="reset" value="Cancel" id="btn-cancel" class="btn">
</span>
</form>
</section>
</main>
</body>
</html>

View File

@@ -1,25 +1,38 @@
import {searchBarTimeout, searchSection} from "./main.js"
// TODO consider renaming this to "searchUtility.js"
import { searchBarTimeout, searchSection, lastQuery } from "./main.js"
import Dataset from "./dataset.js"
export function fetchQuery(fetchString) {
export async function fetchQuery(fetchString, clearResults) {
clearTimeout(searchBarTimeout);
fetch(fetchString)
.then(resp => resp.json())
.then((data) => {
parseContent(data.content);
});
const response = await fetch(fetchString);
const data = await response.json();
parseContent(data.content, clearResults);
lastQuery.totalPages = data.totalPages;
if (clearResults) {
lastQuery.currentPage = 0;
}
}
function parseContent(content) {
function parseContent(content, clearResults) {
const nothingFoundElement = searchSection.querySelector("#nothing-found");
if (content.length === 0) {
searchSection.querySelector("#nothing-found ").classList.remove("hidden");
nothingFoundElement.classList.remove("hidden");
searchSection.querySelector(".datasets").classList.add("hidden");
} else {
searchSection.querySelector("#nothing-found").classList.add("hidden");
const datasets = content.map(dataset => new Dataset(dataset));
Array.from(searchSection.querySelectorAll(".datasets .dataset")).forEach(e => e.remove());
nothingFoundElement.classList.add("hidden");
const datasets = content.map(dataset => Dataset.get(dataset.id) ?? new Dataset(dataset));
searchSection.querySelector(".datasets").classList.remove("hidden");
if (clearResults) {
Array.from(searchSection.querySelectorAll(".datasets .dataset")).forEach(e => e.remove());
}
for (const dataset of datasets) {
searchSection.querySelector(".datasets").appendChild(dataset.createDatasetHTMLElement());
searchSection.querySelector(".datasets")
.appendChild(dataset.createDatasetHTMLElement());
}
}
}

View File

@@ -1,6 +1,8 @@
import { vote } from "./main.js";
import { DATASET_ENDPOINT, getBaseURL } from "./main.js";
export default class Dataset {
static #datasets = new Map();
#abstract;
#author;
#categories;
@@ -13,8 +15,13 @@ export default class Dataset {
#upvotes;
#url;
#votes;
#elements = [];
constructor({abst: shortDescription, author, categories, date, description, id, rating, title, type, upvotes, url, votes}) {
static get(id) {
return this.#datasets.get(id);
}
constructor({ abst: shortDescription, author, categories, date, description, id, rating, title, type, upvotes, url, votes }) {
this.#abstract = shortDescription;
this.#author = author;
this.#categories = categories;
@@ -27,25 +34,179 @@ export default class Dataset {
this.#upvotes = upvotes;
this.#url = url;
this.#votes = votes;
Dataset.#datasets.set(id, this);
}
// Getters
get abstract() {
return this.#abstract;
}
get author() {
return this.#author;
}
get categories() {
return this.#categories;
}
get date() {
return this.#date;
}
get description() {
return this.#description;
}
get id() {
return this.#id;
}
get rating() {
return this.#rating;
}
get title() {
return this.#title;
}
get type() {
return this.#type;
}
get upvotes() {
return this.#upvotes;
}
get url() {
return this.#url;
}
get votes() {
return this.#votes;
}
get mainElement() {
return this.#elements[0];
}
get elements() {
return this.#elements;
}
// Main methods
// Only on main page
createDatasetHTMLElement() {
let template = document.querySelector("#dataset-template");
const clone = template.content.cloneNode(true);
const clone = this.#createTemplateInstance("dataset-template");
if (clone == null) {
return null;
}
clone.querySelector(".dataset").dataset.id = this.#id;
clone.querySelector("h3").innerText = this.#title;
clone.querySelector("p").innerText = this.#description;
clone.querySelector("span").innerText = this.#upvotes;
clone.querySelector(".dataset-title").innerText = this.#title;
clone.querySelector(".dataset-description").innerText = this.#description;
clone.querySelector(".upvote-count").innerText = this.#upvotes;
// Event Listeners
clone.querySelector(".upvote-btn").addEventListener("click", () => {
vote(this.#id, true);
});
clone.querySelector(".downvote-btn").addEventListener("click", () => {
vote(this.#id, false);
})
this.#elements.push(clone.children[0]);
this.#createUpvoteButtons(clone);
return clone;
}
// Only on details page
createUpvoteComponent() {
let clone = this.#createTemplateInstance("voting-template")
clone.querySelector(".upvote-count").innerText = this.#upvotes;
this.#elements.push(clone.children[0]);
this.#createUpvoteButtons(clone);
return clone;
}
#createUpvoteButtons(templateInstance) {
if (this.storageGetKey("is-voted", false)) {
// The template instance (clone) has to be pushed before this can work
this.#disableVoteBtns();
}
// Event Listeners
templateInstance.querySelector(".upvote-btn").addEventListener("click", () => {
this.vote();
});
templateInstance.querySelector(".downvote-btn").addEventListener("click", () => {
this.vote(false);
})
}
#createTemplateInstance(templateId) {
let template = document.getElementById(templateId);
if (template == null) {
return null;
}
return template.content.cloneNode(true);
}
isMainElement(element) {
return this.#elements.length > 0 && this.#elements[0].isEqualNode(element);
}
async vote(up = true) {
const fetchURL = new URL(
`${DATASET_ENDPOINT}/id/${this.#id}/${up ? "up" : "down"}vote`,
getBaseURL(),
);
const response = await fetch(fetchURL, {
method: "PUT",
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
this.#upvotes = data.upvotes;
for (const element of this.#elements) {
element.querySelector(".upvote-count").innerText = this.#upvotes;
}
this.storageSetKey("vote-type", up ? "upvote" : "downvote");
this.storageSetKey("is-voted", true);
this.#disableVoteBtns();
}
#disableVoteBtns() {
if (this.#elements.length > 0) {
const voteType = this.storageGetKey("vote-type", null);
this.#elements.flatMap(e => Array.from(e.querySelectorAll(".upvote .btn")))
.forEach(btn => {
btn.disabled = true;
if (btn.classList.contains(`${voteType}-btn`)) {
btn.classList.add("isVoted");
}
});
}
}
storageGet() {
const json = localStorage.getItem(this.#id);
return json != null ? JSON.parse(json) : {};
}
storageSetKey(key, value) {
let currentStorage = this.storageGet();
currentStorage[key] = value;
localStorage.setItem(this.#id, JSON.stringify(currentStorage));
}
storageGetKey(key, defaultValue) {
return this.storageGet()?.[key] ?? defaultValue;
}
}

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DataDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="prefetch" href="https://fonts.googleapis.com/css2?family=Flow+Circular&display=swap">
<link rel="stylesheet" href="main.css">
<script type="module" src="main.js" defer></script>
</head>
<body>
<div id="add-btn" title="Add a new API entry"></div>
<main>
<header>
<h1>Welcome to DataDash</h1>
<p>The place to launch and discover datasets and API endpoints.</p>
</header>
<template id="dataset-template">
<li class="dataset" data-id="id">
<div class="dataset-info">
<div class="details">
<h3 class="dataset-title">title</h3>
<p class="dataset-description">Simply daily accountability phone call, powered by AI</p>
</div>
</div>
<aside class="upvote">
<button class="upvote-btn btn flat">Upvote</button>
<span class="upvote-count">0</span>
<button class="downvote-btn btn flat">Downvote</button>
</aside>
</li>
</template>
<section id="tool-bar">
<button class="btn flat" id="reset-tools-btn" title="Reset search results">Reset</button>
<select id="sort-btn" class="btn flat" title="Sort entries">Sort by
<option id="default-sort">Date newest-oldest</option>
<option>Date oldest-newest</option>
<option>Author A-Z</option>
<option>Author Z-A</option>
<option>Title A-Z</option>
<option>Title Z-A</option>
<option>Stars ↑</option>
<option>Stars ↓</option>
<option>Upvotes ↑</option>
<option>Upvotes ↓</option>
</select>
<div class="divider"></div>
<select class="btn flat" id="filter-btn" title="Filter entries">Filter
<option id="default-filter">None</option>
<optgroup label="Standard categories">
<option>Dataset</option>
<option>API</option>
</optgroup>
<optgroup id="other-categories" label="Other categories">
</optgroup>
</select>
<input type="search" name="query" id="search-entry" placeholder="Search">
<button class="btn flat" id="search-btn" title="Search entries">Search</button>
</section>
<section id="recents">
<h2>Recently added:</h2>
<ul class="datasets">
</ul>
</section>
<section id="top">
<h2>Most Liked:</h2>
<ul class="datasets">
</ul>
</section>
<section id="search" class="hidden">
<h2>Search results:</h2>
<div id="nothing-found">
<div id="nothing-found-bg"></div>
<h3 id="nothing-found-text">Nothing found</h3>
</div>
<ul class="datasets">
</ul>
<div id="observable" style="height: 2rem"></div>
</section>
</main>
</body>
</html>

View File

@@ -83,9 +83,6 @@ header {
text-align: center;
}
.hidden {
display: none;
}
#search-entry:focus-visible {
outline: none;
}
@@ -97,6 +94,11 @@ header {
gap: var(--gap-medium);
}
.hidden {
display: none;
}
@container (width < 60ch) {
.datasets {
grid-template-columns: 1fr;
@@ -122,6 +124,7 @@ header {
align-items: center;
gap: var(--gap-small);
}
/* Buttons */
.upvote-btn, .downvote-btn, #search-btn, #filter-btn, #sort-btn, #reset-tools-btn {
background: var(--icon-url) no-repeat;
@@ -148,6 +151,7 @@ header {
--icon-url: url(looking-glass.svg);
--icon-size: 1rem;
}
#filter-btn {
--icon-url: url(filter.svg);
--icon-size: 1rem;
@@ -163,6 +167,10 @@ header {
--icon-size: 1rem;
}
.isVoted {
filter: brightness(1.75);
}
.divider {
width: .05rem;
height: 1rem;

View File

@@ -1,14 +1,15 @@
import {fetchQuery} from "./contentUtility.js";
import { fetchQuery } from "./contentUtility.js";
import Dataset from "./dataset.js";
const apiEndpoint = "/api/v1/datasets";
const baseURL = location.origin;
export const DATASET_ENDPOINT = "/api/v1/datasets";
export const getBaseURL = () => location.origin;
const defaultPagingValue = 20;
const lastQuery = {
url: "",
export const lastQuery = {
totalPages: 0,
currentPage: 0
currentPage: 0,
};
const loadedCategories = new Set;
// definition of all buttons & sections
const addButton = document.getElementById("add-btn");
@@ -33,33 +34,34 @@ addButton.addEventListener("click", () => {
});
filterButton.addEventListener("change", () => {
const filterString = filterButton.value;
if (filterString !== filterButton.querySelector("#default-filter").value) {
fetchQuery(createQuery());
}
fetchQuery(createQuery(), true);
});
filterButton.addEventListener("click", () => {
fetchCategories();
})
searchButton.addEventListener("click", () => {
fetchQuery(createQuery());
fetchQuery(createQuery(), true);
});
searchBar.addEventListener("input", () => {
updateSections();
clearTimeout(searchBarTimeout);
searchBarTimeout = setTimeout(() => {
fetchQuery(createQuery());
fetchQuery(createQuery(), true);
updateSections();
}, searchDelay);
});
searchBar.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
fetchQuery(createQuery());
fetchQuery(createQuery(), true);
}
});
sortButton.addEventListener("change", () => {
fetchQuery(createQuery());
fetchQuery(createQuery(), true);
});
resetButton.addEventListener("click", () => {
@@ -69,37 +71,19 @@ resetButton.addEventListener("click", () => {
updateSections();
});
// Consider moving this to datasets.js completely
const upvoteButtonClickListener = e => {
const entryID = e.target.parentElement.parentElement.dataset.id;
vote(entryID, true);
};
for (const upvoteButton of upvoteButtons) {
upvoteButton.addEventListener("click", upvoteButtonClickListener);
}
// Consider moving this to datasets.js completely
const downvoteButtonClickListener = e => {
const entryID = e.target.parentElement.parentElement.dataset.id;
vote(entryID, false);
};
for (const downvoteButton of downvoteButtons) {
downvoteButton.addEventListener("click", downvoteButtonClickListener);
}
// functions of the main page
function navigateToAdd() {
window.location.href = "/add";
window.location.href = "/add.html"; //TODO: move to EventListener?
}
function getFilterQuery() {
let filterString= filterButton.value.toUpperCase();
let filterString = filterButton.value.toUpperCase();
if (filterString === "NONE") {
return ["type", "%"]
} else if (document.querySelector('#filter-btn option:checked').parentElement.label === "Standard categories") {
return ["type", filterString];
} else {
return ["category", filterString];
return ["category", filterButton.options[filterButton.selectedIndex].value]
}
}
@@ -121,39 +105,20 @@ function getSortQuery() {
// creates query for the whole toolbar, so that searching, sorting and filtering are always combined
function createQuery() {
updateSections();
let queryURL = new URL(apiEndpoint + "/search", baseURL);
let queryURL = new URL(DATASET_ENDPOINT + "/search", getBaseURL());
queryURL.searchParams.append("search", getSearchQuery());
let filterQuery = getFilterQuery();
queryURL.searchParams.append(filterQuery[0], filterQuery[1]);
let sortQuery = getSortQuery();
queryURL.searchParams.append("sort", sortQuery[0]);
queryURL.searchParams.append("direction", sortQuery[1]);
queryURL.searchParams.append("size", defaultPagingValue.toString(10));
return queryURL;
}
export function vote(entryID, up) {
const fetchURL = new URL(
`${apiEndpoint}/id/${entryID}/${up ? "up" : "down"}vote`,
baseURL,
);
fetch(fetchURL, {
method: "PUT",
headers: {
'Content-Type': 'application/json',
}})
.then(resp => resp.json())
.then((data) => {
console.log(data.upvotes); // TODO: remove, check einbauen: data.id === entryID?
let dataset = document.querySelector('[data-id= ' + CSS.escape(entryID) + ']')
dataset.querySelector("span").innerText = data.upvotes;
});
}
function incrementPageCount() {
lastQuery.currentPage++;
}
// updates the page display. If no query is present, the initial page is shown, otherwise the search results.
function updateSections() {
if (searchBar.value === "" && sortButton.value === sortButton.querySelector("#default-sort").value
@@ -172,44 +137,47 @@ function updateSections() {
// fetches the further categories used in the filter function
function fetchCategories() {
const fetchURL = new URL(
"api/v1/categories" , baseURL);
const fetchURL = new URL("api/v1/categories", getBaseURL());
fetch(fetchURL)
.then(resp => resp.json())
.then((data) => {
for (let i = 0; i < data.length; i++) {
let category = data[i].toLowerCase();
category = category.charAt(0).toUpperCase() + category.slice(1);
document.getElementById("other-categories").appendChild(new Option(category));
let categoryName = data[i].name.toLowerCase();
categoryName = categoryName.charAt(0).toUpperCase() + categoryName.slice(1);
if (!loadedCategories.has(categoryName)) {
let newCategory = new Option(categoryName, data[i].id);
document.getElementById("other-categories").appendChild(newCategory);
loadedCategories.add(categoryName);
}
}
});
}
// fetches entries for the initial page
function fetchInitialEntries() {
let recentsQueryURL = new URL(apiEndpoint + "/search", baseURL);
async function fetchInitialEntries() {
let recentsQueryURL = new URL(DATASET_ENDPOINT + "/search", getBaseURL());
recentsQueryURL.searchParams.append("sort", "date");
recentsQueryURL.searchParams.append("direction", "desc");
recentsQueryURL.searchParams.append("size", "6");
fetch(recentsQueryURL)
.then(resp => resp.json())
.then((data) => {
const datasets = data.content.map(dataset => new Dataset(dataset));
for (const dataset of datasets) {
document.querySelector("#recents .datasets").appendChild(dataset.createDatasetHTMLElement());
}
});
let topVotedQueryURL = new URL(apiEndpoint + "/search", baseURL);
const recentsResponse = await fetch(recentsQueryURL);
const recentsData = await recentsResponse.json();
const recentsDatasets = recentsData.content.map(dataset => new Dataset(dataset));
for (const recentDataset of recentsDatasets) {
document.querySelector("#recents .datasets").appendChild(recentDataset.createDatasetHTMLElement());
}
let topVotedQueryURL = new URL(DATASET_ENDPOINT + "/search", getBaseURL());
topVotedQueryURL.searchParams.append("sort", "upvotes");
topVotedQueryURL.searchParams.append("direction", "desc");
topVotedQueryURL.searchParams.append("size", "1");
fetch(topVotedQueryURL)
.then(resp => resp.json())
.then((data) => {
document.querySelector("#top .datasets")
.appendChild(new Dataset(data.content[0]).createDatasetHTMLElement());
});
const topVotedResponse = await fetch(topVotedQueryURL);
const topVotedData = await topVotedResponse.json();
const topVotedDataset = new Dataset(topVotedData.content[0]);
document.querySelector("#top .datasets").appendChild(topVotedDataset.createDatasetHTMLElement());
}
window.onload = function () {
@@ -217,6 +185,21 @@ window.onload = function () {
fetchInitialEntries();
updateSections();
if (searchBar.value !== "") {
fetchQuery(createQuery());
fetchQuery(createQuery(), true);
}
let observer = new IntersectionObserver(() => {
if (!searchSection.classList.contains("hidden")) {
fetchPagingResults();
}
}, {root: null, rootMargin: "0px", threshold: .9});
observer.observe(document.getElementById("observable"));
}
function fetchPagingResults() {
lastQuery.currentPage++
if (lastQuery.currentPage < lastQuery.totalPages) {
let pagingQuery = new URL(createQuery());
pagingQuery.searchParams.append("page", lastQuery.currentPage.toString(10));
fetchQuery(pagingQuery, false);
}
}