Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Blog: Added filter by tags #466

Merged
merged 30 commits into from
Sep 1, 2022
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
069c89a
Added Filter by Tag feature
NLTN Apr 30, 2022
481b9c8
Added TagField component
NLTN May 5, 2022
ec3022f
Updated TagField on:change event & Fixed minor bugs
NLTN May 9, 2022
148fef5
Updated TagField + Added discernTags() + Modified fetchNewsletters()
NLTN May 11, 2022
259d7fd
Optimized discernTags()
NLTN May 12, 2022
98affdb
Update src/lib/components/utils/acm-tagfield.svelte
NLTN May 12, 2022
2021d42
Update src/routes/blog/index.xml.ts
NLTN May 12, 2022
32a7dc2
Fix bugs and improved code quality.
NLTN May 13, 2022
a843815
Created a unit test for discernTags() function
NLTN May 13, 2022
baaac58
Added Svelte Slot component to TagField component
NLTN May 13, 2022
4c9e390
Code refactoring. Change the word "Tag" with "Label".
NLTN May 13, 2022
ff25d04
Optimized filterPosts()
NLTN May 15, 2022
3abe409
Changed slot name.
NLTN May 27, 2022
111206b
Update src/lib/components/utils/acm-labelfield.svelte
NLTN May 28, 2022
1d52644
Update src/routes/blog/index.svelte
NLTN May 28, 2022
2cafd31
Remove unnecessary code
NLTN May 28, 2022
3e9f8c4
Update src/routes/blog/index.json.ts
NLTN May 28, 2022
94fec94
Merge branch 'main' into fix/277
NLTN Jun 6, 2022
2dd72cf
Fixed lint errors
NLTN Jun 7, 2022
2a1fea3
Hide LabelField if there is no posts
NLTN Jun 20, 2022
90489f5
Merge branch 'main' into fix/277
EthanThatOneKid Aug 29, 2022
c0b4f53
Fixed a mistake made when merging conflicts
EthanThatOneKid Aug 29, 2022
49c6146
Added support for the "no posts" UI state.
EthanThatOneKid Aug 29, 2022
549716f
Removed unused import to pass the tests!
EthanThatOneKid Aug 29, 2022
92ed359
Merge branch 'main' into fix/277
EthanThatOneKid Aug 29, 2022
27aae8a
Refactored `fetchNewsletters` to handle DEBUG mode logic
EthanThatOneKid Aug 30, 2022
42086af
Made blog page to work with/without Javascript.
NLTN Aug 30, 2022
1afde39
Merge branch 'main' into fix/277
EthanThatOneKid Sep 1, 2022
c729607
Revert "Revert "Refactored caching logic for individual blog posts (#…
EthanThatOneKid Sep 1, 2022
5fb3e7d
Fixed typo in blog page title
EthanThatOneKid Sep 1, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/lib/common/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,30 @@ for (const input of FALSY_INPUT_DATA) {
expect(utils.parseBool(input)).toBe(false);
});
}

test('discerns no labels from empty input', () => {
const input = [];
const expected = [];
const output = utils.discernLabels(input);
expect(output).toEqual(expected);
});

test('discerns one label from length-1 input', () => {
const input = [{ labels: ['a'] }];
const expected = ['a'];
const output = utils.discernLabels(input);
expect(output).toEqual(expected);
});

test('discerns unique labels from list in alphabetical order', () => {
const input = [
{ labels: [] },
{ labels: ['abc def'] },
{ labels: ['d', 'c', 'c', 'c'] },
{ labels: ['e', 'd'] },
{ labels: ['c', 'a', 'b'] },
];
const expected = ['a', 'abc def', 'b', 'c', 'd', 'e'];
const output = utils.discernLabels(input);
expect(output).toEqual(expected);
});
12 changes: 12 additions & 0 deletions src/lib/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,15 @@ export function parseBool(payload?: string | null): boolean {
const isFalsy = !payload || FALSY_PATTERN.test(payload);
return !isFalsy;
}

interface WithLabels {
labels: string[];
}

export function discernLabels(posts: WithLabels[]): string[] {
const labels = new Set([]);
for (const post of posts) {
post.labels.forEach((label) => labels.add(label));
}
return Array.from(labels).sort();
}
141 changes: 141 additions & 0 deletions src/lib/components/utils/acm-labelfield.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';

export let labels: string[] = [];
export let selectedLabels: string[] = [];
export let urlSearchParamKey = '';

const dispatch = createEventDispatcher();
let hasSelectedLabels = selectedLabels.length > 0;

