This implements the option to choose directories when uploading files via the Web UI (The most important part of #52). When you choose a directory, its file and sub-directory structure will be maintained. Tested with the latest Safari, Firefox, and Chrome. Additionally, the Deno version was updated, which required some accessibility improvements as well.
268 lines
9.1 KiB
TypeScript
268 lines
9.1 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 dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' });
|
|
|
|
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),
|
|
});
|
|
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),
|
|
});
|
|
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),
|
|
});
|
|
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>
|
|
</>
|
|
);
|
|
}
|