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 6 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
11 changes: 11 additions & 0 deletions src/lib/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
interface WithLabels {
labels: string[];
}

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

export let tags: string[] = [];
export let selectedTags: string[] = [];
export let label = '';
export let resetButton = '';
export let urlSearchParamKey = '';

const dispatch = createEventDispatcher();
let hasSelectedTags = selectedTags.length > 0;

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

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

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

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

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

<div class="tag-box">
<div class="tag-title" class:hidden={hasSelectedTags}>{label}</div>
<a href="?" class="tag-clear-button" class:hidden={!hasSelectedTags} on:click={deselectAll}>
{resetButton}
</a>

<div class="tag-list">
{#each tags as tag}
<a
href={createTagURL(tag)}
class="tag"
class:selected={selectedTags.includes(tag)}
on:click={selectTag}
>
{tag}
</a>
{/each}
</div>
</div>

<style lang="scss">
.tag-box {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.2em;
.tag-title {
font-size: 1em;
font-weight: bold;
margin-right: 1em;
cursor: default;
}
.hidden {
display: none;
}
.tag-clear-button {
margin-right: 1em;
font-size: small;
text-decoration: underline;
cursor: pointer;
}
.tag-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 0.2em;
.tag {
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) {
.tag-title {
display: none;
}
.tag-clear-button {
display: none;
}
}
}
</style>
2 changes: 1 addition & 1 deletion src/routes/blog/[id].json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ async function getCache(id: number, origin: string) {

try {
const response = await fetch(target);
const data = await response.json();
const data = (await response.json()).posts;
const newsletter = (data as Newsletter[]).find((item) => {
return item.id === id;
});
Expand Down
24 changes: 21 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 { discernTags } from '$lib/common/utils';

export interface BlogOutput {
tags: 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,7 +104,7 @@ 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', {
Expand All @@ -103,6 +113,14 @@ export async function fetchNewsletters(): Promise<Newsletter[]> {
body: JSON.stringify({ query: newslettersQuery }),
});

const newsletters = formatNewsletters(await response.json());
return newsletters;
let newsletters = formatNewsletters(await response.json());
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
const tags = discernTags(newsletters);

if (options && options.labels.length > 0) {
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
newsletters = newsletters.filter((post) =>
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
post.labels.some((item) => options.labels.includes(item))
);
}

return { tags: tags, posts: newsletters };
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
}
14 changes: 10 additions & 4 deletions src/routes/blog/index.json.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
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';

export async function get(): Promise<RequestHandlerOutput> {
return new Response(JSON.stringify(await fetchNewsletters()), {
export async function get(event: RequestEvent): Promise<RequestHandlerOutput> {
const fetchOptions: NewsletterFetchOptions = { labels: [] };

if (event.url.searchParams.has('l') === true && event.url.searchParams.get('l').length > 0) {
jaasonw marked this conversation as resolved.
Show resolved Hide resolved
fetchOptions.labels = event.url.searchParams.get('l').split(',');
}
NLTN marked this conversation as resolved.
Show resolved Hide resolved

return new Response(JSON.stringify(await fetchNewsletters(fetchOptions)), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
Expand Down
46 changes: 42 additions & 4 deletions src/routes/blog/index.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
<script lang="ts" context="module">
import type { LoadInput, LoadOutput } from '@sveltejs/kit/types/internal';
import TagField from '$lib/components/utils/acm-tagfield.svelte';
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved

export async function load({ fetch }: LoadInput): Promise<LoadOutput> {
const response = await fetch(`/blog.json`);
return { props: { posts: await response.json() } };
let tags = [];
let selectedTags: string[] = [];
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved

export async function load(event: LoadInput): Promise<LoadOutput> {
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
const target = new URL('/blog.json', event.url);

if (event.url.searchParams.has('l') && event.url.searchParams.get('l').length > 0) {
target.searchParams.set('l', event.url.searchParams.get('l'));
selectedTags = event.url.searchParams.get('l').split(',');
} else {
selectedTags = [];
}

const response = await fetch(target.toString());
const blogOutput = await response.json();
tags = blogOutput.tags;

return { props: { posts: blogOutput.posts } };
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
}
</script>

Expand All @@ -12,10 +28,23 @@
import Spacing from '$lib/components/sections/spacing.svelte';

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

async function filterPosts(event: CustomEvent) {
const tags = event.detail;

window.history.replaceState({}, '', `${window.location.pathname}?l=${tags.join(',')}`);
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved

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

const response = await fetch(target.toString());
const blogOutput = await response.json();
posts = blogOutput.posts;
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
}
</script>

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

<Spacing --min="175px" --med="200px" --max="200px" />
Expand All @@ -31,6 +60,15 @@

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

<TagField
{tags}
{selectedTags}
label="Filter by Tags"
resetButton="✖ Clear Filter"
urlSearchParamKey="l"
on:change={filterPosts}
/>

<ul>
{#each posts as post (post.id)}
<li>
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: Newsletter[] = (await fetchNewsletters()).posts;
NLTN marked this conversation as resolved.
Show resolved Hide resolved
return new Response(JSON.stringify(makeRssFeed(posts)), {
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
status: 200,
headers: { 'Cache-Control': 'max-age=0, s-maxage=3600', 'Content-Type': 'application/xml' },
});
Expand Down