function selectLabel(event: MouseEvent) {
event.preventDefault();

if (selectedLabels.includes(this.innerText)) {
selectedLabels = selectedLabels.filter((t) => t !== this.innerText);
} else {
selectedLabels.push(this.innerText);
}

this.classList.toggle('selected');
hasSelectedLabels = selectedLabels.length > 0;
dispatch('change', selectedLabels);
}

function deselectAll(event: MouseEvent) {
event.preventDefault();
selectedLabels = [];
hasSelectedLabels = false;
dispatch('change', selectedLabels);
}

function createLabelURL(label: string) {
if (urlSearchParamKey === '') {
return '';
}
const nextLabels = selectedLabels.includes(label)
? selectedLabels.filter((t) => t !== label)
: selectedLabels.concat([label]);
const params = new URLSearchParams([[urlSearchParamKey, nextLabels.join(',')]]);
return '?' + params.toString();
}
</script>

{#if labels.length > 0}
<div class="label-box">
<div class="title" class:hidden={hasSelectedLabels}>
<slot name="title" />
</div>
<a href="?" class="reset-button" class:hidden={!hasSelectedLabels} on:click={deselectAll}>
<slot name="reset-button" />
</a>

<div class="label-list">
{#each labels as label}
<a
href={createLabelURL(label)}
class="label"
class:selected={selectedLabels.includes(label)}
on:click={selectLabel}
>
{label}
</a>
{/each}
</div>
</div>
{/if}

<style lang="scss">
.label-box {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.2em;

.title {
font-size: 1em;
font-weight: bold;
margin-right: 1em;
cursor: default;
}

.hidden {
display: none;
}

.reset-button {
margin-right: 1em;
font-size: small;
text-decoration: underline;
cursor: pointer;
}

.label-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 0.2em;

.label {
margin-bottom: 0.2em;
margin-right: var(--size-sm);
padding: 0.25em 0.8em 0.25em 0.8em;
background-color: #eeeeee;
color: #212121;
border-radius: var(--size-sm);
border: 2px solid #e0e0e0;
cursor: pointer;
transition: 0.25s ease-in-out;
text-decoration: none;

&:hover {
border-color: #b0bec5;
}

&.selected {
transition: 1s ease-in-out;
background-color: #81d4fa;
border-color: #4fc3f7;

&:before {
content: '✓ ';
}

&:hover {
border-color: #2196f3;
}
}
}
}

@media screen and (max-width: 900px) {
.title {
display: none;
}

.reset-button {
display: none;
}
}
}
</style>
25 changes: 22 additions & 3 deletions src/routes/blog/_query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Officer } from '$lib/constants';
import { OFFICERS } from '$lib/constants';
import { discernLabels } from '$lib/common/utils';

export interface BlogOutput {
labels: string[];
posts: Newsletter[];
}

export interface Newsletter {
id: number;
Expand All @@ -17,6 +23,10 @@ export interface Newsletter {
};
}

export interface NewsletterFetchOptions {
labels: string[];
}

