From b36c377bfc9d8b6a5bc25601ea15e8b2e9f838fc Mon Sep 17 00:00:00 2001 From: Vehbi Emiroglu Date: Wed, 4 Mar 2026 14:57:45 +0300 Subject: [PATCH] Add tagging system for starred repositories - Add RepoTag type to types - Add tags table to IndexedDB schema - Create TagManager component for managing tags - Add tag display on repo cards with color coding - Add tag filtering in dashboard - Add tag management in repo detail modal - Add i18n translations (EN/TR) Features: - Create custom tags with different colors - Assign multiple tags to repositories - Filter repos by tags - Tags stored locally in browser --- src/components/repo/RepoCard.tsx | 15 +++ src/components/repo/RepoDetail.tsx | 8 ++ src/components/repo/RepoGrid.tsx | 5 +- src/components/repo/RepoList.tsx | 5 +- src/components/repo/TagManager.tsx | 152 +++++++++++++++++++++++++++++ src/i18n/locales/en.json | 10 ++ src/i18n/locales/tr.json | 10 ++ src/pages/Dashboard.tsx | 46 ++++++++- src/services/db.ts | 54 +++++++++- src/types/index.ts | 7 ++ src/utils/tagColors.ts | 31 ++++++ 11 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 src/components/repo/TagManager.tsx create mode 100644 src/utils/tagColors.ts diff --git a/src/components/repo/RepoCard.tsx b/src/components/repo/RepoCard.tsx index 27f890f..85bd38f 100644 --- a/src/components/repo/RepoCard.tsx +++ b/src/components/repo/RepoCard.tsx @@ -1,6 +1,7 @@ import type { Repo } from '../../types'; import { formatDistanceToNow } from 'date-fns'; import clsx from 'clsx'; +import { getTailwindColorClasses } from '../../utils/tagColors'; interface RepoCardProps { repo: Repo & { @@ -9,6 +10,7 @@ interface RepoCardProps { percentage: number; direction: 'up' | 'down' | 'stable'; }; + tags?: Array<{ id: string; name: string; color: string }>; }; viewMode?: 'grid' | 'list'; onClick: () => void; @@ -109,6 +111,19 @@ export function RepoCard({ repo, viewMode = 'grid', onClick }: RepoCardProps) { {repo.description || 'No description provided'}

+ {repo.tags && repo.tags.length > 0 && ( +
+ {repo.tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} +
{repo.language && ( diff --git a/src/components/repo/RepoDetail.tsx b/src/components/repo/RepoDetail.tsx index 07c5125..3ccd507 100644 --- a/src/components/repo/RepoDetail.tsx +++ b/src/components/repo/RepoDetail.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'; import type { Repo } from '../../types'; import { SimilarRepos } from './SimilarRepos'; import { StarHistoryChart } from './StarHistoryChart'; +import { TagManager } from './TagManager'; import { formatDistanceToNow } from 'date-fns'; import { githubApi } from '../../services/github'; import { Loader } from '../common/Loader'; @@ -118,6 +119,13 @@ export function RepoDetail({ repo, isOpen, onClose }: RepoDetailProps) {
)} +
+

+ Tags +

+ +
+
+ + ))} + + +
+ + {showCreateForm && ( +
+
+ + setNewTagName(e.target.value)} + placeholder={t('tags.placeholder')} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()} + /> +
+ +
+ +
+ {TAG_COLORS.map((colorOption) => ( +
+
+ +
+ + +
+
+ )} + + {availableTags.length > 0 && ( +
+

{t('tags.addExisting')}

+
+ {availableTags.map((tag) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 43d1448..9e5b036 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -36,6 +36,7 @@ "filters": { "search": "Search repositories...", "language": "All Languages", + "tag": "All Tags", "sort": "Sort by" }, "sort": { @@ -90,5 +91,14 @@ "light": "Light", "dark": "Dark", "system": "System" + }, + "tags": { + "tagName": "Tag Name", + "placeholder": "e.g., favorite, work, learning", + "color": "Color", + "create": "Create Tag", + "addExisting": "Or add existing tag:", + "remove": "Remove tag", + "confirmDelete": "Are you sure you want to delete this tag?" } } diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 2c13f4b..820c9fa 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -36,6 +36,7 @@ "filters": { "search": "Repo ara...", "language": "Tüm Diller", + "tag": "Tüm Etiketler", "sort": "Sırala" }, "sort": { @@ -90,5 +91,14 @@ "light": "Açık", "dark": "Koyu", "system": "Sistem" + }, + "tags": { + "tagName": "Etiket Adı", + "placeholder": "örn: favori, iş, öğrenme", + "color": "Renk", + "create": "Etiket Oluştur", + "addExisting": "Veya mevcut etiket ekle:", + "remove": "Etiketi kaldır", + "confirmDelete": "Bu etiketi silmek istediğinize emin misiniz?" } } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index da61d02..ad0b900 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from 'react'; import { useRepoStore } from '../stores/repoStore'; import { useAuthStore } from '../stores/authStore'; import { githubApi } from '../services/github'; -import { saveRepos, updateRepoLastChecked, getPreviousStarCount } from '../services/db'; +import { saveRepos, updateRepoLastChecked, getPreviousStarCount, getTags, getTagsForRepo } from '../services/db'; import { RepoGrid } from '../components/repo/RepoGrid'; import { RepoList } from '../components/repo/RepoList'; import { RepoDetail } from '../components/repo/RepoDetail'; @@ -24,6 +24,9 @@ export function Dashboard() { const [selectedRepo, setSelectedRepo] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selectedLanguage, setSelectedLanguage] = useState(''); + const [selectedTag, setSelectedTag] = useState(''); + const [allTags, setAllTags] = useState>([]); + const [repoTags, setRepoTags] = useState>>({}); const [isInitialLoad, setIsInitialLoad] = useState(true); const fetchRepos = async (showToast = false) => { @@ -71,6 +74,24 @@ export function Dashboard() { } }, [token]); + useEffect(() => { + const loadTags = async () => { + const tags = await getTags(); + setAllTags(tags.map((t) => ({ id: t.id, name: t.name, color: t.color }))); + + const repoTagMap: Record> = {}; + for (const repo of repos) { + const tags = await getTagsForRepo(repo.id.toString()); + repoTagMap[repo.id.toString()] = tags.map((t) => ({ id: t.id, name: t.name, color: t.color })); + } + setRepoTags(repoTagMap); + }; + + if (repos.length > 0) { + loadTags(); + } + }, [repos]); + const languages = useMemo(() => { const langs = new Set(repos.map((r) => r.language).filter(Boolean)); return Array.from(langs) as string[]; @@ -84,10 +105,12 @@ export function Dashboard() { repo.owner.login.toLowerCase().includes(searchQuery.toLowerCase()); const matchesLanguage = !selectedLanguage || repo.language === selectedLanguage; + + const matchesTag = !selectedTag || (repoTags[repo.id.toString()]?.some(t => t.id === selectedTag)); - return matchesSearch && matchesLanguage; + return matchesSearch && matchesLanguage && matchesTag; }); - }, [repos, searchQuery, selectedLanguage]); + }, [repos, searchQuery, selectedLanguage, selectedTag, repoTags]); const totalStars = repos.reduce((sum, r) => sum + r.stargazers_count, 0); const totalStarChange = repos.reduce( @@ -173,6 +196,19 @@ export function Dashboard() { ))} + +
) : viewMode === 'grid' ? ( - + ) : ( - + )} {selectedRepo && ( diff --git a/src/services/db.ts b/src/services/db.ts index addd89b..62d9137 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,11 +1,12 @@ import Dexie, { type Table } from 'dexie'; -import type { Repo, StarHistory, UserPreferences, FollowingData } from '../types'; +import type { Repo, StarHistory, UserPreferences, FollowingData, RepoTag } from '../types'; class GitHubStarDB extends Dexie { repos!: Table; starHistory!: Table; userPreferences!: Table; followingData!: Table; + tags!: Table; constructor() { super('GitHubStarTracker'); @@ -16,6 +17,10 @@ class GitHubStarDB extends Dexie { userPreferences: 'id', followingData: 'id, username, lastFetched', }); + + this.version(2).stores({ + tags: 'id, name, color, repoIds', + }); } } @@ -98,3 +103,50 @@ export async function saveFollowingData(data: FollowingData): Promise { export async function getFollowingData(): Promise { return await db.followingData.toArray(); } + +// Tag operations +export async function getTags(): Promise { + return await db.tags.toArray(); +} + +export async function getTag(id: string): Promise { + return await db.tags.get(id); +} + +export async function createTag(name: string, color: string, repoIds: string[] = []): Promise { + const id = crypto.randomUUID(); + await db.tags.add({ id, name, color, repoIds }); + return id; +} + +export async function updateTag(id: string, updates: Partial): Promise { + await db.tags.update(id, updates); +} + +export async function deleteTag(id: string): Promise { + await db.tags.delete(id); +} + +export async function addRepoToTag(tagId: string, repoId: string): Promise { + const tag = await getTag(tagId); + if (tag && !tag.repoIds.includes(repoId)) { + await updateTag(tagId, { repoIds: [...tag.repoIds, repoId] }); + } +} + +export async function removeRepoFromTag(tagId: string, repoId: string): Promise { + const tag = await getTag(tagId); + if (tag) { + await updateTag(tagId, { repoIds: tag.repoIds.filter((id) => id !== repoId) }); + } +} + +export async function getTagsForRepo(repoId: string): Promise { + const allTags = await getTags(); + return allTags.filter((tag) => tag.repoIds.includes(repoId)); +} + +export async function getReposForTag(tagId: string): Promise { + const tag = await getTag(tagId); + return tag?.repoIds || []; +} diff --git a/src/types/index.ts b/src/types/index.ts index d1bbd7f..f0cefd6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -82,3 +82,10 @@ export interface SimilarRepo { similarityScore: number; matchReasons: string[]; } + +export interface RepoTag { + id: string; + name: string; + color: string; + repoIds: string[]; +} diff --git a/src/utils/tagColors.ts b/src/utils/tagColors.ts new file mode 100644 index 0000000..eb847c5 --- /dev/null +++ b/src/utils/tagColors.ts @@ -0,0 +1,31 @@ +export const TAG_COLORS = [ + { name: 'Red', value: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' }, + { name: 'Orange', value: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300' }, + { name: 'Yellow', value: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300' }, + { name: 'Green', value: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' }, + { name: 'Blue', value: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }, + { name: 'Indigo', value: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300' }, + { name: 'Purple', value: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' }, + { name: 'Pink', value: 'bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300' }, + { name: 'Gray', value: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' }, +]; + +export function getRandomTagColor(): string { + const colorKeys = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'indigo', 'orange']; + return colorKeys[Math.floor(Math.random() * colorKeys.length)]; +} + +export function getTailwindColorClasses(color: string): string { + const colorMap: Record = { + red: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', + blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + green: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', + yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', + purple: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + pink: 'bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300', + indigo: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300', + orange: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + gray: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + }; + return colorMap[color] || colorMap.gray; +}