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
2 changes: 2 additions & 0 deletions server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function initializeSchema(db: Database.Database): void {
custom_description TEXT,
custom_tags TEXT,
custom_category TEXT,
category_locked INTEGER DEFAULT 0,
last_edited TEXT,
subscribed_to_releases INTEGER DEFAULT 0
);
Expand Down Expand Up @@ -102,4 +103,5 @@ export function initializeSchema(db: Database.Database): void {
`);

addColumnIfMissing(db, 'ai_configs', 'reasoning_effort', 'TEXT');
addColumnIfMissing(db, 'repositories', 'category_locked', 'INTEGER DEFAULT 0');
}
8 changes: 5 additions & 3 deletions server/src/routes/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function transformRepo(row: Record<string, unknown>) {
custom_description: row.custom_description,
custom_tags: parseJsonColumn(row.custom_tags),
custom_category: row.custom_category,
category_locked: !!row.category_locked,
last_edited: row.last_edited,
subscribed_to_releases: !!row.subscribed_to_releases,
};
Expand Down Expand Up @@ -95,9 +96,9 @@ router.put('/api/repositories', (req, res) => {
created_at, updated_at, pushed_at, starred_at,
owner_login, owner_avatar_url, topics,
ai_summary, ai_tags, ai_platforms, analyzed_at, analysis_failed,
custom_description, custom_tags, custom_category, last_edited,
custom_description, custom_tags, custom_category, category_locked, last_edited,
subscribed_to_releases
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);

const deleteAllReleases = db.prepare('DELETE FROM releases');
Expand Down Expand Up @@ -142,7 +143,7 @@ router.put('/api/repositories', (req, res) => {
repo.analyzed_at ?? null, (repo.analysis_failed === true || repo.analysis_failed === 1) ? 1 : 0,
repo.custom_description ?? null,
JSON.stringify(Array.isArray(repo.custom_tags) ? repo.custom_tags : []),
repo.custom_category ?? null, repo.last_edited ?? null,
repo.custom_category ?? null, (repo.category_locked === true || repo.category_locked === 1) ? 1 : 0, repo.last_edited ?? null,
(repo.subscribed_to_releases === true || repo.subscribed_to_releases === 1) ? 1 : 0
);
count++;
Expand Down Expand Up @@ -174,6 +175,7 @@ router.patch('/api/repositories/:id', (req, res) => {
custom_description: (v) => v,
custom_tags: (v) => JSON.stringify(Array.isArray(v) ? v : []),
custom_category: (v) => v,
category_locked: (v) => (v === true || v === 1) ? 1 : 0,
last_edited: (v) => v,
subscribed_to_releases: (v) => (v === true || v === 1) ? 1 : 0,
description: (v) => v,
Expand Down
6 changes: 3 additions & 3 deletions server/src/routes/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ router.post('/api/sync/import', (req, res) => {
created_at, updated_at, pushed_at, starred_at,
owner_login, owner_avatar_url, topics,
ai_summary, ai_tags, ai_platforms, analyzed_at, analysis_failed,
custom_description, custom_tags, custom_category, last_edited,
custom_description, custom_tags, custom_category, category_locked, last_edited,
subscribed_to_releases
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const r of repos) {
repoStmt.run(
Expand All @@ -119,7 +119,7 @@ router.post('/api/sync/import', (req, res) => {
r.analyzed_at ?? null, r.analysis_failed ? 1 : 0,
r.custom_description ?? null,
typeof r.custom_tags === 'string' ? r.custom_tags : JSON.stringify(r.custom_tags ?? []),
r.custom_category ?? null, r.last_edited ?? null,
r.custom_category ?? null, (r.category_locked === true || r.category_locked === 1) ? 1 : 0, r.last_edited ?? null,
r.subscribed_to_releases ? 1 : 0
);
}
Expand Down
190 changes: 133 additions & 57 deletions src/components/CategorySidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,69 @@
import React, { useState } from 'react';
import {
Folder,
Code,
Globe,
Smartphone,
Database,
Shield,
Gamepad2,
Palette,
Bot,
Wrench,
BookOpen,
Zap,
Users,
BarChart3,
import React, { useMemo, useState } from 'react';
import {
Plus,
Edit3
Edit3,
Trash2,
EyeOff,
} from 'lucide-react';
import { Repository, Category } from '../types';
import { Category, Repository } from '../types';
import { useAppStore, getAllCategories } from '../store/useAppStore';
import { CategoryEditModal } from './CategoryEditModal';
import { forceSyncToBackend } from '../services/autoSync';

interface CategorySidebarProps {
repositories: Repository[];
selectedCategory: string;
onCategorySelect: (category: string) => void;
}


export const CategorySidebar: React.FC<CategorySidebarProps> = ({
repositories,
selectedCategory,
onCategorySelect
}) => {
const {
customCategories,
hiddenDefaultCategoryIds,
deleteCustomCategory,
language
hideDefaultCategory,
language,
updateRepository,
} = useAppStore();

const [editModalOpen, setEditModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
const [dragOverCategoryId, setDragOverCategoryId] = useState<string | null>(null);

const allCategories = getAllCategories(customCategories, language);
const allCategories = getAllCategories(customCategories, language, hiddenDefaultCategoryIds);
const repositoryMap = useMemo(() => new Map(repositories.map(repo => [String(repo.id), repo])), [repositories]);

// Calculate repository count for each category
const getCategoryCount = (category: Category) => {
if (category.id === 'all') return repositories.length;

return repositories.filter(repo => {
// Check custom category first
if (repo.custom_category === category.name) {
return true;
}

// 优先使用AI标签进行匹配

if (repo.ai_tags && repo.ai_tags.length > 0) {
return repo.ai_tags.some(tag =>
category.keywords.some(keyword =>
return repo.ai_tags.some(tag =>
category.keywords.some(keyword =>
tag.toLowerCase().includes(keyword.toLowerCase()) ||
keyword.toLowerCase().includes(tag.toLowerCase())
)
);
}

// 如果没有AI标签,使用传统方式匹配

const repoText = [
repo.name,
repo.description || '',
repo.language || '',
...(repo.topics || []),
repo.ai_summary || ''
].join(' ').toLowerCase();
return category.keywords.some(keyword =>

return category.keywords.some(keyword =>
repoText.includes(keyword.toLowerCase())
);
}).length;
Expand All @@ -92,12 +81,61 @@ export const CategorySidebar: React.FC<CategorySidebarProps> = ({
setEditModalOpen(true);
};

const handleDeleteCategory = async (category: Category) => {
const confirmed = confirm(
t(
`确定删除自定义分类“${category.name}”吗?\n\n仓库会保留,Star 不会取消,只会清空它们的手动分类归属。`,
`Delete custom category "${category.name}"?\n\nRepositories will stay starred. Only their manual category assignment will be cleared.`
)
);

if (!confirmed) return;

deleteCustomCategory(category.id);
await forceSyncToBackend();
};

const handleHideDefaultCategory = async (category: Category) => {
const confirmed = confirm(
t(
`隐藏默认分类“${category.name}”?\n\n这不会删除任何仓库,只是在左侧隐藏这个预设分类。`,
`Hide default category "${category.name}"?\n\nThis will not delete any repositories. It only hides this built-in category from the sidebar.`
)
);

if (!confirmed) return;

hideDefaultCategory(category.id);
await forceSyncToBackend();
};

const handleCloseModal = () => {
setEditModalOpen(false);
setEditingCategory(null);
setIsCreatingCategory(false);
};

const handleDropOnCategory = async (event: React.DragEvent<HTMLButtonElement>, category: Category) => {
event.preventDefault();
setDragOverCategoryId(null);

if (category.id === 'all') return;

const repoId = event.dataTransfer.getData('application/x-gsm-repository-id');
const repository = repositoryMap.get(repoId);
if (!repository) return;

const nextRepo = {
...repository,
custom_category: category.name,
category_locked: true,
last_edited: new Date().toISOString(),
};
Comment on lines +128 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Store a stable category key here, not the localized name.

Line 130 persists category.name, but built-in category names come from the current language. A repo dropped onto a default category in Chinese will stop matching after the user switches to English, because later comparisons use the new localized label. Persist the category id (or another stable key) and derive the display name when rendering.

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

In `@src/components/CategorySidebar.tsx` around lines 128 - 133, The code is
persisting the localized category label by setting nextRepo.custom_category =
category.name; instead persist a stable key (e.g., category.id or category.key)
so the category remains consistent across locale changes; update the assignment
in the nextRepo construction to use category.id (or another stable identifier)
for custom_category, keep category_locked and last_edited as-is, and ensure any
rendering logic reads the stored id and resolves the localized display name when
rendering rather than storing the localized name on the repository object.


updateRepository(nextRepo);
await forceSyncToBackend();
};

const t = (zh: string, en: string) => language === 'zh' ? zh : en;

return (
Expand All @@ -115,21 +153,36 @@ export const CategorySidebar: React.FC<CategorySidebarProps> = ({
<Plus className="w-4 h-4" />
</button>
</div>

<div className="flex gap-2 overflow-x-auto pb-1 lg:block lg:space-y-1 lg:overflow-visible">
{allCategories.map(category => {
const count = getCategoryCount(category);
const isSelected = selectedCategory === category.id;

const isDragTarget = dragOverCategoryId === category.id;

return (
<div key={category.id} className="group shrink-0 lg:shrink">
<button
onClick={() => onCategorySelect(category.id)}
onDragOver={(event) => {
if (category.id === 'all') return;
event.preventDefault();
setDragOverCategoryId(category.id);
}}
onDragLeave={() => {
if (dragOverCategoryId === category.id) {
setDragOverCategoryId(null);
}
}}
onDrop={(event) => handleDropOnCategory(event, category)}
className={`flex min-w-[140px] items-center justify-between px-3 py-2.5 rounded-lg text-left transition-colors lg:w-full ${
isSelected
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: isDragTarget
? 'bg-green-100 text-green-700 ring-2 ring-green-400 dark:bg-green-900 dark:text-green-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={category.id !== 'all' ? t('可将仓库卡片拖到这里快速改分类', 'Drag repository cards here to quickly change category') : undefined}
>
<div className="flex items-center space-x-3 min-w-0 flex-1">
<span className="text-base flex-shrink-0">{category.icon}</span>
Expand All @@ -139,36 +192,59 @@ export const CategorySidebar: React.FC<CategorySidebarProps> = ({
<div className={`relative text-xs px-2 py-1 rounded-full ${
isSelected
? 'bg-blue-200 text-blue-800 dark:bg-blue-800 dark:text-blue-200'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
: isDragTarget
? 'bg-green-200 text-green-800 dark:bg-green-800 dark:text-green-200'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
}`}>
{/* Count badge - shown by default */}
<span className="group-hover:opacity-0 transition-opacity duration-200">
{count}
</span>

{/* Edit button - shown on hover, only for non-"All Categories" */}
{category.id !== 'all' && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditCategory(category);
}}
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-black hover:bg-opacity-10 rounded-full"
title={t('编辑分类', 'Edit category')}
>
<Edit3 className="w-3 h-3" />
</button>
)}
</div>
<span>{count}</span>
</div>

{category.id !== 'all' && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditCategory(category);
}}
className="p-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-200 dark:hover:bg-gray-600"
title={t('编辑分类', 'Edit category')}
>
<Edit3 className="w-3 h-3" />
</button>
)}

{category.id !== 'all' && category.isCustom && (
<button
onClick={(e) => {
e.stopPropagation();
void handleDeleteCategory(category);
}}
className="p-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity text-red-500 hover:bg-red-100 dark:hover:bg-red-900/40"
title={t('删除分类', 'Delete category')}
>
<Trash2 className="w-3 h-3" />
</button>
)}

{category.id !== 'all' && !category.isCustom && (
<button
onClick={(e) => {
e.stopPropagation();
void handleHideDefaultCategory(category);
}}
className="p-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600"
title={t('隐藏默认分类', 'Hide default category')}
>
<EyeOff className="w-3 h-3" />
</button>
)}
</div>
</button>
</div>
);
})}
</div>
</div>

{/* Category Edit Modal */}

<CategoryEditModal
isOpen={editModalOpen}
onClose={handleCloseModal}
Expand Down
Loading
Loading