1
0
Fork 1
mirror of https://github.com/0x2E/fusion.git synced 2025-06-08 05:27:15 +09:00

feat: display items by group (#149)

This commit is contained in:
Yuan 2025-04-25 16:28:45 +08:00 committed by GitHub
parent 103ece629f
commit 5c98073f9f
Signed by: github
GPG key ID: B5690EEEBB952194
9 changed files with 174 additions and 82 deletions

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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}

View 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}

View 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 };
};

View file

@ -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
}

View file

@ -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,
}

View file

@ -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"`
}