Skip to content

Commit ea1047f

Browse files
feat: add government update detail view and enhance news feature
Introduced a new component for displaying detailed government updates, including attachments and formatted content. Updated the news feature to navigate to the new detail view for official updates. Enhanced the routing and added localization strings for improved user experience. Updated CI workflow to install frontend dependencies for the desktop app.
1 parent f21c0a5 commit ea1047f

28 files changed

Lines changed: 1111 additions & 79 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ jobs:
6666
uses: Swatinem/rust-cache@v2
6767
with:
6868
workspaces: ". -> target"
69+
# The showcase compiles the desktop source directly. TypeScript resolves
70+
# imports relative to that source, so its dependencies must exist too.
71+
- name: Install desktop frontend dependencies
72+
working-directory: apps/desktop
73+
run: bun install --frozen-lockfile
6974
- name: Install dependencies
7075
working-directory: apps/showcase
7176
run: bun install --frozen-lockfile

apps/desktop/src-tauri/src/article_dates.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,17 @@ mod tests {
172172

173173
fn item(title: &str, published: Option<DateTime<Utc>>) -> NewsItem {
174174
NewsItem {
175+
id: None,
175176
title: title.to_owned(),
176177
link: format!("https://annapurnapost.com/story/{title}"),
177178
source: NewsSource::AnnapurnaPost,
178179
source_name: "Annapurna Post".to_owned(),
179180
published,
180181
precision: sajilo_api::news::DatePrecision::Exact,
182+
content: None,
183+
department: None,
184+
tags: Vec::new(),
185+
attachments: Vec::new(),
181186
}
182187
}
183188

apps/desktop/src-tauri/src/commands/news.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Nine-source RSS headline merge.
1+
//! Publisher headlines plus official Nepal Government updates.
22
33
use chrono::Utc;
44
use sajilo_api::load_state::LoadState;

apps/desktop/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DayDetail } from "./features/calendar/day-detail";
88
import { Events } from "./features/calendar/events";
99
import { Forex } from "./features/forex/forex";
1010
import { Keeper } from "./features/keeper/keeper";
11+
import { GovernmentUpdateDetail } from "./features/news/government-update-detail";
1112
import { News } from "./features/news/news";
1213
import { Radio } from "./features/radio/radio";
1314
import { RadioMiniPlayer } from "./features/radio/radio-mini-player";
@@ -35,6 +36,11 @@ const ROUTES = [
3536
{ path: "/weather", titleKey: "screen.weather", element: <Weather /> },
3637
{ path: "/forex", titleKey: "screen.exchange-rates", element: <Forex /> },
3738
{ path: "/news", titleKey: "screen.news", element: <News /> },
39+
{
40+
path: "/news/government",
41+
titleKey: "screen.official-update",
42+
element: <GovernmentUpdateDetail />,
43+
},
3844
{ path: "/bazar", titleKey: "screen.bazar", element: <Bazar /> },
3945
{ path: "/rashifal", titleKey: "screen.rashifal", element: <Rashifal /> },
4046
{ path: "/radio", titleKey: "screen.radio", element: <Radio /> },
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useSearchParams } from "react-router";
2+
import useSWR from "swr";
3+
import { Icon } from "../../shared/components/icon";
4+
import { StateBanner } from "../../shared/components/state-banner";
5+
import { useSettings } from "../../shared/context/settings-context";
6+
import { openExternalLink } from "../../shared/lib/external-link";
7+
import { api } from "../../shared/lib/ipc";
8+
import { catchAsFailed, loadBanner, loadedValue } from "../../shared/lib/load-state";
9+
import type { NewsAttachment } from "../../types/api/NewsAttachment";
10+
11+
function formatTag(tag: string): string {
12+
return tag
13+
.toLowerCase()
14+
.replaceAll("_", " ")
15+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
16+
}
17+
18+
function formatSize(size: number): string {
19+
if (size < 1024) return `${size} B`;
20+
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
21+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
22+
}
23+
24+
function AttachmentRow({ attachment }: { attachment: NewsAttachment }) {
25+
return (
26+
<button
27+
type="button"
28+
onClick={() => openExternalLink(attachment.url)}
29+
className="row-line flex w-full items-center gap-2 px-2 py-2 text-left transition-colors hover:bg-surface-hover"
30+
>
31+
<Icon name="link" className="size-3.5 shrink-0 text-text-secondary" />
32+
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{attachment.filename}</span>
33+
<span className="shrink-0 text-[10px] text-text-muted">{formatSize(attachment.size)}</span>
34+
</button>
35+
);
36+
}
37+
38+
export function GovernmentUpdateDetail() {
39+
const { language, t } = useSettings();
40+
const [params] = useSearchParams();
41+
const id = params.get("id");
42+
const { data: state } = useSWR("news", () => catchAsFailed(api.getNews(false)));
43+
const digest = loadedValue(state);
44+
const update = digest?.items.find((item) => item.source === "nepalGovernment" && item.id === id);
45+
const banner = loadBanner(state);
46+
47+
return (
48+
<StateBanner state={banner}>
49+
{!update?.content ? (
50+
<div className="surface-card p-2.5">
51+
<p className="text-[12px] font-medium">{t("news.official-unavailable")}</p>
52+
<button
53+
type="button"
54+
onClick={() => openExternalLink("https://nepal.gov.np/updates")}
55+
className="btn-ghost mt-2 -ml-2 text-[11px] text-[color:var(--color-accent-mark)]"
56+
>
57+
{t("news.open-government-portal")}
58+
</button>
59+
</div>
60+
) : (
61+
<article className="px-0.5 pb-1">
62+
<p className="text-[10px] font-semibold uppercase tracking-[0.06em] text-[color:var(--color-accent-mark)]">
63+
{t("news.government-source")}
64+
</p>
65+
<h2 className="mt-1 text-[15px] font-semibold leading-snug text-text">{update.title}</h2>
66+
67+
<div className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[10px] text-text-muted">
68+
{update.department && <span>{update.department}</span>}
69+
{update.department && update.published && <span>·</span>}
70+
{update.published && (
71+
<time dateTime={update.published}>
72+
{new Date(update.published).toLocaleString(language === "ne" ? "ne-NP" : "en-GB", {
73+
day: "numeric",
74+
month: "short",
75+
hour: "numeric",
76+
minute: "2-digit",
77+
timeZone: "Asia/Kathmandu",
78+
})}
79+
</time>
80+
)}
81+
</div>
82+
83+
{update.tags.length > 0 && (
84+
<p className="mt-1 text-[10px] text-text-secondary">
85+
{update.tags.map(formatTag).join(" · ")}
86+
</p>
87+
)}
88+
89+
<div className="section-divider mt-3 space-y-2.5 pt-3 text-[12px] leading-relaxed text-text-secondary select-text">
90+
{update.content
91+
.split(/\n\s*\n/)
92+
.filter(Boolean)
93+
.map((paragraph) => (
94+
<p key={paragraph} className="whitespace-pre-line">
95+
{paragraph}
96+
</p>
97+
))}
98+
</div>
99+
100+
{update.attachments.length > 0 && (
101+
<section className="mt-3">
102+
<h3 className="mb-1.5 text-[11px] font-semibold text-text-secondary">
103+
{t("news.attachments")}
104+
</h3>
105+
<div className="surface-card list-rows overflow-hidden">
106+
{update.attachments.map((attachment) => (
107+
<AttachmentRow key={attachment.id} attachment={attachment} />
108+
))}
109+
</div>
110+
</section>
111+
)}
112+
113+
<button
114+
type="button"
115+
onClick={() => openExternalLink(update.link)}
116+
className="btn-ghost mt-3 -ml-2 text-[11px] text-[color:var(--color-accent-mark)]"
117+
>
118+
{t("news.open-government-portal")}
119+
</button>
120+
</article>
121+
)}
122+
</StateBanner>
123+
);
124+
}

