forked from 0x2E/fusion
feat: display items by group (#149)
This commit is contained in:
parent
103ece629f
commit
5c98073f9f
9 changed files with 174 additions and 82 deletions
|
@ -8,6 +8,7 @@ export type ListFilter = {
|
|||
page_size?: number;
|
||||
keyword?: string;
|
||||
feed_id?: number;
|
||||
group_id?: number;
|
||||
unread?: boolean;
|
||||
bookmark?: boolean;
|
||||
};
|
||||
|
@ -21,7 +22,7 @@ export async function listItems(options?: ListFilter) {
|
|||
.get('items', {
|
||||
searchParams: options
|
||||
})
|
||||
.json<{ total: number; items: Omit<Item, 'content'>[] }>();
|
||||
.json<{ total: number; items: Item[] }>();
|
||||
}
|
||||
|
||||
export function parseURLtoFilter(params: URLSearchParams, override?: ListFilter): ListFilter {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { getFavicon } from '$lib/api/favicon';
|
||||
import { applyFilterToURL, parseURLtoFilter } from '$lib/api/item';
|
||||
import type { Item } from '$lib/api/model';
|
||||
import { defaultPageSize } from '$lib/consts';
|
||||
import { t } from '$lib/i18n';
|
||||
import ItemActionBookmark from './ItemActionBookmark.svelte';
|
||||
import ItemActionUnread from './ItemActionUnread.svelte';
|
||||
|
@ -162,26 +163,28 @@
|
|||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 flex w-full flex-wrap justify-center gap-4">
|
||||
<Pagination
|
||||
currentPage={filter.page}
|
||||
pageSize={filter.page_size}
|
||||
{total}
|
||||
onPageChange={handleChangePage}
|
||||
/>
|
||||
<div class="join">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={filter.page_size}
|
||||
onchange={handleChangePageSize}
|
||||
min="10"
|
||||
step="10"
|
||||
class="input join-item w-16"
|
||||
{#if total / (filter.page_size ?? defaultPageSize) > 1}
|
||||
<div class="mt-6 flex w-full flex-wrap justify-center gap-4">
|
||||
<Pagination
|
||||
currentPage={filter.page}
|
||||
pageSize={filter.page_size}
|
||||
{total}
|
||||
onPageChange={handleChangePage}
|
||||
/>
|
||||
<span class="join-item bg-base-300 text-base-content/60 flex items-center px-2 text-sm">
|
||||
Per page
|
||||
</span>
|
||||
<div class="join">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={filter.page_size}
|
||||
onchange={handleChangePageSize}
|
||||
min="10"
|
||||
step="10"
|
||||
class="input join-item w-16"
|
||||
/>
|
||||
<span class="join-item bg-base-300 text-base-content/60 flex items-center px-2 text-sm">
|
||||
Per page
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -43,29 +43,27 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if pages.length > 1}
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => handlePageChange(currentPage - 1)}>«</button
|
||||
>
|
||||
{#each pages as page}
|
||||
{#if typeof page === 'string'}
|
||||
<button class="join-item btn" disabled>...</button>
|
||||
{:else}
|
||||
<button
|
||||
class={`join-item btn ${page === currentPage ? 'btn-active border-b-base-content/60 border-b-2' : ''}`}
|
||||
onclick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
class="join-item btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => handlePageChange(currentPage + 1)}>»</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => handlePageChange(currentPage - 1)}>«</button
|
||||
>
|
||||
{#each pages as page}
|
||||
{#if typeof page === 'string'}
|
||||
<button class="join-item btn" disabled>...</button>
|
||||
{:else}
|
||||
<button
|
||||
class={`join-item btn ${page === currentPage ? 'btn-active border-b-base-content/60 border-b-2' : ''}`}
|
||||
onclick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
class="join-item btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => handlePageChange(currentPage + 1)}>»</button
|
||||
>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
BookmarkCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleEllipsis,
|
||||
CirclePlus,
|
||||
Command,
|
||||
|
@ -33,6 +35,9 @@
|
|||
|
||||
let { feeds, groups }: Props = $props();
|
||||
|
||||
// State to track open groups
|
||||
let openGroups = $state<Record<number, boolean>>({});
|
||||
|
||||
let feedList = $derived.by(async () => {
|
||||
const [feedsData, groupsData] = await Promise.all([feeds, groups]);
|
||||
const groupFeeds: { id: number; name: string; feeds: (Feed & { indexInList: number })[] }[] =
|
||||
|
@ -50,6 +55,7 @@
|
|||
indexInList: curIndexInList++
|
||||
}))
|
||||
});
|
||||
openGroups[group.id] = false;
|
||||
});
|
||||
return groupFeeds;
|
||||
});
|
||||
|
@ -133,7 +139,7 @@
|
|||
|
||||
const el = document.getElementById(`sidebar-feed-${selectedFeedIndex}`);
|
||||
if (el) {
|
||||
selectedFeedGroupId = parseInt(el.getAttribute('data-group-id') ?? '-1');
|
||||
openGroups[parseInt(el.getAttribute('data-group-id') ?? '-1')] = true;
|
||||
el.focus();
|
||||
// focus twice because <details> element's opening delay blocks the focus when
|
||||
// we open a new group (<details>)
|
||||
|
@ -193,44 +199,58 @@
|
|||
</ul>
|
||||
|
||||
<ul class="menu w-full">
|
||||
<li class="menu-title">{t('common.feeds')}</li>
|
||||
<li class="menu-title text-xs">{t('common.feeds')}</li>
|
||||
{#await feedList}
|
||||
<div class="skeleton bg-base-300 h-10"></div>
|
||||
{:then groupData}
|
||||
{#each groupData as group, groupIndex}
|
||||
{#each groupData as group (group.id)}
|
||||
{@const isOpen = openGroups[group.id]}
|
||||
<li>
|
||||
<details open={groupIndex === 0 || selectedFeedGroupId === group.id}>
|
||||
<summary class="overflow-hidden">
|
||||
<span class="line-clamp-1">{group.name}</span>
|
||||
</summary>
|
||||
<ul>
|
||||
{#each group.feeds as feed}
|
||||
{@const textColor = feed.suspended
|
||||
? 'text-neutral-content/60'
|
||||
: feed.failure
|
||||
? 'text-error'
|
||||
: ''}
|
||||
<li>
|
||||
<a
|
||||
id="sidebar-feed-{feed.indexInList}"
|
||||
data-group-id={group.id}
|
||||
href="/feeds/{feed.id}"
|
||||
class={`${isHighlight('/feeds/' + feed.id) ? 'menu-active' : ''} focus:ring-2`}
|
||||
>
|
||||
<div class="avatar">
|
||||
<div class="size-4 rounded-full">
|
||||
<img src={getFavicon(feed.link)} alt={feed.name} loading="lazy" />
|
||||
</div>
|
||||
<div class="relative flex items-center pl-10">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-square absolute top-0 left-1"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
openGroups[group.id] = !isOpen;
|
||||
}}
|
||||
>
|
||||
{#if isOpen}
|
||||
<ChevronDown class="size-4" />
|
||||
{:else}
|
||||
<ChevronRight class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
<a href="/groups/{group.id}" class="line-clamp-1 grow text-left">
|
||||
{group.name}
|
||||
</a>
|
||||
</div>
|
||||
<ul class:hidden={!isOpen}>
|
||||
{#each group.feeds as feed}
|
||||
{@const textColor = feed.suspended
|
||||
? 'text-neutral-content/60'
|
||||
: feed.failure
|
||||
? 'text-error'
|
||||
: ''}
|
||||
<li>
|
||||
<a
|
||||
id="sidebar-feed-{feed.indexInList}"
|
||||
data-group-id={group.id}
|
||||
href="/feeds/{feed.id}"
|
||||
class={`${isHighlight('/feeds/' + feed.id) ? 'menu-active' : ''} focus:ring-2`}
|
||||
>
|
||||
<div class="avatar">
|
||||
<div class="size-4 rounded-full">
|
||||
<img src={getFavicon(feed.link)} alt={feed.name} loading="lazy" />
|
||||
</div>
|
||||
<span class={`line-clamp-1 grow ${textColor}`}>{feed.name}</span>
|
||||
{#if feed.unread_count > 0}
|
||||
<span class="text-base-content/60 text-xs">{feed.unread_count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<span class={`line-clamp-1 grow ${textColor}`}>{feed.name}</span>
|
||||
{#if feed.unread_count > 0}
|
||||
<span class="text-base-content/60 text-xs">{feed.unread_count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{/await}
|
||||
|
|
37
frontend/src/routes/(authed)/groups/[id]/+page.svelte
Normal file
37
frontend/src/routes/(authed)/groups/[id]/+page.svelte
Normal file
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import ItemActionMarkAllasRead from '$lib/components/ItemActionMarkAllasRead.svelte';
|
||||
import ItemList from '$lib/components/ItemList.svelte';
|
||||
import PageNavHeader from '$lib/components/PageNavHeader.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { Settings2 } from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#await data.group then group}
|
||||
<title>{group.name}</title>
|
||||
{/await}
|
||||
</svelte:head>
|
||||
|
||||
{#await data.group}
|
||||
Loading...
|
||||
{:then group}
|
||||
<PageNavHeader showSearch={true}>
|
||||
{#await data.items then items}
|
||||
<ItemActionMarkAllasRead items={items.items} />
|
||||
{/await}
|
||||
<div class="tooltip tooltip-bottom" data-tip={t('common.settings')}>
|
||||
<a href="/settings#groups" class="btn btn-ghost btn-square">
|
||||
<Settings2 class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</PageNavHeader>
|
||||
|
||||
<div class="px-4 lg:px-8">
|
||||
<div class="items-center py-6">
|
||||
<h1 class="text-3xl font-bold">{group.name}</h1>
|
||||
</div>
|
||||
<ItemList data={data.items} highlightUnread={true} />
|
||||
</div>
|
||||
{/await}
|
27
frontend/src/routes/(authed)/groups/[id]/+page.ts
Normal file
27
frontend/src/routes/(authed)/groups/[id]/+page.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { allGroups } from '$lib/api/group';
|
||||
import { listItems, parseURLtoFilter } from '$lib/api/item';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const load: PageLoad = async ({ depends, url, params }) => {
|
||||
depends(`page:${url.pathname}`);
|
||||
|
||||
const id = parseInt(params.id);
|
||||
const group = allGroups().then((groups) => {
|
||||
const group = groups.find((g) => g.id === id);
|
||||
if (!group) {
|
||||
error(404, 'Group not found');
|
||||
}
|
||||
return group;
|
||||
});
|
||||
const filter = parseURLtoFilter(url.searchParams, {
|
||||
unread: undefined,
|
||||
bookmark: undefined,
|
||||
feed_id: undefined,
|
||||
group_id: id
|
||||
});
|
||||
const items = listItems(filter);
|
||||
return { group, items: items };
|
||||
};
|
|
@ -22,6 +22,7 @@ type Item struct {
|
|||
type ItemFilter struct {
|
||||
Keyword *string
|
||||
FeedID *uint
|
||||
GroupID *uint
|
||||
Unread *bool
|
||||
Bookmark *bool
|
||||
}
|
||||
|
@ -29,7 +30,7 @@ type ItemFilter struct {
|
|||
func (i Item) List(filter ItemFilter, page, pageSize int) ([]*model.Item, int, error) {
|
||||
var total int64
|
||||
var res []*model.Item
|
||||
db := i.db.Model(&model.Item{})
|
||||
db := i.db.Model(&model.Item{}).Joins("JOIN feeds ON feeds.id = items.feed_id")
|
||||
if filter.Keyword != nil {
|
||||
expr := "%" + *filter.Keyword + "%"
|
||||
db = db.Where("title LIKE ? OR content LIKE ?", expr, expr)
|
||||
|
@ -37,6 +38,9 @@ func (i Item) List(filter ItemFilter, page, pageSize int) ([]*model.Item, int, e
|
|||
if filter.FeedID != nil {
|
||||
db = db.Where("feed_id = ?", *filter.FeedID)
|
||||
}
|
||||
if filter.GroupID != nil {
|
||||
db = db.Where("feeds.group_id = ?", *filter.GroupID)
|
||||
}
|
||||
if filter.Unread != nil {
|
||||
db = db.Where("unread = ?", *filter.Unread)
|
||||
}
|
||||
|
@ -48,7 +52,7 @@ func (i Item) List(filter ItemFilter, page, pageSize int) ([]*model.Item, int, e
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = db.Joins("Feed").Order("items.pub_date desc, items.created_at desc").
|
||||
err = db.Preload("Feed").Order("items.pub_date desc, items.created_at desc").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).Find(&res).Error
|
||||
return res, int(total), err
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ func (i Item) List(ctx context.Context, req *ReqItemList) (*RespItemList, error)
|
|||
filter := repo.ItemFilter{
|
||||
Keyword: req.Keyword,
|
||||
FeedID: req.FeedID,
|
||||
GroupID: req.GroupID,
|
||||
Unread: req.Unread,
|
||||
Bookmark: req.Bookmark,
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ type ReqItemList struct {
|
|||
Paginate
|
||||
Keyword *string `query:"keyword"`
|
||||
FeedID *uint `query:"feed_id"`
|
||||
GroupID *uint `query:"group_id"`
|
||||
Unread *bool `query:"unread"`
|
||||
Bookmark *bool `query:"bookmark"`
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue