Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/components/repo/RepoCard.tsx
Original file line number Diff line number Diff line change
@@ -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 & {
Expand All @@ -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;
Expand Down Expand Up @@ -109,6 +111,19 @@ export function RepoCard({ repo, viewMode = 'grid', onClick }: RepoCardProps) {
{repo.description || 'No description provided'}
</p>

{repo.tags && repo.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{repo.tags.map((tag) => (
<span
key={tag.id}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${getTailwindColorClasses(tag.color)}`}
>
{tag.name}
</span>
))}
</div>
)}

<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{repo.language && (
Expand Down
8 changes: 8 additions & 0 deletions src/components/repo/RepoDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,6 +119,13 @@ export function RepoDetail({ repo, isOpen, onClose }: RepoDetailProps) {
</div>
)}

<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
</h3>
<TagManager repoId={repo.id.toString()} />
</div>

<div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('readme')}
Expand Down
5 changes: 3 additions & 2 deletions src/components/repo/RepoGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ interface RepoGridProps {
direction: 'up' | 'down' | 'stable';
};
})[];
repoTags?: Record<string, Array<{ id: string; name: string; color: string }>>;
onRepoClick: (repo: Repo) => void;
}

export function RepoGrid({ repos, onRepoClick }: RepoGridProps) {
export function RepoGrid({ repos, repoTags = {}, onRepoClick }: RepoGridProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{repos.map((repo) => (
<RepoCard
key={repo.id}
repo={repo}
repo={{ ...repo, tags: repoTags[repo.id.toString()] || [] }}
viewMode="grid"
onClick={() => onRepoClick(repo)}
/>
Expand Down
5 changes: 3 additions & 2 deletions src/components/repo/RepoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ interface RepoListProps {
direction: 'up' | 'down' | 'stable';
};
})[];
repoTags?: Record<string, Array<{ id: string; name: string; color: string }>>;
onRepoClick: (repo: Repo) => void;
}

export function RepoList({ repos, onRepoClick }: RepoListProps) {
export function RepoList({ repos, repoTags = {}, onRepoClick }: RepoListProps) {
return (
<div className="space-y-3">
{repos.map((repo) => (
<RepoCard
key={repo.id}
repo={repo}
repo={{ ...repo, tags: repoTags[repo.id.toString()] || [] }}
viewMode="list"
onClick={() => onRepoClick(repo)}
/>
Expand Down
152 changes: 152 additions & 0 deletions src/components/repo/TagManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { getTagsForRepo, getTags, createTag, addRepoToTag, removeRepoFromTag } from '../../services/db';
import { TAG_COLORS, getTailwindColorClasses } from '../../utils/tagColors';
import { useTranslation } from 'react-i18next';

interface TagManagerProps {
repoId: string;
onTagsChange?: () => void;
}

export function TagManager({ repoId, onTagsChange }: TagManagerProps) {
const { t } = useTranslation();
const [repoTags, setRepoTags] = useState<Array<{ id: string; name: string; color: string }>>([]);
const [allTags, setAllTags] = useState<Array<{ id: string; name: string; color: string; repoIds: string[] }>>([]);
const [newTagName, setNewTagName] = useState('');
const [selectedColor, setSelectedColor] = useState(TAG_COLORS[0].value);
const [showCreateForm, setShowCreateForm] = useState(false);

useEffect(() => {
loadTags();
}, [repoId]);

const loadTags = async () => {
const tags = await getTagsForRepo(repoId);
setRepoTags(tags.map((t) => ({ id: t.id, name: t.name, color: t.color })));

const allTagsData = await getTags();
setAllTags(allTagsData);
};

const handleCreateTag = async () => {
if (!newTagName.trim()) return;

const colorKey = TAG_COLORS.find((c) => c.value === selectedColor)?.name.toLowerCase() || 'gray';
await createTag(newTagName.trim(), colorKey, [repoId]);
setNewTagName('');
setShowCreateForm(false);
await loadTags();
onTagsChange?.();
};

const handleAddExistingTag = async (tagId: string) => {
await addRepoToTag(tagId, repoId);
await loadTags();
onTagsChange?.();
};

const handleRemoveTag = async (tagId: string) => {
await removeRepoFromTag(tagId, repoId);
await loadTags();
onTagsChange?.();
};

const availableTags = allTags.filter((tag) => !tag.repoIds.includes(repoId));

return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{repoTags.map((tag) => (
<span
key={tag.id}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${getTailwindColorClasses(tag.color)}`}
>
{tag.name}
<button
onClick={() => handleRemoveTag(tag.id)}
className="hover:opacity-75"
aria-label={t('tags.remove')}
>
×
</button>
</span>
))}

<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
>
+
</button>
</div>

{showCreateForm && (
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('tags.tagName')}
</label>
<input
type="text"
value={newTagName}
onChange={(e) => 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()}
/>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('tags.color')}
</label>
<div className="flex flex-wrap gap-2">
{TAG_COLORS.map((colorOption) => (
<button
key={colorOption.name}
onClick={() => setSelectedColor(colorOption.value)}
className={`w-8 h-8 rounded-full ${colorOption.value} ${
selectedColor === colorOption.value ? 'ring-2 ring-offset-2 ring-blue-500' : ''
}`}
aria-label={colorOption.name}
/>
))}
</div>
</div>

<div className="flex gap-2">
<button
onClick={handleCreateTag}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{t('tags.create')}
</button>
<button
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
</div>
</div>
)}

