Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/aio-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Build and Publish aio-dev

on:
push:
branches:
- dev

jobs:
build-and-deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Set up Bun
uses: oven-sh/setup-bun@v1

- name: Install dependencies and build web app
run: |
cd apps/web
bun install
bun run build

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}

- name: Build and Push Multi-Arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ secrets.DOCKER_USERNAME }}/dashwise:aio-dev
67 changes: 58 additions & 9 deletions apps/backend/src/lib/data/news.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,22 @@ export type NewsFeedRecordCreateInput = {

export type NewsSavedArticle = {
id: string;
list: string;
list: string[];
isRead?: boolean;
json: NewsFeedItem;
userId?: string;
created?: string;
updated?: string;
};

export type NewsSavedArticleList = {
id: string;
name: string;
};

export type NewsSavedArticlesResponse = {
articles: NewsSavedArticle[];
lists: string[];
lists: NewsSavedArticleList[];
defaultList: string;
};

Expand All @@ -129,10 +134,22 @@ function normalizeListName(list?: string | null) {
return String(list || "").trim() || "readLater";
}

function normalizeListIds(list?: unknown): string[] {
const values = Array.isArray(list) ? list : list ? [list] : [];
return Array.from(new Set(values.map((value) => String(value).trim()).filter(Boolean)));
}

function normalizeSavedArticleList(record: Record<string, unknown>): NewsSavedArticleList {
return {
id: String(record.id || ""),
name: normalizeListName(String(record.name || "")),
};
}

function normalizeSavedArticle(record: Record<string, unknown>): NewsSavedArticle {
return {
id: String(record.id || ""),
list: normalizeListName(String(record.list || "")),
list: normalizeListIds(record.list),
isRead: Boolean(record.isRead),
json: (record.json && typeof record.json === "object" ? record.json : {}) as NewsFeedItem,
userId: record.userId ? String(record.userId) : undefined,
Expand All @@ -158,6 +175,38 @@ async function ensureNewsDefaultList(userId: string) {
return defaultList;
}

async function ensureNewsSavedArticleList(userId: string, list?: string | null): Promise<NewsSavedArticleList> {
const pb = await getSuperuserPB();
const target = normalizeListName(list);
const collection = pb.collection("newsSavedArticleLists");
const byId = await collection.getOne(target).catch(() => null) as Record<string, unknown> | null;
if (byId && String(byId.userId || "") === userId) {
return normalizeSavedArticleList(byId);
}

const existing = await collection.getFullList(200, {
filter: `userId=\"${escapeFilter(userId)}\" && name=\"${escapeFilter(target)}\"`,
}) as Array<Record<string, unknown>>;

if (existing[0]) {
return normalizeSavedArticleList(existing[0]);
}

const created = await collection.create({ userId, name: target });
return normalizeSavedArticleList(created as Record<string, unknown>);
}

async function getNewsSavedArticleLists(userId: string, defaultList: string): Promise<NewsSavedArticleList[]> {
const defaultRecord = await ensureNewsSavedArticleList(userId, defaultList);
const pb = await getSuperuserPB();
const records = await pb.collection("newsSavedArticleLists").getFullList(200, {
filter: `userId=\"${escapeFilter(userId)}\"`,
sort: "name",
}) as Array<Record<string, unknown>>;
const lists = records.map(normalizeSavedArticleList);
return lists.some((list) => list.id === defaultRecord.id) ? lists : [defaultRecord, ...lists];
}

function itemTime(item: NewsFeedItem): number {
const value = item?.pubDate;
if (value instanceof Date) return value.getTime();
Expand Down Expand Up @@ -617,11 +666,12 @@ export async function getNewsFeeds(userId: string): Promise<NewsFeedsResponse> {

export async function getNewsSavedArticles(userId: string, list?: string | null): Promise<NewsSavedArticlesResponse> {
const defaultList = await ensureNewsDefaultList(userId);
const lists = await getNewsSavedArticleLists(userId, defaultList);
const targetList = String(list || "").trim();
const pb = await getSuperuserPB();
const filters = [`userId=\"${escapeFilter(userId)}\"`];
if (targetList) {
filters.push(`list=\"${escapeFilter(targetList)}\"`);
filters.push(`list ?= \"${escapeFilter(targetList)}\"`);
}

const records = await pb.collection("newsSavedArticles").getFullList(2000, {
Expand All @@ -630,29 +680,28 @@ export async function getNewsSavedArticles(userId: string, list?: string | null)
}) as Array<Record<string, unknown>>;

const articles = records.map(normalizeSavedArticle);
const lists = Array.from(new Set([defaultList, ...articles.map((article) => article.list)].filter(Boolean))).sort((left, right) => left.localeCompare(right));

return { articles, lists, defaultList };
return { articles, lists, defaultList: lists.find((entry) => entry.name === defaultList)?.id || lists[0]?.id || defaultList };
}

export async function saveNewsArticle(userId: string, article: NewsFeedItem, list?: string | null): Promise<NewsSavedArticle> {
const defaultList = await ensureNewsDefaultList(userId);
const targetList = normalizeListName(list || defaultList);
const listRecord = await ensureNewsSavedArticleList(userId, list || defaultList);
const link = String(article?.link || "").trim();
if (!link) {
throw new Error("Article link is required");
}

const pb = await getSuperuserPB();
const existingRecords = await pb.collection("newsSavedArticles").getFullList(2000, {
filter: `userId=\"${escapeFilter(userId)}\" && list=\"${escapeFilter(targetList)}\"`,
filter: `userId=\"${escapeFilter(userId)}\" && list ?= \"${escapeFilter(listRecord.id)}\"`,
}) as Array<Record<string, unknown>>;
const existing = existingRecords.find((record) => {
const json = record.json && typeof record.json === "object" ? record.json as Record<string, unknown> : {};
return String(json.link || "").trim() === link;
}) || null;

const payload = { userId, list: targetList, isRead: false, json: article };
const payload = { userId, list: [listRecord.id], isRead: false, json: article };
const saved = existing?.id
? await pb.collection("newsSavedArticles").update(String(existing.id), payload)
: await pb.collection("newsSavedArticles").create(payload);
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/news/NewsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ export default function NewsDashboardComponent() {
};

const openSaveDialog = (article: NewsFeedItem) => {
const defaultList = savedArticlesData?.defaultList || "readLater";
const defaultList = savedArticlesData?.defaultList || savedArticlesData?.lists?.[0]?.id || "readLater";
setSaveDialogArticle(article);
setSaveListSelection(defaultList);
setNewSaveListName("");
Expand Down Expand Up @@ -814,8 +814,8 @@ export default function NewsDashboardComponent() {
value={saveListSelection}
onChange={(event) => setSaveListSelection(event.target.value)}
>
{(savedArticlesData?.lists?.length ? savedArticlesData.lists : ["readLater"]).map((list) => (
<option key={list} value={list}>{list}</option>
{(savedArticlesData?.lists?.length ? savedArticlesData.lists : [{ id: "readLater", name: "readLater" }]).map((list) => (
<option key={list.id} value={list.id}>{list.name}</option>
))}
</select>
</div>
Expand Down Expand Up @@ -859,7 +859,7 @@ function NewsArticle({ item, iconUrl, isSaved, onSave, onSaveOptions }: { item:
<div className="w-full h-45 frosted rounded-xl" />}
<button
type="button"
className={`absolute right-2 top-2 flex h-9 w-9 items-center justify-center rounded-full frosted transition ${isSaved ? "bg-(--accent)/20 ring-1 ring-(--accent)" : "bg-black/30 hover:bg-black/50"}`}
className="absolute right-2 top-2 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 frosted transition hover:bg-black/50 group"
title={isSaved ? "Saved to list" : "Save article"}
onClick={(event) => {
event.preventDefault();
Expand All @@ -870,7 +870,7 @@ function NewsArticle({ item, iconUrl, isSaved, onSave, onSaveOptions }: { item:
onSaveOptions?.();
}}
>
<Icon icon="fa6-solid:bookmark" className={isSaved ? "text-(--accent)" : "text-white/90"} />
<Icon icon={isSaved ? "fa6-solid:square-check" : "fa6-solid:bookmark"} className={isSaved ? "text-(--accent)" : "text-white/90 group-hover:text-(--accent)"} />
</button>
</div>

Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/components/news/NewsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ interface FeedRecord {
title?: string;
}

interface SavedListRecord {
id: string;
name: string;
}

export default function NewsLayout({ children }: { children: ReactNode }) {
const { token, withAuth } = useAuth();
const navigate = useNavigate();
const { feedId } = useParams();
const [searchParams] = useSearchParams();
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
const [savedLists, setSavedLists] = useState<string[]>([]);
const [savedLists, setSavedLists] = useState<SavedListRecord[]>([]);
const [sidebarRefreshVersion, setSidebarRefreshVersion] = useState(0);

useEffect(() => {
Expand Down Expand Up @@ -63,7 +68,7 @@ export default function NewsLayout({ children }: { children: ReactNode }) {

setSubscriptions(subscriptionsData?.subscriptions ?? []);
setFeeds(Array.isArray(feedsData?.feeds) ? feedsData.feeds : []);
setSavedLists(Array.isArray(savedData?.lists) ? savedData.lists : ["readLater"]);
setSavedLists(Array.isArray(savedData?.lists) ? savedData.lists : [{ id: "readLater", name: "readLater" }]);
} catch (error) {
console.error("Failed to load news subscriptions:", error);
if (mounted) {
Expand Down Expand Up @@ -183,12 +188,12 @@ export default function NewsLayout({ children }: { children: ReactNode }) {
collapsible={true}
/>

{(savedLists.length ? savedLists : ["readLater"]).map((list) => (
{(savedLists.length ? savedLists : [{ id: "readLater", name: "readLater" }]).map((list) => (
<Tab
key={list}
dst={`/apps/news/saved-${encodeURIComponent(list)}`}
key={list.id}
dst={`/apps/news/saved-${encodeURIComponent(list.id)}`}
icon="fa6-solid:bookmark"
title={list}
title={list.name}
group="Saved"
/>
))}
Expand Down
9 changes: 7 additions & 2 deletions packages/types/sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,22 @@ export type NewsFeedItem = {

export type NewsSavedArticle = {
id: string;
list: string;
list: string[];
isRead?: boolean;
json: NewsFeedItem;
userId?: string;
created?: string;
updated?: string;
};

export type NewsSavedArticleList = {
id: string;
name: string;
};

export type NewsSavedArticlesResponse = {
articles: NewsSavedArticle[];
lists: string[];
lists: NewsSavedArticleList[];
defaultList: string;
};

Expand Down
Loading