feat: add search functionality

this commit adds long awaited search feature

fix https://github.com/zyachel/quetre/issues/21
This commit is contained in:
zyachel 2022-10-31 01:05:02 +05:30
parent 86df58367a
commit 1cdafe0380
16 changed files with 546 additions and 27 deletions

View file

@ -186,7 +186,7 @@ From [their privacy policy](https://www.quora.com/about/privacy)
## To-Do
- [ ] add missing routes like topics, profile, and search
- [x] add missing routes like topics, profile, and search
- [ ] use redis
- [x] serve images and other assets from Quetre
- [x] implement a better installation method

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
////////////////////////////////////////////////////////
// IMPORTS
////////////////////////////////////////////////////////
@ -6,6 +7,7 @@ import catchAsyncErrors from '../utils/catchAsyncErrors.js';
import getAnswers from '../fetchers/getAnswers.js';
import getTopic from '../fetchers/getTopic.js';
import getProfile from '../fetchers/getProfile.js';
import getSearch from '../fetchers/getSearch.js';
////////////////////////////////////////////////////////
// EXPORTS
@ -33,6 +35,15 @@ export const profile = catchAsyncErrors(async (req, res, next) => {
res.status(200).json({ status: 'success', data });
});
export const search = catchAsyncErrors(async (req, res, next) => {
const searchText = req.urlObj.searchParams.get('q')?.trim(); // no search to perform if there isn't any query
let searchData = null;
if (searchText) searchData = await getSearch(req.urlObj.search);
res.status(200).json({ status: 'success', data: searchData });
});
export const unimplemented = (req, res, next) => {
res.status(501).json({
status: 'fail',

View file

@ -7,6 +7,7 @@ import getAnswers from '../fetchers/getAnswers.js';
import getTopic from '../fetchers/getTopic.js';
import { nonSlugRoutes } from '../utils/constants.js';
import getProfile from '../fetchers/getProfile.js';
import getSearch from '../fetchers/getSearch.js';
////////////////////////////////////////////////////////
// EXPORTS
@ -83,20 +84,37 @@ export const profile = catchAsyncErrors(async (req, res, next) => {
});
});
export const unimplemented = (req, res, next) => {
const message =
"This route isn't yet implemented. Check back sometime later!";
res.status(501).render('error', {
data: {
statusCode: 501,
message,
export const search = catchAsyncErrors(async (req, res, next) => {
const searchText = req.urlObj.searchParams.get('q')?.trim();
let searchData = null;
if (searchText) searchData = await getSearch(req.urlObj.search);
res.status(200).render('search', {
data: searchData,
meta: {
title: searchText || 'Search',
url: req.urlObj.href,
imageUrl: `${req.urlObj.origin}/icon.svg`,
description: searchText ? `results for '${searchText}'` : 'search page',
},
});
});
export const unimplemented = (req, res, next) => {
const data = {
message: "This route isn't yet implemented. Check back sometime later!",
statusCode: 501,
};
res.status(data.statusCode).render('error', {
data,
meta: {
title: 'Not yet implemented',
url: `${req.urlObj.origin}${req.urlObj.pathname}`,
imageUrl: `${req.urlObj.origin}/icon.svg`,
urlObj: req.urlObj,
description: message,
description: data.message,
},
});
};

165
fetchers/getSearch.js Normal file
View file

@ -0,0 +1,165 @@
////////////////////////////////////////////////////////
// IMPORTS
////////////////////////////////////////////////////////
import AppError from '../utils/AppError.js';
import fetcher from './fetcher.js';
////////////////////////////////////////////////////////
// HELPER FUNCTIONS
////////////////////////////////////////////////////////
const topicCleaner = topic => ({
type: 'topic',
url: topic.url,
name: topic.name,
numFollowers: topic.numFollowers,
image: topic.photoUrl,
isSensitive: topic.isSensitive,
});
const spaceCleaner = space => ({
type: 'space',
numUsers: space.tribeUserCount,
url: space.url,
name: space.nameString,
description: space.descriptionString,
image: space.iconRetinaUrl,
isSensitive: space.isSensitive,
});
const profileCleaner = profile => ({
type: 'profile',
credential: profile.bestCredential?.translatedString,
isAnon: profile.isAnon,
name: `${profile.names[0]?.givenName} ${profile.names[0]?.familyName}`,
url: profile.profileUrl,
image: profile.profileImageUrl,
numFollowers: profile.followerCount,
isVerified: profile.isVerified,
isBusiness: profile.businessStatus,
isPlusUser: profile.consumerBundleActive,
});
const questionCleaner = question => ({
type: 'question',
text: JSON.parse(question.title).sections,
url: question.url,
isDeleted: question.isDeleted,
numFollowers: question.followerCount,
creationTime: question.creationTime,
numComments: question.numDisplayComments,
isSensitive: question.isSensitive,
});
const answerCleaner = ({ question, previewAnswer: answer }) => ({
type: 'answer',
question: {
...questionCleaner(question),
},
...(answer.originalQuestionIfDifferent && {
originalQuestion: {
text: JSON.parse(answer.originalQuestionIfDifferent.question.title)
.sections,
url: answer.originalQuestionIfDifferent.question.url,
qid: answer.originalQuestionIfDifferent.question.qid,
},
}),
isViewable: !!answer.viewerHasAccess,
text: JSON.parse(answer.content).sections,
creationTime: answer.creationTime,
updatedTime: answer.updatedTime,
numComments: answer.numDisplayComments,
numUpvotes: answer.numUpvotes,
numViews: answer.numViews,
numShares: answer.numSharers,
numAnswerRequests: answer.numRequesters,
isBusinessAnswer: answer.businessAnswer,
url: answer.url,
isSensitive: answer.isSensitive,
author: {
uid: answer.author.uid,
isAnon: answer.author.isAnon,
image: answer.author.profileImageUrl,
isVerified: answer.author.isVerified,
isPlusUser: answer.author.consumerBundleActive,
url: answer.author.profileUrl,
name: `${answer.author.names[0].givenName} ${answer.author.names[0].familyName}`,
credential: answer.authorCredential?.translatedString,
},
});
const postCleaner = post => ({
type: 'post',
pid: post.pid,
isViewable: post.viewerHasAccess,
url: post.url,
title: JSON.parse(post.title).sections,
isDeleted: post.isDeleted,
isSensitive: post.isSensitive,
text: JSON.parse(post.content).sections,
creationTime: post.creationTime,
updatedTime: post.updatedTime,
numComments: post.numDisplayComments,
numUpvotes: post.numUpvotes,
numViews: post.numViews,
numShares: post.numSharers,
author: {
uid: post.author.uid,
isAnon: post.author.isAnon,
image: post.author.profileImageUrl,
isVerified: post.author.isVerified,
isPlusUser: post.author.consumerBundleActive,
url: post.author.profileUrl,
name: `${post.author.names[0].givenName} ${post.author.names[0].familyName}`,
credential: post.authorCredential?.translatedString,
},
...(post.tribeItem && {
space: {
isSensitive: post.tribeItem.tribe.isSensitive,
name: post.tribeItem.tribe.nameString,
url: post.tribeItem.tribe.url,
image: post.tribeItem.tribe.iconRetinaUrl,
description: post.tribeItem.descriptionString,
numFollowers: post.tribeItem.tribe.numFollowers,
},
}),
});
const resultsCleaner = results => {
const cleanedResults = results.map(result => {
const resultToClean = result.node;
if (resultToClean.topic) return topicCleaner(resultToClean.topic);
if (resultToClean.tribe) return spaceCleaner(resultToClean.tribe);
if (resultToClean.post) return postCleaner(resultToClean.post);
if (resultToClean.user) return profileCleaner(resultToClean.user);
if (resultToClean.previewAnswer) return answerCleaner(resultToClean);
if (resultToClean.question) return questionCleaner(resultToClean.question);
return {};
});
return cleanedResults;
};
////////////////////////////////////////////////////////
// FUNCTION
////////////////////////////////////////////////////////
const getSearch = async querySlug => {
const res = await fetcher(`search/${querySlug}`, 'searchConnection', false);
const {
data: { searchConnection: rawData },
} = JSON.parse(res);
if (!rawData)
throw new AppError(
"Search couldn't be done. Recheck the URL, or resend the request if you believe the URL is correct.",
404
);
const data = {
results: resultsCleaner(rawData.edges),
};
return data;
};
////////////////////////////////////////////////////////
// EXPORTS
////////////////////////////////////////////////////////
export default getSearch;

View file

@ -1,4 +1,6 @@
<svg width="0" height="0" class="hidden">
<!-- TODO: readd links to fontawesome svg icons. They're ones that don't have viewBox of '0 0 24 24' -->
<!--main logo-->
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 189.91 130.7" id="icon-logo">
<g transform="translate(-6.7989 -97.001)">
@ -111,6 +113,12 @@
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-quoraplus">
<path d="M12,1L9,9L1,12L9,15L12,23L15,15L23,12L15,9L12,1Z"></path>
</symbol>
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-search">
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>
</symbol>
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-cross">
<path d="M13.46,12L19,17.54V19H17.54L12,13.46L6.46,19H5V17.54L10.54,12L5,6.46V5H6.46L12,10.54L17.54,5H19V6.46L13.46,12Z"></path>
</symbol>
<!-- not yet used -->
<symbol aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-cancel">

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

@ -6,12 +6,13 @@ import {
topic,
image,
profile,
search,
} from '../controllers/apiController.js';
const apiRouter = express.Router();
apiRouter.get('/', about);
apiRouter.get('/search', unimplemented);
apiRouter.get('/(|search)', search);
apiRouter.get('/about', about);
apiRouter.get('/image/:domain/:path', image);
apiRouter.get('/profile/:name', profile);
apiRouter.get('/topic/:slug', topic);

View file

@ -6,13 +6,14 @@ import {
topic,
unimplemented,
profile,
search,
} from '../controllers/viewController.js';
const viewRouter = express.Router();
viewRouter.get('/', about);
viewRouter.get('/(|search)', search); // search on / or /search
viewRouter.get('/about', about);
viewRouter.get('/privacy', privacy);
viewRouter.get('/search', unimplemented);
viewRouter.get('/profile/:name', profile);
viewRouter.get('/topic/:slug', topic);
viewRouter.get('/unanswered/:slug', answers);

View file

@ -3,19 +3,19 @@
//-//////////////////////////////////////////////////////
footer.footer(class=`${meta.title ==='About' ? 'footer__about' : ''}`)
block footer
//- EXTRA STUFF GOES HERE IF THE PAGE IS ABOUT PAGE
//- NAVIGATION
nav.footer__nav-box(aria-label='Primary navigation')
ul.footer__nav
- if (meta.title !=='About')
li.footer__nav-item: a.footer__nav-link.footer__link(href="/") About
li.footer__nav-item: a.footer__nav-link.footer__link(href="/about") About
li.footer__nav-item: a.footer__nav-link.footer__link(href="/search") Search
li.footer__nav-item: a.footer__nav-link.footer__link(href="https://github.com/zyachel/quetre") Source Code
li.footer__nav-item: a.footer__nav-link.footer__link(href="/privacy") Privacy
li.footer__nav-item: a.footer__nav-link.footer__link(href="#") Back to top
//- LICENSE
p.footer__license Licensed under&nbsp;
a.footer__link(href="https://www.gnu.org/licenses/agpl-3.0.html") GNU AGPLv3

View file

@ -7,11 +7,14 @@ header.header(class=`${meta.title === 'About' ? 'header__about': ''}`)
a.header__link.header__logo(href='/') Quetre
//- for nav on about page
block header__nav
//- BUTTON FOR CHANGING THEME
button.button.theme-changer.header__theme(aria-label='Change Theme')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--sun: use(href='/misc/sprite.svg#icon-sun')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--moon: use(href='/misc/sprite.svg#icon-moon')
.header__misc
a.link.header__search(href="/search", aria-label='search page')
svg.icon: use(href='/misc/sprite.svg#icon-search')
button.button.theme-changer.header__theme(aria-label='Change Theme')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--sun: use(href='/misc/sprite.svg#icon-sun')
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--moon: use(href='/misc/sprite.svg#icon-moon')
//- IF THE PAGE IS ABOUT PAGE, THE BLOCK BELOW WILL GET POPULATED
block header__info

110
views/pug/pages/search.pug Normal file
View file

@ -0,0 +1,110 @@
//-//////////////////////////////////////////////////////
//- INCLUDES/EXTENDS
//-//////////////////////////////////////////////////////
extends ../base
include ../mixins/_formatText
include ../mixins/_utils
include ../mixins/_answer
include ../mixins/_question
include ../mixins/_post
include ../mixins/_metadata
//-//////////////////////////////////////////////////////
//- MAIN CONTENT
//-//////////////////////////////////////////////////////
block content
main#main(class=`main search ${data ? '' :'search--no-results'}`)
form.search__form.search-form(action="/search", method='get', autocomplete='off', name='search')
.search-form__search-container
input.search-form__searchbar(type="search", name="q", placeholder='Enter your query...', minlength='3', aria-label='search for anything')
button.search-form__button.search-form__button--reset(type="reset", aria-label='clear searchbar and filters'): svg.icon: use(href='/misc/sprite.svg#icon-cross')
button.search-form__button.search-form__button--submit(type="submit", aria-label='search'): svg.icon: use(href='/misc/sprite.svg#icon-search')
.search-form__filters-container.search-form__filters-container--type
p.search-form__filters-heading Filter by Type
//- .search-form__filters-group
input#type--all.search-form__radio.visually-hidden(type="radio", name="type", value='', checked)
label.search-form__label(for="type--all") All
.search-form__filters-group
input#type--question.search-form__radio.visually-hidden(type="radio", name="type", value='question')
label.search-form__label(for="type--question") Questions
.search-form__filters-group
input#type--answer.search-form__radio.visually-hidden(type="radio", name="type", value='answer' )
label.search-form__label(for="type--answer") Answers
.search-form__filters-group
input#type--post.search-form__radio.visually-hidden(type="radio", name="type", value='post')
label.search-form__label(for="type--post") Posts
.search-form__filters-group
input#type--profile.search-form__radio.visually-hidden(type="radio", name="type", value='profile')
label.search-form__label(for="type--profile") Profiles
.search-form__filters-group
input#type--topic.search-form__radio.visually-hidden(type="radio", name="type", value='topic')
label.search-form__label(for="type--topic") Topics
.search-form__filters-group
input#type--space.search-form__radio.visually-hidden(type="radio", name="type", value='tribe')
label.search-form__label(for="type--space") Spaces
.search-form__filters-container.search-form__filters-container--time
p.search-form__filters-heading Filter by Time
//- .search-form__filters-group
input#time--all.search-form__radio.visually-hidden(type="radio", name='time', value='', checked)
label.search-form__label(for="time--all") All
.search-form__filters-group
input#time--hour.search-form__radio.visually-hidden(type="radio", name='time', value='hour')
label.search-form__label(for="time--hour") Hour
.search-form__filters-group
input#time--day.search-form__radio.visually-hidden(type="radio", name='time', value='day' )
label.search-form__label(for="time--day") Day
.search-form__filters-group
input#time--week.search-form__radio.visually-hidden(type="radio", name='time', value='week')
label.search-form__label(for="time--week") Week
.search-form__filters-group
input#time--month.search-form__radio.visually-hidden(type="radio", name='time', value='month')
label.search-form__label(for="time--month") Month
.search-form__filters-group
input#time--year.search-form__radio.visually-hidden(type="radio", name='time', value='year')
label.search-form__label(for="time--year") Year
//- TODO: refactor 'profile', 'topic', and 'space' into resusable mixins.
- if (data?.results)
section.search__results.search-results
h1.heading.heading__primary Results
- if (data.results.length === 0)
p No results found for the query. Try being less specific and/or removing filters.
- else
.search-results__container
each item in data.results
- console.log(item.type) // temporary
.search-results__item
- if (item.type === 'answer')
+addAnswer(item, true)
- else if (item.type === 'question')
+addQuestion(item)
- else if (item.type === 'post')
+addPost(item)
- else if (item.type === 'profile')
.metadata-primary
p.metadata-primary__heading
if item.isAnon
span Anonymous
else
a.link.metadata-primary__link(href=item.url)= item.name
if item.isVerified
svg.icon.metadata-primary__icon
title verified
use(href='/misc/sprite.svg#icon-verified')
+proxifyImg(item.image)(class='metadata-primary__image', alt='', aria-hidden='true')
p.metadata-primary__misc(aria-label=`${item.name}'s credentials`)= item.credential || ''
- else if (item.type === 'topic')
.metadata-primary
a.link.metadata-primary__heading(href=item.url)= item.name
+proxifyImg(item.image)(class='metadata-primary__image', aria-hidden='true', alt='')
p.metadata-primary__misc
+formatNumber(item.numFollowers)
|&nbsp;Followers
- else if (item.type === 'space')
.metadata-primary.profile-spaces__list-item
+proxifyImg(item.image)(class='metadata-primary__image', alt='', aria-hidden='true')
a.link.metadata-primary__heading(href=item.url)= item.name
p.metadata-primary__misc= item.description

View file

@ -45,6 +45,10 @@ $misc-vars: (
);
$themed-vars: (
color-scheme: (
light: 'light',
dark: 'dark',
),
// base
clr-base-bg:
(
@ -152,5 +156,5 @@ $themed-vars: (
clr-focus: (
light: color.scale($clr-highlight, $lightness: 0%, $saturation: 0%),
dark: color.scale($clr-highlight),
)
),
);

View file

@ -43,6 +43,8 @@ body {
// BASE STYLING
////////////////////////////////////////////////////////
:root {
color-scheme: light dark;
// normal vars
@include get-misc-vars;
// themed vars(default:light)
@ -64,6 +66,7 @@ body {
}
.body {
color-scheme: var(--color-scheme);
font-size: var(--fs-160);
font-family: var(--ff-primary);
background-color: var(--clr-base-bg);
@ -126,8 +129,7 @@ body {
// KEYBOARD NAVIGATION
////////////////////////////////////////////////////////
:focus {
outline: 3px solid var(--clr-focus);
outline-offset: 0.2em;
@include focus-rules;
}
@supports selector(:focus-visible) {
@ -136,8 +138,7 @@ body {
}
:focus-visible {
outline: 3px solid var(--clr-focus);
outline-offset: 0.2em;
@include focus-rules;
}
}

View file

@ -51,6 +51,8 @@
.icon {
max-height: var(--fs-500);
max-width: var(--fs-500);
height: 100%;
width: 100%;
fill: var(--clr-base-icon);
&__down {
@ -81,6 +83,23 @@
}
}
////////////////////////////////////////////////////////
// HELPER CLASSES
////////////////////////////////////////////////////////
.visually-hidden {
clip: rect(1px, 1px, 1px, 1px) !important;
border: 0 !important;
-webkit-clip-path: inset(50%) !important;
clip-path: inset(50%) !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
white-space: nowrap !important;
width: 1px !important;
}
////////////////////////////////////////////////////////
// LINKS
////////////////////////////////////////////////////////
@ -507,3 +526,113 @@
gap: var(--space-800);
}
}
////////////////////////////////////////////////////////
// SEARCH PAGE COMPONENTS
////////////////////////////////////////////////////////
///
.search-form {
display: grid;
gap: var(--space-200);
// justify-items: center;
&__search-container {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, auto) 3rem 3rem;
grid-auto-rows: 3rem;
border: 1px solid var(--clr-base-icon-alt-alpha);
border-radius: 100vmax;
overflow: hidden;
padding: var(--space-100);
gap: var(--space-050);
&:focus-within {
background-color: var(--clr-code-bg);
color: var(--clr-code-text);
}
}
&__searchbar {
outline: none;
border: none;
background-color: transparent;
font: inherit;
caret-color: var(--clr-base-icon);
}
&__button {
border: none;
background: transparent;
cursor: pointer;
border-radius: 100vmax;
&:hover {
transform: scale(1.1);
}
&--reset {
}
&--submit {
}
}
&__filters-container {
display: flex;
flex-wrap: wrap;
gap: 2rem 1rem;
}
&__filters-heading {
flex-basis: 100%;
}
&__filters-group {
position: relative;
}
&__radio {
}
&__label {
cursor: pointer;
background: var(--clr-code-bg);
color: var(--clr-code-text);
padding: 1rem;
border-radius: 1rem;
}
&__radio:checked + &__label {
color: var(--clr-selection-text);
background: var(--clr-selection-bg);
}
&__radio:focus + &__label {
@include focus-rules;
}
@supports selector(:focus-visible) {
&__radio:focus + &__label {
outline: none;
}
&__radio:focus-visible + &__label {
@include focus-rules;
}
}
}
.search-results {
--img-dim: var(--fs-600);
display: grid;
gap: var(--space-500);
&__container {
display: grid;
gap: var(--space-500);
}
&__item {
}
}

View file

@ -76,7 +76,13 @@
}
}
&__theme {
&__misc {
display: flex;
gap: var(--space-200);
}
&__theme,
&__search {
height: var(--fs-300);
width: var(--fs-300);
@ -86,6 +92,12 @@
}
}
// search icon viewBox is larger than content, hence the fix
&__search > svg {
height: 105%;
width: 105%;
}
&__info {
display: grid;
place-items: center;

View file

@ -63,6 +63,11 @@
}
}
@mixin focus-rules {
outline: 3px solid var(--clr-focus);
outline-offset: 0.2em;
}
////////////////////////////////////////////////////////
// BREAKPOINT MANAGER
////////////////////////////////////////////////////////

View file

@ -405,3 +405,54 @@
padding-inline: var(--space-200);
}
}
////////////////////////////////////////////////////////
// SEARCH
////////////////////////////////////////////////////////
.search {
// justify-self: center;
padding: var(--space-500) var(--space-800);
display: grid;
grid-template-columns: 2fr 1.2fr;
gap: var(--space-500);
align-items: start;
&--no-results {
grid-template-columns: unset;
place-content: center;
max-width: 100rem;
margin-inline: auto;
}
&__results {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
&__form {
grid-row: 1 / 2;
grid-column: -2 / -1;
}
@include respond-to(bp-1200) {
padding: var(--space-500);
gap: var(--space-500);
}
@include respond-to(bp-900) {
grid-template-columns: auto;
grid-template-rows: max-content auto;
&__results {
grid-row: 2 / 3;
}
&__form {
grid-row: 1 / 2;
}
}
@include respond-to(bp-550) {
padding-inline: var(--space-200);
}
}