I was able to reproduce the problem by setting my system to a timezone, and my `TZ` to a different one. Since it'll default to `UTC`, and to avoid having to pass it around from the system to the client (since we don't really care about the timezone), we simply force the timezone to UTC in the formatting as well, because, again, we don't store timezones or care about them for expenses. Fixes #88
285 lines
9.6 KiB
TypeScript
285 lines
9.6 KiB
TypeScript
import { useSignal } from '@preact/signals';
|
|
|
|
import { NewsFeedArticle } from '/lib/types.ts';
|
|
import {
|
|
RequestBody as RefreshRequestBody,
|
|
ResponseBody as RefreshResponseBody,
|
|
} from '/routes/api/news/refresh-articles.tsx';
|
|
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/routes/api/news/mark-read.tsx';
|
|
|
|
interface ArticlesProps {
|
|
initialArticles: NewsFeedArticle[];
|
|
}
|
|
|
|
interface Filter {
|
|
status: 'all' | 'unread';
|
|
}
|
|
|
|
export default function Articles({ initialArticles }: ArticlesProps) {
|
|
const isRefreshing = useSignal<boolean>(false);
|
|
const articles = useSignal<NewsFeedArticle[]>(initialArticles);
|
|
const filter = useSignal<Filter>({ status: 'unread' });
|
|
const sessionReadArticleIds = useSignal<Set<string>>(new Set());
|
|
const isFilterDropdownOpen = useSignal<boolean>(false);
|
|
|
|
const dateFormatOptions: Intl.DateTimeFormatOptions = { dateStyle: 'medium' };
|
|
|
|
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
|
|
|
async function refreshArticles() {
|
|
if (isRefreshing.value) {
|
|
return;
|
|
}
|
|
|
|
isRefreshing.value = true;
|
|
|
|
try {
|
|
const requestBody: RefreshRequestBody = {};
|
|
const response = await fetch(`/api/news/refresh-articles`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to refresh articles. ${response.statusText} ${await response.text()}`);
|
|
}
|
|
|
|
const result = await response.json() as RefreshResponseBody;
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to refresh articles!');
|
|
}
|
|
|
|
articles.value = [...result.newArticles];
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
|
|
isRefreshing.value = false;
|
|
}
|
|
|
|
const filteredArticles = articles.value.filter((article) => {
|
|
if (filter.value.status === 'unread') {
|
|
if (article.is_read && !sessionReadArticleIds.value.has(article.id)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
async function onClickView(articleId: string) {
|
|
const newArticles = [...articles.value];
|
|
|
|
const matchingArticle = newArticles.find((article) => article.id === articleId);
|
|
if (matchingArticle) {
|
|
if (matchingArticle.is_read) {
|
|
return;
|
|
}
|
|
|
|
matchingArticle.is_read = true;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
sessionReadArticleIds.value.add(articleId);
|
|
|
|
articles.value = [...newArticles];
|
|
|
|
try {
|
|
const requestBody: ReadRequestBody = { articleId };
|
|
const response = await fetch(`/api/news/mark-read`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to mark article as read. ${response.statusText} ${await response.text()}`);
|
|
}
|
|
|
|
const result = await response.json() as ReadResponseBody;
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to mark article as read!');
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
async function onClickMarkAllRead() {
|
|
const newArticles = [...articles.value].map((article) => {
|
|
article.is_read = true;
|
|
|
|
sessionReadArticleIds.value.add(article.id);
|
|
|
|
return article;
|
|
});
|
|
|
|
articles.value = [...newArticles];
|
|
|
|
try {
|
|
const requestBody: ReadRequestBody = { articleId: 'all' };
|
|
const response = await fetch(`/api/news/mark-read`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to mark all articles as read. ${response.statusText} ${await response.text()}`);
|
|
}
|
|
|
|
const result = await response.json() as ReadResponseBody;
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to mark all articles as read!');
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
function toggleFilterDropdown() {
|
|
isFilterDropdownOpen.value = !isFilterDropdownOpen.value;
|
|
}
|
|
|
|
function setNewFilter(newFilter: Partial<Filter>) {
|
|
filter.value = { ...filter.value, ...newFilter };
|
|
|
|
isFilterDropdownOpen.value = false;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<section class='flex flex-row items-center justify-between mb-4'>
|
|
<a href='/news/feeds' class='mr-2'>Manage feeds</a>
|
|
<section class='flex items-center'>
|
|
<section class='relative inline-block text-left ml-2'>
|
|
<div>
|
|
<button
|
|
type='button'
|
|
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
|
id='filter-button'
|
|
aria-expanded='true'
|
|
aria-haspopup='true'
|
|
onClick={() => toggleFilterDropdown()}
|
|
>
|
|
Filter
|
|
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
|
<path
|
|
fill-rule='evenodd'
|
|
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
|
clip-rule='evenodd'
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
|
!isFilterDropdownOpen.value ? 'hidden' : ''
|
|
}`}
|
|
role='menu'
|
|
aria-orientation='vertical'
|
|
aria-labelledby='filter-button'
|
|
tabindex={-1}
|
|
>
|
|
<div class='py-1'>
|
|
<button
|
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
|
filter.value.status === 'unread' ? 'font-semibold' : ''
|
|
}`}
|
|
onClick={() => setNewFilter({ status: 'unread' })}
|
|
type='button'
|
|
>
|
|
Show only unread
|
|
</button>
|
|
<button
|
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
|
filter.value.status === 'all' ? 'font-semibold' : ''
|
|
}`}
|
|
onClick={() => setNewFilter({ status: 'all' })}
|
|
type='button'
|
|
>
|
|
Show all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<button
|
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600 ml-2'
|
|
type='button'
|
|
title='Mark all read'
|
|
onClick={() => onClickMarkAllRead()}
|
|
>
|
|
<img
|
|
src='/images/check-all.svg'
|
|
alt='Mark all read'
|
|
class={`white`}
|
|
width={20}
|
|
height={20}
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
|
type='button'
|
|
title='Fetch new articles'
|
|
onClick={() => refreshArticles()}
|
|
>
|
|
<img
|
|
src='/images/refresh.svg'
|
|
alt='Fetch new articles'
|
|
class={`white ${isRefreshing.value ? 'animate-spin' : ''}`}
|
|
width={20}
|
|
height={20}
|
|
/>
|
|
</button>
|
|
</section>
|
|
</section>
|
|
|
|
<section class='mx-auto max-w-7xl my-8'>
|
|
{filteredArticles.length === 0
|
|
? <p class='my-4 block text-center text-lg text-slate-400'>There are no new articles to show.</p>
|
|
: (
|
|
<section class='divide-y divide-slate-800 shadow-sm rounded-md'>
|
|
{filteredArticles.map((article) => (
|
|
<details
|
|
class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`}
|
|
>
|
|
<summary
|
|
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-[:first-child]:rounded-tl-md group-[:first-child]:rounded-tr-md ${
|
|
article.is_read ? 'opacity-50' : 'font-semibold'
|
|
}`}
|
|
onClick={() => onClickView(article.id)}
|
|
>
|
|
<span class='mr-2'>{article.article_title}</span>
|
|
<span class='text-sm text-slate-300 ml-2 font-normal'>
|
|
{dateFormat.format(new Date(article.article_date))}
|
|
</span>
|
|
</summary>
|
|
<article class='overflow-auto max-w-full max-h-80 py-2 px-4 font-mono text-sm whitespace-pre-wrap border-t border-b border-slate-600'>
|
|
{article.article_summary}
|
|
</article>
|
|
<a
|
|
href={article.article_url}
|
|
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-[:last-child]:rounded-bl-md group-[:last-child]:rounded-br-md'
|
|
target='_blank'
|
|
rel='noreferrer noopener'
|
|
onClick={() => onClickView(article.id)}
|
|
>
|
|
<span class='text-sm text-slate-400 mr-2 font-normal'>{article.article_url}</span>
|
|
<span class='ml-2'>View article</span>
|
|
</a>
|
|
</details>
|
|
))}
|
|
</section>
|
|
)}
|
|
</section>
|
|
</>
|
|
);
|
|
}
|