apps/desktop/src/features/news/news.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { useNavigate } from "react-router";
23
import useSWR from "swr";
34
import { useHeaderSlot } from "../../shared/components/header-slot";
45
import { Icon } from "../../shared/components/icon";
@@ -28,6 +29,7 @@ const SOURCE_KEY = "news.source";
2829

2930
export function News() {
3031
const { t } = useSettings();
32+
const navigate = useNavigate();
3133
const [visible, setVisible] = useState(PAGE);
3234
const [sources, setSources] = useState<NewsSourceInfo[]>([]);
3335
const [saved, setSaved] = usePersistedString(SOURCE_KEY);
@@ -117,10 +119,16 @@ export function News() {
117119
onChange={pick}
118120
options={[{ id: ALL, label: t("news.all-sources") }]}
119121
groups={[
122+
{
123+
label: t("news.group-official"),
124+
options: sources
125+
.filter((source) => source.official)
126+
.map((source) => ({ id: source.id as string, label: source.name })),
127+
},
120128
{
121129
label: t("news.group-nepali"),
122130
options: sources
123-
.filter((source) => !source.english)
131+
.filter((source) => !source.english && !source.official)
124132
.map((source) => ({ id: source.id as string, label: source.name })),
125133
},
126134
{
@@ -145,7 +153,11 @@ export function News() {
145153
<HeadlineRow
146154
item={item}
147155
showSource={showSource}
148-
onOpen={() => openExternalLink(item.link)}
156+
onOpen={() =>
157+
item.source === "nepalGovernment" && item.id
158+
? navigate(`/news/government?id=${encodeURIComponent(item.id)}`)
159+
: openExternalLink(item.link)
160+
}
149161
/>
150162
</FadeUp>
151163
))}

apps/desktop/src/i18n/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,16 @@
242242
"screen.exchange-rates": "Exchange Rates",
243243
"news.source": "Source",
244244
"news.all-sources": "All sources",
245+
"news.attachments": "Attachments",
246+
"news.government-source": "Nepal Government",
247+
"news.group-official": "Official",
245248
"news.group-nepali": "Nepali",
246249
"news.group-english": "English",
247250
"news.none-from-source": "No headlines from this source right now.",
251+
"news.official-unavailable": "This official update is no longer available in the current feed.",
252+
"news.open-government-portal": "Open government portal",
248253
"screen.news": "News",
254+
"screen.official-update": "Official update",
249255
"screen.radio": "Radio",
250256
"screen.rashifal": "Rashifal",
251257
"screen.keeper": "Keeper",

apps/desktop/src/i18n/ne.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,16 @@
242242
"screen.exchange-rates": "विनिमय दर",
243243
"news.source": "स्रोत",
244244
"news.all-sources": "सबै स्रोत",
245+
"news.attachments": "संलग्न फाइलहरू",
246+
"news.government-source": "नेपाल सरकार",
247+
"news.group-official": "आधिकारिक",
245248
"news.group-nepali": "नेपाली",
246249
"news.group-english": "अंग्रेजी",
247250
"news.none-from-source": "यो स्रोतबाट अहिले कुनै समाचार छैन।",
251+
"news.official-unavailable": "यो आधिकारिक अपडेट हालको फिडमा उपलब्ध छैन।",
252+
"news.open-government-portal": "सरकारी पोर्टल खोल्नुहोस्",
248253
"screen.news": "समाचार",
254+
"screen.official-update": "आधिकारिक अपडेट",
249255
"screen.radio": "रेडियो",
250256
"screen.rashifal": "राशिफल",
251257
"screen.keeper": "सम्हाल",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2+
3+
/**
4+
* A file attached to an official government update.
5+
*/
6+
export type NewsAttachment = { id: string, filename: string, mimeType: string, size: number, url: string, };
Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
22
import type { DatePrecision } from "./DatePrecision";
3+
import type { NewsAttachment } from "./NewsAttachment";
34
import type { NewsSource } from "./NewsSource";
45

56
/**
67
* A single headline.
78
*
8-
* Deliberately just a title, a link, and where it came from. The feeds
9-
* carry more — OnlineKhabar ships `content:encoded` with the whole article
10-
* body — and none of it is read. Syndicating a feed is not a licence to
11-
* republish what it contains.
9+
* Publisher entries deliberately retain only a title, a link, and where
10+
* they came from. Official government notices additionally retain their
11+
* public body and attachments so Sajilo can provide a usable detail view.
1212
*/
13-
export type NewsItem = { title: string, link: string, source: NewsSource, sourceName: string,
14-
/**
15-
* Absent in some feeds — Annapurna Post publishes no `pubDate` at all
16-
* — so nothing may depend on it being there.
17-
*/
18-
published: string | null, precision: DatePrecision, };
13+
export type NewsItem = { id: string | null, title: string, link: string, source: NewsSource, sourceName: string, published: string | null, precision: DatePrecision, content: string | null, department: string | null, tags: Array<string>, attachments: Array<NewsAttachment>, };

0 commit comments

Comments
 (0)