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
32 changes: 28 additions & 4 deletions src/components/DiscoveryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
Terminal,
Smartphone,
Globe,
X
X,
Calendar
} from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
Expand Down Expand Up @@ -584,6 +585,8 @@ export const DiscoveryView: React.FC = React.memo(() => {
setDiscoveryTotalCount,
setDiscoveryScrollPosition,
appendDiscoveryRepos,
trendingTimeRange,
setTrendingTimeRange,
} = useAppStore();

const [isAnalyzing, setIsAnalyzing] = useState(false);
Expand Down Expand Up @@ -667,7 +670,7 @@ export const DiscoveryView: React.FC = React.memo(() => {

switch (channelId) {
case 'trending':
result = await githubApi.getTrendingRepositories(discoveryPlatform, page);
result = await githubApi.getTrendingRepositories(discoveryPlatform, page, 20, trendingTimeRange);
break;
case 'hot-release':
result = await githubApi.getHotReleaseRepositories(discoveryPlatform, page);
Expand Down Expand Up @@ -735,7 +738,7 @@ export const DiscoveryView: React.FC = React.memo(() => {
} finally {
setDiscoveryLoading(channelId, false);
}
}, [githubToken, t, setDiscoveryLoading, setDiscoveryRepos, setDiscoveryLastRefresh, discoveryPlatform, discoveryLanguage, discoverySortBy, discoverySortOrder, discoverySearchQuery, discoverySelectedTopic, setDiscoveryHasMore, setDiscoveryNextPage, setDiscoveryTotalCount, appendDiscoveryRepos]);
}, [githubToken, t, setDiscoveryLoading, setDiscoveryRepos, setDiscoveryLastRefresh, discoveryPlatform, discoveryLanguage, discoverySortBy, discoverySortOrder, discoverySearchQuery, discoverySelectedTopic, setDiscoveryHasMore, setDiscoveryNextPage, setDiscoveryTotalCount, appendDiscoveryRepos, trendingTimeRange]);

// 切换频道时重置页码、恢复滚动位置,并自动加载空数据
useEffect(() => {
Expand All @@ -755,6 +758,13 @@ export const DiscoveryView: React.FC = React.memo(() => {
}
}, [selectedDiscoveryChannel, refreshChannel]);

// 趋势时间范围改变时刷新数据
useEffect(() => {
if (selectedDiscoveryChannel === 'trending' && trendingTimeRange) {
refreshChannel('trending', 1, false);
}
}, [trendingTimeRange, selectedDiscoveryChannel, refreshChannel]);
Comment on lines +761 to +766

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset pagination when the trending range changes.

This fetches page 1 for the new range but leaves currentPage unchanged. If the user switches ranges while on page 2+, the UI slices the new page-1 results from the old page offset and can render an empty page.

🐛 Proposed pagination fix
   // 趋势时间范围改变时刷新数据
   useEffect(() => {
     if (selectedDiscoveryChannel === 'trending' && trendingTimeRange) {
+      setCurrentPage(1);
       refreshChannel('trending', 1, false);
     }
   }, [trendingTimeRange, selectedDiscoveryChannel, refreshChannel]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/DiscoveryView.tsx` around lines 760 - 765, The useEffect that
reacts to trendingTimeRange changes calls refreshChannel('trending', 1, false)
but does not reset the pagination state, so currentPage remains at the previous
value; update the effect to also reset the pagination state (e.g., call
setCurrentPage(1) or the component's pagination state setter) before/when
invoking refreshChannel so the UI uses page 1 results for the new
trendingTimeRange; locate the useEffect that references trendingTimeRange,
selectedDiscoveryChannel and refreshChannel and add the currentPage reset there
(or modify refreshChannel to accept a flag that resets currentPage internally).


// 主题改变时刷新数据
useEffect(() => {
if (selectedDiscoveryChannel === 'topic' && discoverySelectedTopic) {
Expand Down Expand Up @@ -1054,7 +1064,21 @@ export const DiscoveryView: React.FC = React.memo(() => {

{/* 第二行:筛选和操作按钮 */}
<div className="flex items-center gap-2 flex-wrap">
{selectedDiscoveryChannel === 'topic' && (
{selectedDiscoveryChannel === 'trending' && (
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4 text-gray-400 dark:text-gray-500" />
<select
value={trendingTimeRange}
onChange={(e) => setTrendingTimeRange(e.target.value as TrendingTimeRange)}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-gray-100/80 text-gray-700 dark:bg-gray-700/80 dark:text-gray-300 border border-gray-200 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
>
<option value="daily">{t('今日', 'Today')}</option>
<option value="weekly">{t('本周', 'This Week')}</option>
<option value="monthly">{t('本月', 'This Month')}</option>
</select>
</div>
)}
{selectedDiscoveryChannel === 'topic' && (
<select
value={discoverySelectedTopic || ''}
onChange={(e) => setDiscoverySelectedTopic(e.target.value as TopicCategory | null)}
Expand Down
16 changes: 6 additions & 10 deletions src/components/SubscriptionRepoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,8 @@ export const SubscriptionRepoCard: React.FC<SubscriptionRepoCardProps> = ({ repo

// 点击卡片打开 README
const handleCardClick = useCallback(() => {
if (desktopSafeMode) return;
setReadmeModalOpen(true);
}, [desktopSafeMode]);
}, []);

const cardTitle = repo.full_name || `${repo.owner?.login || ''}/${repo.name || ''}`;

Expand Down Expand Up @@ -388,14 +387,14 @@ export const SubscriptionRepoCard: React.FC<SubscriptionRepoCardProps> = ({ repo
</div>

{/* Description */}
{!desktopSafeMode && repo.description && (
{repo.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
{repo.description}
</p>
)}

{/* AI Summary */}
{!desktopSafeMode && repo.ai_summary && (
{repo.ai_summary && (
<div className="flex items-start gap-1.5 mb-3">
<Bot className="w-4 h-4 text-purple-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-purple-600 dark:text-purple-400 line-clamp-2">
Expand All @@ -405,7 +404,7 @@ export const SubscriptionRepoCard: React.FC<SubscriptionRepoCardProps> = ({ repo
)}

{/* Tags */}
{!desktopSafeMode && ((repo.ai_tags && repo.ai_tags.length > 0) || (repo.topics && repo.topics.length > 0)) && (
{((repo.ai_tags && repo.ai_tags.length > 0) || (repo.topics && repo.topics.length > 0)) && (
<div className="flex flex-wrap gap-1.5 mb-3">
{(repo.ai_tags || repo.topics || []).slice(0, 5).map((tag) => (
<span
Expand All @@ -419,7 +418,7 @@ export const SubscriptionRepoCard: React.FC<SubscriptionRepoCardProps> = ({ repo
)}

{/* Platform icons */}
{!desktopSafeMode && repo.ai_platforms && repo.ai_platforms.length > 0 && (
{repo.ai_platforms && repo.ai_platforms.length > 0 && (
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-gray-400 dark:text-gray-500">
{t('平台:', 'Platforms:')}
Expand Down Expand Up @@ -503,13 +502,10 @@ export const SubscriptionRepoCard: React.FC<SubscriptionRepoCardProps> = ({ repo
</Modal>

{/* README Modal */}
{!desktopSafeMode && (
<ReadmeModal
isOpen={readmeModalOpen}
onClose={() => setReadmeModalOpen(false)}
repository={repo}
/>
)}
repository={repo} />
</>
);
};
148 changes: 125 additions & 23 deletions src/services/githubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,33 +493,135 @@ export class GitHubApiService {
async getTrendingRepositories(
platform: DiscoveryPlatform,
page: number = 1,
perPage: number = 20
perPage: number = 20,
timeRange: TrendingTimeRange = 'weekly'
): Promise<PaginatedDiscoveryRepositories> {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const platformQuery = this.buildPlatformQuery(platform);

let query = `stars:>50 archived:false pushed:>=${thirtyDaysAgo}`;
if (platformQuery) {
query += ` ${platformQuery}`;
}
const rssUrlMap: Record<TrendingTimeRange, string> = {
daily: 'https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml',
weekly: 'https://mshibanami.github.io/GitHubTrendingRSS/weekly/all.xml',
monthly: 'https://mshibanami.github.io/GitHubTrendingRSS/monthly/all.xml',
};
const rssUrl = rssUrlMap[timeRange];

const data = await this.makeRequest<GitHubSearchRepoResponse>(
`/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=${perPage}&page=${page}`
);
try {
const response = await fetch(rssUrl, {
headers: { 'Accept': 'application/rss+xml, application/xml, text/xml' }
});
if (!response.ok) {
throw new Error(`RSS fetch failed: ${response.status}`);
}
const text = await response.text();
const parser = new DOMParser();
const xml = parser.parseFromString(text, 'text/xml');
const items = xml.querySelectorAll('item');

const repos = (data.items || []).map((repo, index) => ({
...repo,
rank: (page - 1) * perPage + index + 1,
channel: 'trending' as DiscoveryChannelId,
platform,
}));
const repos: DiscoveryRepo[] = [];
const startIndex = (page - 1) * perPage;
const endIndex = Math.min(startIndex + perPage, items.length);

return {
repos,
hasMore: repos.length === perPage,
nextPageIndex: page + 1,
totalCount: data.total_count,
};
for (let i = startIndex; i < endIndex; i++) {
const item = items[i];
const title = item.querySelector('title')?.textContent || '';
const link = item.querySelector('link')?.textContent || '';

// Parse description - strip XML/HTML tags
const descriptionEl = item.querySelector('description');
let description = descriptionEl?.textContent || '';
// Decode HTML entities and strip HTML tags
const tempDiv = document.createElement('div');
tempDiv.innerHTML = description;
description = tempDiv.textContent || tempDiv.innerText || '';
// Clean up extra whitespace
description = description.replace(/\s+/g, ' ').trim();
Comment on lines +527 to +535

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid innerHTML for external RSS content decoding.

The RSS description is untrusted external content. Using innerHTML creates a DOM node that could execute scripts if the feed is compromised. Use DOMParser with a sandboxed context to safely decode HTML entities and extract text content instead.

🛡️ Safer decode approach
-        // Decode HTML entities and strip HTML tags
-        const tempDiv = document.createElement('div');
-        tempDiv.innerHTML = description;
-        description = tempDiv.textContent || tempDiv.innerText || '';
+        // Decode HTML entities and strip HTML tags without assigning untrusted markup to the live DOM
+        const decodedDoc = new DOMParser().parseFromString(description, 'text/html');
+        description = decodedDoc.body.textContent || '';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Parse description - strip XML/HTML tags
const descriptionEl = item.querySelector('description');
let description = descriptionEl?.textContent || '';
// Decode HTML entities and strip HTML tags
const tempDiv = document.createElement('div');
tempDiv.innerHTML = description;
description = tempDiv.textContent || tempDiv.innerText || '';
// Clean up extra whitespace
description = description.replace(/\s+/g, ' ').trim();
// Parse description - strip XML/HTML tags
const descriptionEl = item.querySelector('description');
let description = descriptionEl?.textContent || '';
// Decode HTML entities and strip HTML tags without assigning untrusted markup to the live DOM
const decodedDoc = new DOMParser().parseFromString(description, 'text/html');
description = decodedDoc.body.textContent || '';
// Clean up extra whitespace
description = description.replace(/\s+/g, ' ').trim();
🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 531-531: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: tempDiv.innerHTML = description
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html

(unsafe-html-content-assignment)


[warning] 531-531: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: tempDiv.innerHTML = description
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/githubApi.ts` around lines 527 - 535, The current RSS
description decoding uses a temporary DOM node and tempDiv.innerHTML which risks
executing untrusted markup; replace that logic by feeding the raw description
string (from descriptionEl?.textContent) into a standalone DOMParser
(parseFromString(..., 'text/html')) and extract the safe text via the
parsedDocument.body.textContent (or textContent fallback) to decode entities and
strip tags without touching document.innerHTML; update the block around
descriptionEl, tempDiv and description to use DOMParser and remove
creation/appending of any DOM nodes.


// Parse link to get owner/repo
const match = link.match(/github\.com\/([^\/]+)\/([^\/\?#]+)/);
const owner = match?.[1] || '';
const repoName = match?.[2] || title;

// Extract stars and forks from description (format like "⭐ 1,234 | 🍴 456")
const starsMatch = description.match(/⭐\s*([\d,]+)/);
const forksMatch = description.match(/🍴\s*([\d,]+)/);
const stars = starsMatch ? parseInt(starsMatch[1].replace(/,/g, '')) : 0;
const forks = forksMatch ? parseInt(forksMatch[1].replace(/,/g, '')) : 0;

repos.push({
id: 0, // will be filled by GitHub API
name: repoName,
full_name: `${owner}/${repoName}`,
description: description,
html_url: link,
stargazers_count: stars,
forks_count: forks,
forks: forks,
language: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
pushed_at: new Date().toISOString(),
owner: {
login: owner,
avatar_url: `https://github.com/${owner}.png`,
},
topics: [],
rank: i + 1,
channel: 'trending' as DiscoveryChannelId,
platform,
});
}

// Supplement missing fields via GitHub API
const reposNeedUpdate = repos.filter(r => r.id === 0 || r.stargazers_count === 0 || r.forks_count === 0 || !r.language);
if (reposNeedUpdate.length > 0) {
await Promise.all(reposNeedUpdate.map(async (r) => {
try {
const [owner, repo] = r.full_name.split('/');
if (!owner || !repo) return;
const data = await this.makeRequest<{
id: number;
stargazers_count: number;
forks_count: number;
forks: number;
language: string | null;
description: string | null;
topics: string[];
created_at: string;
updated_at: string;
pushed_at: string;
}>(`/repos/${owner}/${repo}`);
r.id = data.id;
r.stargazers_count = data.stargazers_count ?? r.stargazers_count;
r.forks_count = data.forks_count ?? r.forks_count;
r.forks = data.forks ?? r.forks;
r.language = data.language ?? r.language;
r.topics = data.topics ?? r.topics;
r.created_at = data.created_at ?? r.created_at;
r.updated_at = data.updated_at ?? r.updated_at;
r.pushed_at = data.pushed_at ?? r.pushed_at;
// Use GitHub API description as fallback (RSS description may contain emoji markers)
if (data.description) {
r.description = data.description;
}
} catch (e) {
console.warn(`Failed to fetch repo details for ${r.full_name}:`, e);
}
// Avoid GitHub API rate limiting
await new Promise(resolve => setTimeout(resolve, 80));
}));
Comment on lines +548 to +609

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep fallback IDs unique and actually throttle supplemental GitHub calls.

If /repos/{owner}/{repo} fails, every repo keeps id: 0, which breaks React keys and store updates by id. Also, Promise.all starts all requests immediately, so the delay at Line 608 does not throttle request bursts.

🐛 Proposed reliability fix
         repos.push({
-          id: 0, // will be filled by GitHub API
+          id: -(i + 1), // temporary unique id; replaced by GitHub API when enrichment succeeds
           name: repoName,
           full_name: `${owner}/${repoName}`,
@@
-      const reposNeedUpdate = repos.filter(r => r.id === 0 || r.stargazers_count === 0 || r.forks_count === 0 || !r.language);
+      const reposNeedUpdate = repos.filter(r => r.id < 0 || r.stargazers_count === 0 || r.forks_count === 0 || !r.language);
       if (reposNeedUpdate.length > 0) {
-        await Promise.all(reposNeedUpdate.map(async (r) => {
+        for (const r of reposNeedUpdate) {
           try {
             const [owner, repo] = r.full_name.split('/');
-            if (!owner || !repo) return;
+            if (!owner || !repo) continue;
             const data = await this.makeRequest<{
               id: number;
@@
           } catch (e) {
             console.warn(`Failed to fetch repo details for ${r.full_name}:`, e);
           }
           // Avoid GitHub API rate limiting
           await new Promise(resolve => setTimeout(resolve, 80));
-        }));
+        }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
repos.push({
id: 0, // will be filled by GitHub API
name: repoName,
full_name: `${owner}/${repoName}`,
description: description,
html_url: link,
stargazers_count: stars,
forks_count: forks,
forks: forks,
language: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
pushed_at: new Date().toISOString(),
owner: {
login: owner,
avatar_url: `https://github.com/${owner}.png`,
},
topics: [],
rank: i + 1,
channel: 'trending' as DiscoveryChannelId,
platform,
});
}
// Supplement missing fields via GitHub API
const reposNeedUpdate = repos.filter(r => r.id === 0 || r.stargazers_count === 0 || r.forks_count === 0 || !r.language);
if (reposNeedUpdate.length > 0) {
await Promise.all(reposNeedUpdate.map(async (r) => {
try {
const [owner, repo] = r.full_name.split('/');
if (!owner || !repo) return;
const data = await this.makeRequest<{
id: number;
stargazers_count: number;
forks_count: number;
forks: number;
language: string | null;
description: string | null;
topics: string[];
created_at: string;
updated_at: string;
pushed_at: string;
}>(`/repos/${owner}/${repo}`);
r.id = data.id;
r.stargazers_count = data.stargazers_count ?? r.stargazers_count;
r.forks_count = data.forks_count ?? r.forks_count;
r.forks = data.forks ?? r.forks;
r.language = data.language ?? r.language;
r.topics = data.topics ?? r.topics;
r.created_at = data.created_at ?? r.created_at;
r.updated_at = data.updated_at ?? r.updated_at;
r.pushed_at = data.pushed_at ?? r.pushed_at;
// Use GitHub API description as fallback (RSS description may contain emoji markers)
if (data.description) {
r.description = data.description;
}
} catch (e) {
console.warn(`Failed to fetch repo details for ${r.full_name}:`, e);
}
// Avoid GitHub API rate limiting
await new Promise(resolve => setTimeout(resolve, 80));
}));
repos.push({
id: -(i + 1), // temporary unique id; replaced by GitHub API when enrichment succeeds
name: repoName,
full_name: `${owner}/${repoName}`,
description: description,
html_url: link,
stargazers_count: stars,
forks_count: forks,
forks: forks,
language: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
pushed_at: new Date().toISOString(),
owner: {
login: owner,
avatar_url: `https://github.com/${owner}.png`,
},
topics: [],
rank: i + 1,
channel: 'trending' as DiscoveryChannelId,
platform,
});
}
// Supplement missing fields via GitHub API
const reposNeedUpdate = repos.filter(r => r.id < 0 || r.stargazers_count === 0 || r.forks_count === 0 || !r.language);
if (reposNeedUpdate.length > 0) {
for (const r of reposNeedUpdate) {
try {
const [owner, repo] = r.full_name.split('/');
if (!owner || !repo) continue;
const data = await this.makeRequest<{
id: number;
stargazers_count: number;
forks_count: number;
forks: number;
language: string | null;
description: string | null;
topics: string[];
created_at: string;
updated_at: string;
pushed_at: string;
}>(`/repos/${owner}/${repo}`);
r.id = data.id;
r.stargazers_count = data.stargazers_count ?? r.stargazers_count;
r.forks_count = data.forks_count ?? r.forks_count;
r.forks = data.forks ?? r.forks;
r.language = data.language ?? r.language;
r.topics = data.topics ?? r.topics;
r.created_at = data.created_at ?? r.created_at;
r.updated_at = data.updated_at ?? r.updated_at;
r.pushed_at = data.pushed_at ?? r.pushed_at;
// Use GitHub API description as fallback (RSS description may contain emoji markers)
if (data.description) {
r.description = data.description;
}
} catch (e) {
console.warn(`Failed to fetch repo details for ${r.full_name}:`, e);
}
// Avoid GitHub API rate limiting
await new Promise(resolve => setTimeout(resolve, 80));
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/githubApi.ts` around lines 548 - 609, The supplemental-update
block leaves r.id as 0 on API failure and spawns all requests at once; change
Promise.all(reposNeedUpdate.map(...)) to a sequential or concurrency-limited
loop (e.g., for...of with await or use a p-limit) so the await setTimeout(...)
actually throttles requests, and inside the catch ensure you assign a stable,
unique fallback id instead of 0 (e.g., set r.id to a generated negative or
composite id using r.rank, channel or a monotonic counter) so React keys/store
updates won't break; keep the rest of the makeRequest(...) handling and the
existing field fallbacks but move the 80ms delay into the sequential flow so it
limits bursts.

}

// Assign rank based on position
repos.forEach((r, idx) => { r.rank = startIndex + idx + 1; });

return {
repos,
hasMore: endIndex < items.length,
nextPageIndex: page + 1,
totalCount: items.length,
};
} catch (error) {
console.error('Failed to fetch trending from RSS:', error);
return { repos: [], hasMore: false, nextPageIndex: 1, totalCount: 0 };
}
}

async getHotReleaseRepositories(
Expand Down
25 changes: 14 additions & 11 deletions src/store/useAppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ProgrammingLanguage,
SortBy,
SortOrder,
TrendingTimeRange,
TopicCategory,
SubscriptionChannel,
defaultSubscriptionChannels
Expand Down Expand Up @@ -181,6 +182,7 @@ interface AppActions {
setDiscoveryNextPage: (channel: DiscoveryChannelId, page: number) => void;
setDiscoveryTotalCount: (channel: DiscoveryChannelId, count: number) => void;
setDiscoveryScrollPosition: (channel: DiscoveryChannelId, position: number) => void;
setTrendingTimeRange: (range: TrendingTimeRange) => void;
appendDiscoveryRepos: (channel: DiscoveryChannelId, repos: DiscoveryRepo[]) => void;
}

Expand Down Expand Up @@ -321,10 +323,9 @@ const normalizePersistedState = (
}

return {
...defaultChannel,
...(persistedChannel as Partial<typeof defaultChannel>),
enabled: persistedChannel.enabled !== false,
};
...defaultChannel,
enabled: persistedChannel.enabled !== false,
};
});
})(),
discoveryRepos: (() => {
Expand Down Expand Up @@ -406,6 +407,7 @@ const normalizePersistedState = (
})(),
// discoveryScrollPositions 不持久化,始终重置为 0
discoveryScrollPositions: { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 },
trendingTimeRange: 'weekly' as TrendingTimeRange,
// 确保 subscription 相关状态包含 trending 键
subscriptionRepos: {
'most-stars': [],
Expand Down Expand Up @@ -563,10 +565,10 @@ const defaultPresetFilters: AssetFilter[] = PRESET_FILTERS.map(pf => ({
const defaultDiscoveryChannels: DiscoveryChannel[] = [
{
id: 'trending',
name: '热门仓库',
name: '趋势',
nameEn: 'Trending',
icon: 'trending',
description: '最近30天内星标数超过50的热门仓库',
description: 'GitHub 趋势仓库,支持今日/本周/本月筛选',
enabled: true,
},
{
Expand Down Expand Up @@ -659,6 +661,7 @@ export const useAppStore = create<AppState & AppActions>()(
discoveryNextPage: { 'trending': 1, 'hot-release': 1, 'most-popular': 1, 'topic': 1, 'search': 1 },
discoveryTotalCount: { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 },
discoveryScrollPositions: { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 },
trendingTimeRange: 'weekly' as TrendingTimeRange,

// Subscription
subscriptionRepos: { 'most-stars': [], 'most-forks': [], 'most-dev': [], 'trending': [] },
Expand Down Expand Up @@ -1209,7 +1212,8 @@ export const useAppStore = create<AppState & AppActions>()(
setDiscoveryTotalCount: (channel, count) => set((state) => ({
discoveryTotalCount: { ...state.discoveryTotalCount, [channel]: count },
})),
setDiscoveryScrollPosition: (channel, position) => set((state) => ({
setTrendingTimeRange: (range) => set({ trendingTimeRange: range }),
setDiscoveryScrollPosition: (channel, position) => set((state) => ({
discoveryScrollPositions: { ...state.discoveryScrollPositions, [channel]: position },
})),
appendDiscoveryRepos: (channel, repos) => set((state) => ({
Expand Down Expand Up @@ -1351,10 +1355,9 @@ export const useAppStore = create<AppState & AppActions>()(
}

return {
...defaultChannel,
...(persistedChannel as Partial<typeof defaultChannel>),
enabled: persistedChannel.enabled !== false,
};
...defaultChannel,
enabled: persistedChannel.enabled !== false,
};
});
}
// 迁移订阅频道(版本 4→5:daily-dev → most-dev,新增 trending,补全 nameEn)
Expand Down
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export interface AppState {
discoveryNextPage: Record<DiscoveryChannelId, number>;
discoveryTotalCount: Record<DiscoveryChannelId, number>;
discoveryScrollPositions: Record<DiscoveryChannelId, number>;
trendingTimeRange: TrendingTimeRange;

// Subscription
subscriptionRepos: Record<string, SubscriptionRepo[]>;
Expand Down Expand Up @@ -280,6 +281,8 @@ export interface DiscoveryRepo extends Repository {
platform: DiscoveryPlatform;
}

export type TrendingTimeRange = 'daily' | 'weekly' | 'monthly';

export type TopicCategory =
| 'ai'
| 'ml'
Expand Down
Loading