/**
* GraphQL query to get all the blog posts from the newsletters category.
* @see https://docs.github.com/en/graphql/overview/explorer
Expand Down Expand Up @@ -94,16 +104,25 @@ function formatNewsletters(output: any): Newsletter[] {
});
}

export async function fetchNewsletters(): Promise<Newsletter[]> {
export async function fetchNewsletters(options?: NewsletterFetchOptions): Promise<BlogOutput> {
const ghAccessToken = import.meta.env.VITE_GH_ACCESS_TOKEN;

const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: { Authorization: `token ${ghAccessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: newslettersQuery }),
});
const newsletters = formatNewsletters(await response.json());
return newsletters.sort((a, b) => {

let posts = formatNewsletters(await response.json());
const labels = discernLabels(posts);

if (options?.labels.length > 0) {
posts = posts.filter((post) => post.labels.some((item) => options.labels.includes(item)));
}

posts = posts.sort((a, b) => {
return new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf();
});

return { labels, posts };
}
15 changes: 11 additions & 4 deletions src/routes/blog/index.json.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import type { RequestHandlerOutput } from '@sveltejs/kit/types/internal';
import { fetchNewsletters } from './_query';
import type { RequestHandlerOutput, RequestEvent } from '@sveltejs/kit/types/internal';
import { fetchNewsletters, NewsletterFetchOptions } from './_query';
import { DEBUG } from '$lib/constants';
import { posts } from './_testdata/posts';

const SAMPLE_POSTS = JSON.stringify(posts);

export async function get(): Promise<RequestHandlerOutput> {
export async function get(event: RequestEvent): Promise<RequestHandlerOutput> {
const fetchOptions: NewsletterFetchOptions = { labels: [] };
const rawLabels = event.url.searchParams.get('l');

if (rawLabels?.length > 0) {
fetchOptions.labels = rawLabels.split(',');
}

// Uses sample data when DEBUG = 1 or env variables are not satisfied.
const isSatisfied =
import.meta.env.VITE_GH_ACCESS_TOKEN !== undefined &&
import.meta.env.VITE_GH_DISCUSSION_CATEGORY_ID !== undefined;

return new Response(
DEBUG || !isSatisfied ? SAMPLE_POSTS : JSON.stringify(await fetchNewsletters()),
DEBUG || !isSatisfied ? SAMPLE_POSTS : JSON.stringify(await fetchNewsletters(fetchOptions)),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
Expand Down
92 changes: 59 additions & 33 deletions src/routes/blog/index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,31 @@
<script lang="ts">
import type { Newsletter } from './_query';
import Spacing from '$lib/components/sections/spacing.svelte';
import LabelField from '$lib/components/utils/acm-labelfield.svelte';
import { Temporal } from '@js-temporal/polyfill';
import { readingTime } from '$lib/blog/utils';
import Labels from '$lib/components/blog/labels.svelte';
import BlogBody from '$lib/blog/blog-body.svelte';

export let posts: Newsletter[] = [];
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
export let labels: string[] = [];
export let selectedLabels: string[] = [];

async function filterPosts(event: CustomEvent) {
const searchValue = event.detail.join(',');

history.replaceState({}, '', location.pathname + '?l=' + searchValue);

const target = new URL('/blog.json', location.origin);
target.searchParams.set('l', searchValue);

const response = await fetch(target.toString());
const { posts: blogPosts } = await response.json();
posts = blogPosts;
}
</script>

<svelte:head>
<title>README / ACM at CSUF</title>
<title>Blog / ACM at CSUF</title>
</svelte:head>

<Spacing --min="175px" --med="200px" --max="200px" />
Expand All @@ -43,38 +58,49 @@

<Spacing --min="175px" --med="200px" --max="200px" />

<section>
<ul>
{#each posts as post (post.id)}
<li class="blog-post">
<a href={`/blog/${post.id}`} sveltekit:prefetch>
<div class="author">
<a href={post.author.url}>
<img src={post.author.picture} alt="" />
</a>
<div>
<a href={post.author.url}>{post.author.displayname}</a>
{#if posts.length > 0}
<LabelField {labels} {selectedLabels} urlSearchParamKey="l" on:change={filterPosts}>
<div slot="title">Filter by Tags</div>
<div slot="reset-button">✖ Clear all</div>
</LabelField>

<section>
<ul>
{#each posts as post (post.id)}
<li class="blog-post">
<a href={`/blog/${post.id}`} sveltekit:prefetch>
<div class="author">
<a href={post.author.url}>
<img src={post.author.picture} alt="" />
</a>
<div>
<a href={post.author.url}>{post.author.displayname}</a>
</div>
</div>
</div>
<h2 class="headers">{post.title}</h2>
<div class="markdown-body">
<BlogBody data={post.html} />
</div>
<p class="read-time">
{Temporal.Instant.from(post.createdAt).toLocaleString('en-US', {
calendar: 'gregory',
year: 'numeric',
month: 'long',
day: 'numeric',
})} •
{readingTime(post.html)} min read
<Labels data={post.labels} />
</p>
</a>
</li>
{/each}
</ul>
</section>
<h2 class="headers">{post.title}</h2>
<div class="markdown-body">
{@html post.html}
</div>
<p class="read-time">
{Temporal.Instant.from(post.createdAt).toLocaleString('en-US', {
calendar: 'gregory',
year: 'numeric',
month: 'long',
day: 'numeric',
})} •
{readingTime(post.html)} min read
<Labels data={post.labels} />
</p>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<section>
<h2 class="size-lg">There are no posts yet.</h2>
</section>
{/if}

<Spacing --min="40px" --med="95px" --max="120px" />

Expand Down
3 changes: 2 additions & 1 deletion src/routes/blog/index.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ function makeRssFeed(posts: Newsletter[]): string {
}

export async function get(): Promise<RequestHandlerOutput> {
return new Response(JSON.stringify(makeRssFeed(await fetchNewsletters())), {
const { posts } = await fetchNewsletters();
return new Response(makeRssFeed(posts), {
status: 200,
headers: { 'Cache-Control': 'max-age=0, s-maxage=3600', 'Content-Type': 'application/xml' },
});
Expand Down