From 93a8ade927cd0c4f9d696dd31d8dbba32b1f7600 Mon Sep 17 00:00:00 2001 From: Jiri Manas <169147815+manana2520@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:06:05 +0100 Subject: [PATCH] Unify dashboard with repository visibility filters, dual icons, and sub-filters (#17) Replaces the separate public/private repository pages with a unified dashboard featuring: - **4 view tabs**: All, Public, Organization, My Repos - **Dual icons**: Globe + Building2 for repos that are both public and in a department - **Sub-filter chips**: contextual filters within each view (Public only, Private only, Shared/Not shared) - **Admin restrict/unrestrict**: toggle visibility of repos for non-admin users - **Share toggle**: users can share personal repos with their organization - **Race condition protection**: loadIdRef counter prevents stale async responses - **Stale state clearing**: view switches immediately clear previous data - Organization endpoints: share, unshare, restrict, unrestrict - OrganizationService with department-based repository access - DepartmentRepositoryInfo DTO with IsPublic, IsRestricted fields - Unified PublicRepositoryList with 4 views + sub-filters - PublicRepositoryCard with dual icons and context-aware toggles - Organization API client with fetchWithAuth for admin endpoints - i18n keys for all new UI text (en, zh, ko, ja) --- .../Endpoints/OrganizationEndpoints.cs | 66 ++- .../Organizations/IOrganizationService.cs | 14 +- .../Organizations/OrganizationService.cs | 147 ++++- .../Repositories/RepositoryService.cs | 5 +- web/app/(main)/page.tsx | 45 +- web/app/(main)/private/github-import/page.tsx | 2 +- web/app/(main)/private/page.tsx | 80 +-- web/app/sidebar.tsx | 4 +- web/components/repo/language-tags.tsx | 18 +- .../repo/public-repository-card.tsx | 101 +++- .../repo/public-repository-list.tsx | 559 +++++++++++++++++- web/components/repo/repo-shell.tsx | 3 +- web/i18n/messages/en/home.json | 26 + web/i18n/messages/ja/home.json | 26 + web/i18n/messages/ko/home.json | 26 + web/i18n/messages/zh/home.json | 26 + web/lib/organization-api.ts | 39 +- web/types/repository.ts | 2 + 18 files changed, 1045 insertions(+), 144 deletions(-) diff --git a/src/OpenDeepWiki/Endpoints/OrganizationEndpoints.cs b/src/OpenDeepWiki/Endpoints/OrganizationEndpoints.cs index fe78812b..5d1ce104 100644 --- a/src/OpenDeepWiki/Endpoints/OrganizationEndpoints.cs +++ b/src/OpenDeepWiki/Endpoints/OrganizationEndpoints.cs @@ -33,17 +33,77 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute // 获取当前用户部门下的仓库列表 group.MapGet("/my-repositories", async ( ClaimsPrincipal user, - [FromServices] IOrganizationService orgService) => + [FromServices] IOrganizationService orgService, + [FromQuery] bool? includeRestricted) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); - var result = await orgService.GetDepartmentRepositoriesAsync(userId); + var result = await orgService.GetDepartmentRepositoriesAsync(userId, includeRestricted ?? false); return Results.Ok(new { success = true, data = result }); }) .WithName("GetMyDepartmentRepositories") - .WithSummary("获取当前用户部门下的仓库列表"); + .WithSummary("Get repository list for current user's departments"); + + // Share a repository with current user's departments + group.MapPost("/my-repositories/{repositoryId}/share", async ( + string repositoryId, + ClaimsPrincipal user, + [FromServices] IOrganizationService orgService) => + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Results.Unauthorized(); + + var result = await orgService.ShareRepositoryWithMyDepartmentsAsync(userId, repositoryId); + return Results.Ok(new { success = result }); + }) + .WithName("ShareRepositoryWithMyDepartments") + .WithSummary("Share a repository with current user's departments"); + + // Unshare a repository from current user's departments + group.MapDelete("/my-repositories/{repositoryId}/share", async ( + string repositoryId, + ClaimsPrincipal user, + [FromServices] IOrganizationService orgService) => + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Results.Unauthorized(); + + var result = await orgService.UnshareRepositoryFromMyDepartmentsAsync(userId, repositoryId); + return Results.Ok(new { success = result }); + }) + .WithName("UnshareRepositoryFromMyDepartments") + .WithSummary("Unshare a repository from current user's departments"); + + // Admin-only endpoints for repository restriction + var adminGroup = group.MapGroup("/repositories").RequireAuthorization("AdminOnly"); + + adminGroup.MapPost("/{repositoryId}/restrict", async ( + string repositoryId, + ClaimsPrincipal user, + [FromServices] IOrganizationService orgService) => + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); + + var result = await orgService.RestrictRepositoryInDepartmentsAsync(repositoryId, userId); + return result ? Results.Ok(new { success = true }) : Results.BadRequest(new { success = false }); + }).WithName("RestrictRepository"); + + adminGroup.MapPost("/{repositoryId}/unrestrict", async ( + string repositoryId, + ClaimsPrincipal user, + [FromServices] IOrganizationService orgService) => + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); + + var result = await orgService.UnrestrictRepositoryInDepartmentsAsync(repositoryId, userId); + return result ? Results.Ok(new { success = true }) : Results.BadRequest(new { success = false }); + }).WithName("UnrestrictRepository"); return app; } diff --git a/src/OpenDeepWiki/Services/Organizations/IOrganizationService.cs b/src/OpenDeepWiki/Services/Organizations/IOrganizationService.cs index 8b3628a8..fd7e9d55 100644 --- a/src/OpenDeepWiki/Services/Organizations/IOrganizationService.cs +++ b/src/OpenDeepWiki/Services/Organizations/IOrganizationService.cs @@ -8,7 +8,11 @@ namespace OpenDeepWiki.Services.Organizations; public interface IOrganizationService { Task> GetUserDepartmentsAsync(string userId); - Task> GetDepartmentRepositoriesAsync(string userId); + Task> GetDepartmentRepositoriesAsync(string userId, bool includeRestricted = false); + Task ShareRepositoryWithMyDepartmentsAsync(string userId, string repositoryId); + Task UnshareRepositoryFromMyDepartmentsAsync(string userId, string repositoryId); + Task RestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId); + Task UnrestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId); } /// @@ -35,4 +39,12 @@ public class DepartmentRepositoryInfo public string StatusName { get; set; } = string.Empty; public string DepartmentId { get; set; } = string.Empty; public string DepartmentName { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public string? PrimaryLanguage { get; set; } + public bool IsRestricted { get; set; } + /// + /// Whether the repository is publicly accessible. Used by frontend to show + /// dual icons (public + org) for repos that are both public and department-assigned. + /// + public bool IsPublic { get; set; } } diff --git a/src/OpenDeepWiki/Services/Organizations/OrganizationService.cs b/src/OpenDeepWiki/Services/Organizations/OrganizationService.cs index d36c0b37..04f31b90 100644 --- a/src/OpenDeepWiki/Services/Organizations/OrganizationService.cs +++ b/src/OpenDeepWiki/Services/Organizations/OrganizationService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using OpenDeepWiki.EFCore; +using OpenDeepWiki.Entities; namespace OpenDeepWiki.Services.Organizations; @@ -37,7 +38,7 @@ public async Task> GetUserDepartmentsAsync(string userI }).ToList(); } - public async Task> GetDepartmentRepositoriesAsync(string userId) + public async Task> GetDepartmentRepositoriesAsync(string userId, bool includeRestricted = false) { // 获取用户所属的部门 var userDeptIds = await _context.UserDepartments @@ -53,10 +54,14 @@ public async Task> GetDepartmentRepositoriesAsync .Where(d => userDeptIds.Contains(d.Id) && d.IsActive) .ToDictionaryAsync(d => d.Id); - // 获取这些部门分配的仓库 - var assignments = await _context.RepositoryAssignments - .Where(ra => userDeptIds.Contains(ra.DepartmentId) && !ra.IsDeleted) - .ToListAsync(); + // Get repositories assigned to these departments + var assignmentsQuery = _context.RepositoryAssignments + .Where(ra => userDeptIds.Contains(ra.DepartmentId)); + + if (!includeRestricted) + assignmentsQuery = assignmentsQuery.Where(ra => !ra.IsDeleted); + + var assignments = await assignmentsQuery.ToListAsync(); var repoIds = assignments.Select(a => a.RepositoryId).Distinct().ToList(); var repos = await _context.Repositories @@ -74,12 +79,142 @@ public async Task> GetDepartmentRepositoriesAsync Status = (int)repos[a.RepositoryId].Status, StatusName = GetStatusName((int)repos[a.RepositoryId].Status), DepartmentId = a.DepartmentId, - DepartmentName = depts[a.DepartmentId].Name + DepartmentName = depts[a.DepartmentId].Name, + CreatedAt = repos[a.RepositoryId].CreatedAt, + PrimaryLanguage = repos[a.RepositoryId].PrimaryLanguage, + IsRestricted = a.IsDeleted, + IsPublic = repos[a.RepositoryId].IsPublic }) .DistinctBy(r => r.RepositoryId) .ToList(); } + public async Task ShareRepositoryWithMyDepartmentsAsync(string userId, string repositoryId) + { + var repo = await _context.Repositories.FirstOrDefaultAsync(r => r.Id == repositoryId && !r.IsDeleted); + if (repo == null || repo.OwnerUserId != userId) return false; + + var userDeptIds = await _context.UserDepartments + .Where(ud => ud.UserId == userId && !ud.IsDeleted) + .Select(ud => ud.DepartmentId) + .ToListAsync(); + + if (userDeptIds.Count == 0) return false; + + // Get ALL existing assignments (including soft-deleted) + var existingAssignments = await _context.RepositoryAssignments + .Where(ra => ra.RepositoryId == repositoryId && userDeptIds.Contains(ra.DepartmentId)) + .ToListAsync(); + + foreach (var deptId in userDeptIds) + { + var existing = existingAssignments.FirstOrDefault(a => a.DepartmentId == deptId); + if (existing != null) + { + // Un-soft-delete if it was restricted + if (existing.IsDeleted) + { + existing.IsDeleted = false; + existing.DeletedAt = null; + } + } + else + { + _context.RepositoryAssignments.Add(new RepositoryAssignment + { + Id = Guid.NewGuid().ToString("N"), + RepositoryId = repositoryId, + DepartmentId = deptId, + AssigneeUserId = userId + }); + } + } + + await _context.SaveChangesAsync(); + return true; + } + + public async Task UnshareRepositoryFromMyDepartmentsAsync(string userId, string repositoryId) + { + // 1. Verify user owns the repository + var repo = await _context.Repositories.FirstOrDefaultAsync(r => r.Id == repositoryId && !r.IsDeleted); + if (repo == null || repo.OwnerUserId != userId) return false; + + // 2. Get user's departments + var userDeptIds = await _context.UserDepartments + .Where(ud => ud.UserId == userId && !ud.IsDeleted) + .Select(ud => ud.DepartmentId) + .ToListAsync(); + + // 3. Soft-delete assignments for user's departments + var assignments = await _context.RepositoryAssignments + .Where(ra => ra.RepositoryId == repositoryId && userDeptIds.Contains(ra.DepartmentId) && !ra.IsDeleted) + .ToListAsync(); + + foreach (var assignment in assignments) + { + assignment.IsDeleted = true; + assignment.DeletedAt = DateTime.UtcNow; + } + + await _context.SaveChangesAsync(); + return true; + } + + public async Task RestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId) + { + // Get admin's departments + var adminDeptIds = await _context.UserDepartments + .Where(ud => ud.UserId == adminUserId && !ud.IsDeleted) + .Select(ud => ud.DepartmentId) + .ToListAsync(); + + if (adminDeptIds.Count == 0) return false; + + // Soft-delete active assignments for these departments + var assignments = await _context.RepositoryAssignments + .Where(ra => ra.RepositoryId == repositoryId && adminDeptIds.Contains(ra.DepartmentId) && !ra.IsDeleted) + .ToListAsync(); + + if (assignments.Count == 0) return false; + + foreach (var assignment in assignments) + { + assignment.IsDeleted = true; + assignment.DeletedAt = DateTime.UtcNow; + } + + await _context.SaveChangesAsync(); + return true; + } + + public async Task UnrestrictRepositoryInDepartmentsAsync(string repositoryId, string adminUserId) + { + // Get admin's departments + var adminDeptIds = await _context.UserDepartments + .Where(ud => ud.UserId == adminUserId && !ud.IsDeleted) + .Select(ud => ud.DepartmentId) + .ToListAsync(); + + if (adminDeptIds.Count == 0) return false; + + // Un-soft-delete restricted assignments for these departments + var assignments = await _context.RepositoryAssignments + .Where(ra => ra.RepositoryId == repositoryId && adminDeptIds.Contains(ra.DepartmentId) && ra.IsDeleted) + .ToListAsync(); + + if (assignments.Count == 0) return false; + + foreach (var assignment in assignments) + { + assignment.IsDeleted = false; + assignment.DeletedAt = null; + } + + await _context.SaveChangesAsync(); + return true; + } + private static string GetStatusName(int status) => status switch { 0 => "Pending", diff --git a/src/OpenDeepWiki/Services/Repositories/RepositoryService.cs b/src/OpenDeepWiki/Services/Repositories/RepositoryService.cs index ef8077e9..a531c6a3 100644 --- a/src/OpenDeepWiki/Services/Repositories/RepositoryService.cs +++ b/src/OpenDeepWiki/Services/Repositories/RepositoryService.cs @@ -4,12 +4,13 @@ using OpenDeepWiki.Entities; using OpenDeepWiki.Models; using OpenDeepWiki.Services.Auth; +using OpenDeepWiki.Services.GitHub; namespace OpenDeepWiki.Services.Repositories; [MiniApi(Route = "/api/v1/repositories")] -[Tags("仓库")] -public class RepositoryService(IContext context, IGitPlatformService gitPlatformService, IUserContext userContext) +[Tags("Repository")] +public class RepositoryService(IContext context, IGitPlatformService gitPlatformService, IUserContext userContext, IGitHubAppService gitHubAppService) { [HttpPost("/submit")] public async Task SubmitAsync([FromBody] RepositorySubmitRequest request) diff --git a/web/app/(main)/page.tsx b/web/app/(main)/page.tsx index 95df1e0e..c3ab14bc 100644 --- a/web/app/(main)/page.tsx +++ b/web/app/(main)/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useCallback } from "react"; -import { useRouter } from "next/navigation"; +import { useState, useCallback, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { AppLayout } from "@/components/app-layout"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -16,13 +16,21 @@ import { import { useAuth } from "@/contexts/auth-context"; import { useScrollPosition } from "@/hooks/use-scroll-position"; import { PublicRepositoryList } from "@/components/repo/public-repository-list"; +import type { RepositoryView } from "@/components/repo/public-repository-list"; import { cn } from "@/lib/utils"; -export default function Home() { +function HomeContent() { const t = useTranslations(); const router = useRouter(); + const searchParams = useSearchParams(); const { user } = useAuth(); - const [activeItem, setActiveItem] = useState(t("sidebar.explore")); + + const viewParam = (searchParams.get("view") as RepositoryView) || "public"; + + const activeItem = (viewParam === "organization" || viewParam === "mine") + ? t("sidebar.private") + : t("sidebar.explore"); + const [isFormOpen, setIsFormOpen] = useState(false); const [isIntegrationsOpen, setIsIntegrationsOpen] = useState(false); const [keyword, setKeyword] = useState(""); @@ -40,10 +48,21 @@ export default function Home() { setIsFormOpen(true); }, [user, router]); + const handleViewChange = useCallback((newView: RepositoryView) => { + const params = new URLSearchParams(searchParams.toString()); + if (newView === "public") { + params.delete("view"); + } else { + params.set("view", newView); + } + const query = params.toString(); + router.replace(query ? `/?${query}` : "/", { scroll: false }); + }, [router, searchParams]); + return ( {}} searchBox={{ value: keyword, onChange: setKeyword, @@ -118,11 +137,23 @@ export default function Home() { - {/* Public Repository List Section */} + {/* Repository List Section */}
- +
); } + +export default function Home() { + return ( + + + + ); +} diff --git a/web/app/(main)/private/github-import/page.tsx b/web/app/(main)/private/github-import/page.tsx index a80769b8..18841057 100644 --- a/web/app/(main)/private/github-import/page.tsx +++ b/web/app/(main)/private/github-import/page.tsx @@ -85,7 +85,7 @@ export default function UserGitHubImportPage() { {/* Header */}
- + - - - - - - -
- -
-
-
-
-
- - - - - ); + const router = useRouter(); + useEffect(() => { + router.replace("/?view=mine"); + }, [router]); + return null; } diff --git a/web/app/sidebar.tsx b/web/app/sidebar.tsx index bbcee843..901134f2 100644 --- a/web/app/sidebar.tsx +++ b/web/app/sidebar.tsx @@ -8,7 +8,7 @@ import { Bookmark, Building2, AppWindow, - Zap, + Lock, } from "lucide-react"; import { Sidebar, @@ -52,7 +52,7 @@ const GithubIcon = ({ className }: { className?: string }) => ( const itemKeys = [ { key: "explore", url: "/", icon: Compass, requireAuth: false }, { key: "recommend", url: "/recommend", icon: ThumbsUp, requireAuth: false }, - { key: "private", url: "/private", icon: GitFork, requireAuth: true }, + { key: "private", url: "/?view=organization", icon: Lock, requireAuth: true }, { key: "subscribe", url: "/subscribe", icon: Star, requireAuth: true }, { key: "bookmarks", url: "/bookmarks", icon: Bookmark, requireAuth: true }, { key: "organizations", url: "/organizations", icon: Building2, requireAuth: false }, diff --git a/web/components/repo/language-tags.tsx b/web/components/repo/language-tags.tsx index 91459c74..43cdf7c4 100644 --- a/web/components/repo/language-tags.tsx +++ b/web/components/repo/language-tags.tsx @@ -11,6 +11,7 @@ interface LanguageTagsProps { selectedLanguage: string | null; onLanguageChange: (language: string | null) => void; className?: string; + languages?: LanguageInfo[]; } // 语言颜色映射 @@ -35,15 +36,20 @@ const languageColors: Record = { const defaultColor = "bg-muted hover:bg-muted/80 text-muted-foreground border-border"; -export function LanguageTags({ selectedLanguage, onLanguageChange, className }: LanguageTagsProps) { - const [languages, setLanguages] = useState([]); +export function LanguageTags({ selectedLanguage, onLanguageChange, className, languages: languagesProp }: LanguageTagsProps) { + const [languageData, setLanguageData] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { + if (languagesProp) { + setLanguageData(languagesProp); + setIsLoading(false); + return; + } const loadLanguages = async () => { try { const response = await getAvailableLanguages(); - setLanguages(response.languages); + setLanguageData(response.languages); } catch (error) { console.error("Failed to load languages:", error); } finally { @@ -51,7 +57,7 @@ export function LanguageTags({ selectedLanguage, onLanguageChange, className }: } }; loadLanguages(); - }, []); + }, [languagesProp]); if (isLoading) { return ( @@ -63,7 +69,7 @@ export function LanguageTags({ selectedLanguage, onLanguageChange, className }: ); } - if (languages.length === 0) { + if (languageData.length === 0) { return null; } @@ -82,7 +88,7 @@ export function LanguageTags({ selectedLanguage, onLanguageChange, className }: > 全部 - {languages.map((lang) => ( + {languageData.map((lang) => ( void; + toggleMode?: "share" | "restrict"; } -export function PublicRepositoryCard({ repository }: PublicRepositoryCardProps) { +export function PublicRepositoryCard({ repository, onShareToggle, toggleMode = "share" }: PublicRepositoryCardProps) { const t = useTranslations(); const { user } = useAuth(); - const createdDate = new Date(repository.createdAt).toLocaleDateString(); + const createdDate = repository.createdAt + ? new Date(repository.createdAt).toLocaleDateString() + : null; const [isBookmarked, setIsBookmarked] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false); const [bookmarkLoading, setBookmarkLoading] = useState(false); const [subscribeLoading, setSubscribeLoading] = useState(false); + const [shareLoading, setShareLoading] = useState(false); // 获取收藏和订阅状态 useEffect(() => { @@ -152,14 +162,66 @@ export function PublicRepositoryCard({ repository }: PublicRepositoryCardProps) } }, [user, repository.id, isSubscribed, subscribeLoading, t]); + const handleShareToggle = useCallback(async (checked: boolean) => { + if (shareLoading) return; + setShareLoading(true); + try { + if (toggleMode === "restrict") { + // Admin restrict/unrestrict in org view + if (checked) { + // Toggle ON = visible = unrestrict + await unrestrictRepoInOrganization(repository.id); + toast.success(t("home.filter.visibleToOrg")); + } else { + // Toggle OFF = restricted + await restrictRepoInOrganization(repository.id); + toast.success(t("home.filter.restricted")); + } + } else { + // User share/unshare in mine view + if (checked) { + await shareRepoWithOrganization(repository.id); + toast.success(t("home.filter.sharedWithOrg")); + } else { + await unshareRepoFromOrganization(repository.id); + toast.success(t("home.filter.unsharedFromOrg")); + } + } + onShareToggle?.(repository.id, checked); + } catch { + toast.error(t("home.actions.actionError")); + } finally { + setShareLoading(false); + } + }, [repository.id, shareLoading, onShareToggle, toggleMode, t]); + return ( - +
- + {/* Icons: dual display for repos that are both public AND in a department. + Restricted repos show EyeOff regardless. Private-only shows Lock. */} + {repository.isRestricted ? ( + + ) : ( +
+ {repository.isPublic && ( + + )} + {repository.departmentName && ( + + )} + {!repository.isPublic && !repository.departmentName && ( + + )} +
+ )}

{repository.orgName}/{repository.repoName}

@@ -168,10 +230,12 @@ export function PublicRepositoryCard({ repository }: PublicRepositoryCardProps)
-
- - {createdDate} -
+ {createdDate && createdDate !== "Invalid Date" && ( +
+ + {createdDate} +
+ )} {typeof repository.starCount === "number" && (
@@ -226,6 +290,25 @@ export function PublicRepositoryCard({ repository }: PublicRepositoryCardProps) )}
+ {onShareToggle && ( +
{ e.preventDefault(); e.stopPropagation(); }}> + {shareLoading ? ( + + ) : ( + + )} + + + {toggleMode === "restrict" + ? (repository.isRestricted ? t("home.filter.restricted") : t("home.filter.visibleToOrg")) + : (repository.departmentName ? t("home.filter.sharedWithOrg") : t("home.filter.shareWithOrg"))} + +
+ )} diff --git a/web/components/repo/public-repository-list.tsx b/web/components/repo/public-repository-list.tsx index be8e2f29..8991b4eb 100644 --- a/web/components/repo/public-repository-list.tsx +++ b/web/components/repo/public-repository-list.tsx @@ -1,22 +1,87 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/hooks/use-translations"; import { fetchRepositoryList } from "@/lib/repository-api"; +import { getMyDepartmentRepositories, restrictRepoInOrganization, unrestrictRepoInOrganization } from "@/lib/organization-api"; +import type { DepartmentRepository } from "@/lib/organization-api"; import { PublicRepositoryCard } from "./public-repository-card"; import { LanguageTags } from "./language-tags"; import type { RepositoryItemResponse } from "@/types/repository"; -import { GitBranch, XCircle, RefreshCw, Search, ChevronLeft, ChevronRight } from "lucide-react"; +import type { LanguageInfo } from "@/lib/recommendation-api"; +import { GitBranch, XCircle, RefreshCw, Search, ChevronLeft, ChevronRight, Globe, Building2, User, Layers, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useAuth } from "@/contexts/auth-context"; +import Link from "next/link"; +import { RepositorySubmitForm } from "@/components/repo/repository-submit-form"; +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog"; + +/** + * ADR: Unified Dashboard Repository Views + * + * Data Sources: + * - Public repos: server-side paginated via fetchRepositoryList({ isPublic: true }) + * - Org (department) repos: client-side via getMyDepartmentRepositories() + * - User-owned repos: server-side via fetchRepositoryList({ ownerId: user.id }) + * + * Key Design Decisions: + * + * 1. DUAL DATA MODEL: Public API returns RepositoryItemResponse (no departmentName), + * while org API returns DepartmentRepository (no star/fork counts). We enrich + * public repos with departmentName from dept data for dual-icon display. + * + * 2. OWNERSHIP vs DEPARTMENT: GitHub App imports set OwnerUserId to the importing + * admin, making all org repos appear as "owned". The Mine view therefore + * subtracts dept repos by default to show only truly personal repos. + * + * 3. SUB-FILTERS: Each view has contextual sub-filters applied client-side: + * - Public: "publicOnly" fetches all + dept IDs, excludes overlap + * - Organization: "privateOnly" filters mapped dept repos by !isPublic + * - Mine: default excludes dept repos; "shared"/"all" include them + * + * 4. RACE CONDITION PROTECTION: loadIdRef counter ensures only the latest + * async request's results are applied. Stale responses are silently discarded. + * + * 5. STALE STATE CLEARING: effectiveView change clears repositories, languages, + * total, page, and subFilter to prevent flash of previous view's data. + * + * 6. PAGINATION: Public (default) uses server-side pagination (PAGE_SIZE=12). + * All other views and sub-filtered public use client-side pagination + * (fetch MAX_CLIENT_PAGE_SIZE=200, slice locally). + */ + +export type RepositoryView = "all" | "public" | "organization" | "mine"; interface PublicRepositoryListProps { keyword: string; + view?: RepositoryView; + onViewChange?: (view: RepositoryView) => void; className?: string; } const PAGE_SIZE = 12; +const MAX_CLIENT_PAGE_SIZE = 200; + +function mapDepartmentRepoToItem(repo: DepartmentRepository): RepositoryItemResponse { + return { + id: repo.repositoryId, + orgName: repo.orgName, + repoName: repo.repoName, + gitUrl: repo.gitUrl || "", + status: repo.status, + statusName: (repo.statusName as RepositoryItemResponse["statusName"]) || "Pending", + isPublic: repo.isPublic ?? false, + createdAt: repo.createdAt || "", + departmentName: repo.departmentName, + primaryLanguage: repo.primaryLanguage, + isRestricted: repo.isRestricted || false, + }; +} function RepositoryGridSkeleton() { return ( @@ -36,47 +101,290 @@ function RepositoryGridSkeleton() { ); } -export function PublicRepositoryList({ keyword, className }: PublicRepositoryListProps) { +function computeLanguageStats(repos: RepositoryItemResponse[]): LanguageInfo[] { + const counts = new Map(); + for (const r of repos) { + if (r.primaryLanguage) { + counts.set(r.primaryLanguage, (counts.get(r.primaryLanguage) || 0) + 1); + } + } + return Array.from(counts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); +} + +const GithubIcon = ({ className }: { className?: string }) => ( + + + +); + +export function PublicRepositoryList({ keyword, view = "public", onViewChange, className }: PublicRepositoryListProps) { const t = useTranslations(); + const { user } = useAuth(); + const isAdmin = user?.roles?.includes("Admin") ?? false; const [repositories, setRepositories] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [selectedLanguage, setSelectedLanguage] = useState(null); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); + const [isFormOpen, setIsFormOpen] = useState(false); + const [viewLanguages, setViewLanguages] = useState(null); + const [subFilter, setSubFilter] = useState(null); + + // Effective view: fall back to "public" if auth-required view is used without auth + const effectiveView = useMemo(() => { + if ((view === "organization" || view === "mine") && !user) { + return "public"; + } + return view; + }, [view, user]); const totalPages = Math.ceil(total / PAGE_SIZE); + // Race condition protection: only the latest request's results are applied + const loadIdRef = useRef(0); + const loadRepositories = useCallback(async () => { + const loadId = ++loadIdRef.current; try { setIsLoading(true); setError(null); - const response = await fetchRepositoryList({ - isPublic: true, - sortBy: "status", - keyword: keyword || undefined, - language: selectedLanguage || undefined, - page, - pageSize: PAGE_SIZE, - }); - setRepositories(response.items); - setTotal(response.total); + + switch (effectiveView) { + case "public": { + // Fetch public repos + dept repos (for enrichment & sub-filter) + const pubDeptRepos = user + ? await getMyDepartmentRepositories().catch(() => [] as DepartmentRepository[]) + : []; + if (loadId !== loadIdRef.current) return; + + // Build dept lookup for enrichment + const pubDeptMap = new Map(pubDeptRepos.map(r => [r.repositoryId, r.departmentName])); + + // Helper: enrich public repos with departmentName from dept data + const enrichPublicRepos = (items: RepositoryItemResponse[]) => + items.map(r => pubDeptMap.has(r.id) ? { ...r, departmentName: pubDeptMap.get(r.id) } : r); + + if (subFilter === "publicOnly" && user) { + // Client-side: fetch all public, exclude org repos + const pubAllResponse = await fetchRepositoryList({ + isPublic: true, + sortBy: "status", + keyword: keyword || undefined, + pageSize: MAX_CLIENT_PAGE_SIZE, + }); + if (loadId !== loadIdRef.current) return; + let filtered = pubAllResponse.items.filter(r => !pubDeptMap.has(r.id)); + setViewLanguages(computeLanguageStats(filtered)); + if (selectedLanguage) { + filtered = filtered.filter(r => r.primaryLanguage?.toLowerCase() === selectedLanguage.toLowerCase()); + } + setTotal(filtered.length); + const pubStart = (page - 1) * PAGE_SIZE; + setRepositories(filtered.slice(pubStart, pubStart + PAGE_SIZE)); + } else { + // Default: server-side paginated, enrich with dept info + setViewLanguages(null); + const response = await fetchRepositoryList({ + isPublic: true, + sortBy: "status", + keyword: keyword || undefined, + language: selectedLanguage || undefined, + page, + pageSize: PAGE_SIZE, + }); + if (loadId !== loadIdRef.current) return; + setRepositories(enrichPublicRepos(response.items)); + setTotal(response.total); + } + break; + } + + case "mine": { + if (!user) break; + const [mineResponse, mineDeptRepos] = await Promise.all([ + fetchRepositoryList({ + ownerId: user.id, + sortBy: "status", + keyword: keyword || undefined, + pageSize: MAX_CLIENT_PAGE_SIZE, + }), + getMyDepartmentRepositories().catch(() => [] as DepartmentRepository[]), + ]); + + if (loadId !== loadIdRef.current) return; + + // Base: all owned private repos (exclude public since user didn't create those) + const mineDeptRepoIds = new Set(mineDeptRepos.map(r => r.repositoryId)); + let myRepos = mineResponse.items.filter(r => !r.isPublic); + + // Sub-filter: default excludes dept repos (org-imported repos belong to org, not user) + if (subFilter === "shared") { + myRepos = myRepos.filter(r => mineDeptRepoIds.has(r.id)); + } else if (subFilter !== "all") { + // Default (null) and "notShared": exclude dept repos = truly personal only + myRepos = myRepos.filter(r => !mineDeptRepoIds.has(r.id)); + } + + setViewLanguages(computeLanguageStats(myRepos)); + if (selectedLanguage) { + myRepos = myRepos.filter(r => r.primaryLanguage === selectedLanguage); + } + const mineTotal = myRepos.length; + const mineStart = (page - 1) * PAGE_SIZE; + setTotal(mineTotal); + setRepositories(myRepos.slice(mineStart, mineStart + PAGE_SIZE)); + break; + } + + case "organization": { + if (!user) break; + const orgDeptRepos = await getMyDepartmentRepositories(isAdmin); + if (loadId !== loadIdRef.current) return; + let mapped = orgDeptRepos.map(mapDepartmentRepoToItem); + + setViewLanguages(computeLanguageStats(mapped)); + + // Client-side keyword filter + if (keyword) { + const kw = keyword.toLowerCase(); + mapped = mapped.filter( + (r) => + r.orgName.toLowerCase().includes(kw) || + r.repoName.toLowerCase().includes(kw) + ); + } + // Sub-filter: private only + if (subFilter === "privateOnly") { + mapped = mapped.filter((r) => !r.isPublic); + } + // Client-side language filter + if (selectedLanguage) { + mapped = mapped.filter( + (r) => r.primaryLanguage?.toLowerCase() === selectedLanguage.toLowerCase() + ); + } + + setTotal(mapped.length); + // Client-side pagination + const start = (page - 1) * PAGE_SIZE; + setRepositories(mapped.slice(start, start + PAGE_SIZE)); + break; + } + + case "all": { + // For unauthenticated users, same as public + if (!user) { + setViewLanguages(null); + const response = await fetchRepositoryList({ + isPublic: true, + sortBy: "status", + keyword: keyword || undefined, + language: selectedLanguage || undefined, + page, + pageSize: PAGE_SIZE, + }); + if (loadId !== loadIdRef.current) return; + setRepositories(response.items); + setTotal(response.total); + break; + } + + // Fetch all sources in parallel + const [allPublicResponse, allDeptRepos, allOwnResponse] = await Promise.all([ + fetchRepositoryList({ + isPublic: true, + sortBy: "status", + keyword: keyword || undefined, + pageSize: MAX_CLIENT_PAGE_SIZE, + }), + getMyDepartmentRepositories(isAdmin).catch(() => [] as DepartmentRepository[]), + fetchRepositoryList({ + ownerId: user.id, + sortBy: "status", + keyword: keyword || undefined, + pageSize: MAX_CLIENT_PAGE_SIZE, + }).catch(() => ({ items: [] as RepositoryItemResponse[], total: 0 })), + ]); + + if (loadId !== loadIdRef.current) return; + + // Merge: start with owned repos, then OVERWRITE with dept repos (they have departmentName), then add remaining public + const repoMap = new Map(); + // First: owned repos (these may lack departmentName) + if (user) { + for (const r of allOwnResponse.items) repoMap.set(r.id, r); + } + // Second: dept repos OVERWRITE owned versions (to get correct departmentName + icon) + for (const dr of allDeptRepos) { + repoMap.set(dr.repositoryId, mapDepartmentRepoToItem(dr)); + } + // Third: public repos (don't overwrite) + for (const r of allPublicResponse.items) { + if (!repoMap.has(r.id)) repoMap.set(r.id, r); + } + + const allRepos = Array.from(repoMap.values()); + setViewLanguages(computeLanguageStats(allRepos)); + + // Client-side language filter + let filteredRepos = allRepos; + if (selectedLanguage) { + filteredRepos = allRepos.filter( + (r) => r.primaryLanguage?.toLowerCase() === selectedLanguage.toLowerCase() + ); + } + setTotal(filteredRepos.length); + // Client-side pagination + const allStart = (page - 1) * PAGE_SIZE; + setRepositories(filteredRepos.slice(allStart, allStart + PAGE_SIZE)); + break; + } + } } catch (err) { + if (loadId !== loadIdRef.current) return; setError("Failed to load repositories"); - console.error("Failed to fetch public repositories:", err); + console.error("Failed to fetch repositories:", err); } finally { - setIsLoading(false); + if (loadId === loadIdRef.current) { + setIsLoading(false); + } } - }, [keyword, selectedLanguage, page]); + }, [effectiveView, keyword, selectedLanguage, page, user, subFilter]); useEffect(() => { loadRepositories(); }, [loadRepositories]); - // 当筛选条件变化时重置页码 + // Clear stale state when view changes (prevents flash of old data) useEffect(() => { + setRepositories([]); + setViewLanguages(null); + setTotal(0); setPage(1); - }, [keyword, selectedLanguage]); + setSubFilter(null); + }, [effectiveView]); + + // Reset page when keyword/language/sub filter changes (within same view) + useEffect(() => { + setPage(1); + }, [keyword, selectedLanguage, subFilter]); + + // Auto-refresh for pending/processing repositories (ported from repository-list.tsx) + useEffect(() => { + if (effectiveView === "public") return; // Public repos don't need auto-refresh + + const hasPendingOrProcessing = repositories.some( + (r) => r.statusName === "Pending" || r.statusName === "Processing" + ); + + if (hasPendingOrProcessing) { + const interval = setInterval(loadRepositories, 10000); + return () => clearInterval(interval); + } + }, [repositories, loadRepositories, effectiveView]); const handleLanguageChange = (language: string | null) => { setSelectedLanguage(language); @@ -90,12 +398,109 @@ export function PublicRepositoryList({ keyword, className }: PublicRepositoryLis if (page < totalPages) setPage(page + 1); }; + const handleViewChange = (newView: RepositoryView) => { + onViewChange?.(newView); + }; + + const handleSubmitSuccess = useCallback(() => { + setIsFormOpen(false); + loadRepositories(); + }, [loadRepositories]); + + // Dynamic section title + const sectionTitle = useMemo(() => { + switch (effectiveView) { + case "all": + return t("home.filter.allTitle"); + case "public": + return t("home.publicRepository.title"); + case "organization": + return t("home.filter.organizationTitle"); + case "mine": + return t("home.repository.listTitle"); + } + }, [effectiveView, t]); + + // Dynamic empty state message + const emptyMessage = useMemo(() => { + switch (effectiveView) { + case "organization": + return t("home.filter.organizationEmpty"); + case "mine": + return t("home.filter.myReposEmpty"); + default: + return t("home.publicRepository.empty"); + } + }, [effectiveView, t]); + + // Filter tabs + const filterTabs: { key: RepositoryView; label: string; icon: React.ElementType; requireAuth: boolean }[] = [ + { key: "all", label: t("home.filter.all"), icon: Layers, requireAuth: false }, + { key: "public", label: t("home.filter.public"), icon: Globe, requireAuth: false }, + { key: "organization", label: t("home.filter.organization"), icon: Building2, requireAuth: true }, + { key: "mine", label: t("home.filter.myRepos"), icon: User, requireAuth: true }, + ]; + + const visibleTabs = filterTabs.filter((tab) => !tab.requireAuth || user); + + const subFilterOptions = useMemo(() => { + switch (effectiveView) { + case "public": + return user ? [ + { key: null, label: t("home.filter.subAll") }, + { key: "publicOnly", label: t("home.filter.publicOnly") }, + ] : []; + case "organization": + return [ + { key: null, label: t("home.filter.subAll") }, + { key: "privateOnly", label: t("home.filter.privateOnly") }, + ]; + case "mine": + return [ + { key: null, label: t("home.filter.notSharedOnly") }, + { key: "shared", label: t("home.filter.sharedOnly") }, + { key: "all", label: t("home.filter.subAll") }, + ]; + default: + return []; + } + }, [effectiveView, user, t]); + if (isLoading && repositories.length === 0) { return (
-

- {t("home.publicRepository.title")} -

+ {/* Filter tabs */} +
+ {visibleTabs.map((tab) => ( + + ))} +
+ {/* Sub-filter chips */} + {subFilterOptions.length > 0 && ( +
+ {subFilterOptions.map((sf) => ( + + ))} +
+ )} +

{sectionTitle}

{[1, 2, 3, 4, 5, 6].map((i) => ( @@ -111,9 +516,38 @@ export function PublicRepositoryList({ keyword, className }: PublicRepositoryLis if (error) { return (
-

- {t("home.publicRepository.title")} -

+ {/* Filter tabs */} +
+ {visibleTabs.map((tab) => ( + + ))} +
+ {/* Sub-filter chips */} + {subFilterOptions.length > 0 && ( +
+ {subFilterOptions.map((sf) => ( + + ))} +
+ )} +

{sectionTitle}

{t("home.publicRepository.loadError")}

@@ -128,10 +562,66 @@ export function PublicRepositoryList({ keyword, className }: PublicRepositoryLis return (
+ {/* Filter tabs */} +
+ {visibleTabs.map((tab) => ( + + ))} + + {/* Action buttons for mine/organization views */} + {user && (effectiveView === "mine" || effectiveView === "organization") && ( +
+ + + + + + + + + +
+ )} +
+ + {/* Sub-filter chips */} + {subFilterOptions.length > 0 && ( +
+ {subFilterOptions.map((sf) => ( + + ))} +
+ )} +
-

- {t("home.publicRepository.title")} -

+

{sectionTitle}