{availableTags.length > 0 && (
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{t('tags.addExisting')}</p>
<div className="flex flex-wrap gap-2">
{availableTags.map((tag) => (
<button
key={tag.id}
onClick={() => handleAddExistingTag(tag.id)}
className={`px-3 py-1 rounded-full text-sm font-medium ${getTailwindColorClasses(tag.color)} hover:opacity-75`}
>
+ {tag.name}
</button>
))}
</div>
</div>
)}
</div>
);
}
10 changes: 10 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"filters": {
"search": "Search repositories...",
"language": "All Languages",
"tag": "All Tags",
"sort": "Sort by"
},
"sort": {
Expand Down Expand Up @@ -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?"
}
}
10 changes: 10 additions & 0 deletions src/i18n/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"filters": {
"search": "Repo ara...",
"language": "Tüm Diller",
"tag": "Tüm Etiketler",
"sort": "Sırala"
},
"sort": {
Expand Down Expand Up @@ -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?"
}
}
46 changes: 41 additions & 5 deletions src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +24,9 @@ export function Dashboard() {
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedLanguage, setSelectedLanguage] = useState<string>('');
const [selectedTag, setSelectedTag] = useState<string>('');
const [allTags, setAllTags] = useState<Array<{ id: string; name: string; color: string }>>([]);
const [repoTags, setRepoTags] = useState<Record<string, Array<{ id: string; name: string; color: string }>>>({});
const [isInitialLoad, setIsInitialLoad] = useState(true);

const fetchRepos = async (showToast = false) => {
Expand Down Expand Up @@ -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<string, Array<{ id: string; name: string; color: string }>> = {};
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[];
Expand All @@ -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(
Expand Down Expand Up @@ -173,6 +196,19 @@ export function Dashboard() {
))}
</select>

<select
value={selectedTag}
onChange={(e) => setSelectedTag(e.target.value)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">{t('dashboard.filters.tag')}</option>
{allTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>

<div className="flex items-center gap-2 ml-auto">
<Button
variant={viewMode === 'grid' ? 'primary' : 'outline'}
Expand Down Expand Up @@ -213,9 +249,9 @@ export function Dashboard() {
</p>
</div>
) : viewMode === 'grid' ? (
<RepoGrid repos={filteredRepos} onRepoClick={setSelectedRepo} />
<RepoGrid repos={filteredRepos} onRepoClick={setSelectedRepo} repoTags={repoTags} />
) : (
<RepoList repos={filteredRepos} onRepoClick={setSelectedRepo} />
<RepoList repos={filteredRepos} onRepoClick={setSelectedRepo} repoTags={repoTags} />
)}

{selectedRepo && (
Expand Down
Loading