mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-10 13:36:45 +03:00
feat: Add Gitea configuration hook and enhance repository list with Gitea links
This commit is contained in:
@@ -1,15 +1,50 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { GitFork } from "lucide-react";
|
import { GitFork } from "lucide-react";
|
||||||
import { SiGithub } from "react-icons/si";
|
import { SiGithub, SiGitea } from "react-icons/si";
|
||||||
import type { Repository } from "@/lib/db/schema";
|
import type { Repository } from "@/lib/db/schema";
|
||||||
import { getStatusColor } from "@/lib/utils";
|
import { getStatusColor } from "@/lib/utils";
|
||||||
|
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||||
|
|
||||||
interface RepositoryListProps {
|
interface RepositoryListProps {
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepositoryList({ repositories }: RepositoryListProps) {
|
export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||||
|
const { giteaConfig } = useGiteaConfig();
|
||||||
|
|
||||||
|
// Helper function to construct Gitea repository URL
|
||||||
|
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||||
|
if (!giteaConfig?.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||||
|
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||||
|
if (!validStatuses.includes(repository.status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mirroredLocation if available, otherwise construct from repository data
|
||||||
|
let repoPath: string;
|
||||||
|
if (repository.mirroredLocation) {
|
||||||
|
repoPath = repository.mirroredLocation;
|
||||||
|
} else {
|
||||||
|
// Fallback: construct the path based on repository data
|
||||||
|
// If repository has organization and preserveOrgStructure would be true, use org
|
||||||
|
// Otherwise use the repository owner
|
||||||
|
const owner = repository.organization || repository.owner;
|
||||||
|
repoPath = `${owner}/${repository.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the base URL doesn't have a trailing slash
|
||||||
|
const baseUrl = giteaConfig.url.endsWith('/')
|
||||||
|
? giteaConfig.url.slice(0, -1)
|
||||||
|
: giteaConfig.url;
|
||||||
|
|
||||||
|
return `${baseUrl}/${repoPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
{/* calculating the max height based non the other elements and sizing styles */}
|
{/* calculating the max height based non the other elements and sizing styles */}
|
||||||
@@ -69,14 +104,48 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
||||||
{repo.status}
|
{repo.status}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" size="icon">
|
{(() => {
|
||||||
<GitFork className="h-4 w-4" />
|
const giteaUrl = getGiteaRepoUrl(repo);
|
||||||
|
|
||||||
|
// Determine tooltip based on status and configuration
|
||||||
|
let tooltip: string;
|
||||||
|
if (!giteaConfig?.url) {
|
||||||
|
tooltip = "Gitea not configured";
|
||||||
|
} else if (repo.status === 'imported') {
|
||||||
|
tooltip = "Repository not yet mirrored to Gitea";
|
||||||
|
} else if (repo.status === 'failed') {
|
||||||
|
tooltip = "Repository mirroring failed";
|
||||||
|
} else if (repo.status === 'mirroring') {
|
||||||
|
tooltip = "Repository is being mirrored to Gitea";
|
||||||
|
} else if (giteaUrl) {
|
||||||
|
tooltip = "View on Gitea";
|
||||||
|
} else {
|
||||||
|
tooltip = "Gitea repository not available";
|
||||||
|
}
|
||||||
|
|
||||||
|
return giteaUrl ? (
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={giteaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<SiGitea className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||||
|
<SiGitea className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" asChild>
|
||||||
<a
|
<a
|
||||||
href={repo.url}
|
href={repo.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
>
|
>
|
||||||
<SiGithub className="h-4 w-4" />
|
<SiGithub className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
||||||
import { SiGithub } from "react-icons/si";
|
import { SiGithub, SiGitea } from "react-icons/si";
|
||||||
import type { Repository } from "@/lib/db/schema";
|
import type { Repository } from "@/lib/db/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||||
import type { FilterParams } from "@/types/filter";
|
import type { FilterParams } from "@/types/filter";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
interface RepositoryTableProps {
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
@@ -31,6 +32,37 @@ export default function RepositoryTable({
|
|||||||
loadingRepoIds,
|
loadingRepoIds,
|
||||||
}: RepositoryTableProps) {
|
}: RepositoryTableProps) {
|
||||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { giteaConfig } = useGiteaConfig();
|
||||||
|
|
||||||
|
// Helper function to construct Gitea repository URL
|
||||||
|
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||||
|
if (!giteaConfig?.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||||
|
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||||
|
if (!validStatuses.includes(repository.status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mirroredLocation if available, otherwise construct from repository data
|
||||||
|
let repoPath: string;
|
||||||
|
if (repository.mirroredLocation) {
|
||||||
|
repoPath = repository.mirroredLocation;
|
||||||
|
} else {
|
||||||
|
// Fallback: construct the path based on repository data
|
||||||
|
const owner = repository.organization || repository.owner;
|
||||||
|
repoPath = `${owner}/${repository.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the base URL doesn't have a trailing slash
|
||||||
|
const baseUrl = giteaConfig.url.endsWith('/')
|
||||||
|
? giteaConfig.url.slice(0, -1)
|
||||||
|
: giteaConfig.url;
|
||||||
|
|
||||||
|
return `${baseUrl}/${repoPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
const hasAnyFilter = Object.values(filter).some(
|
const hasAnyFilter = Object.values(filter).some(
|
||||||
(val) => val?.toString().trim() !== ""
|
(val) => val?.toString().trim() !== ""
|
||||||
@@ -239,60 +271,55 @@ export default function RepositoryTable({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="h-full p-3 flex items-center justify-end gap-x-2 flex-[1]">
|
<div className="h-full p-3 flex items-center justify-end gap-x-2 flex-[1]">
|
||||||
{/* {repo.status === "mirrored" ||
|
|
||||||
repo.status === "syncing" ||
|
|
||||||
repo.status === "synced" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
disabled={repo.status === "syncing" || isLoading}
|
|
||||||
onClick={() => onSync({ repoId: repo.id ?? "" })}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
|
||||||
Sync
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />
|
|
||||||
Sync
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
disabled={repo.status === "mirroring" || isLoading}
|
|
||||||
onClick={() => onMirror({ repoId: repo.id ?? "" })}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
|
||||||
Mirror
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />
|
|
||||||
Mirror
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
<RepoActionButton
|
<RepoActionButton
|
||||||
repo={{ id: repo.id ?? "", status: repo.status }}
|
repo={{ id: repo.id ?? "", status: repo.status }}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onMirror={({ repoId }) =>
|
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
|
||||||
onMirror({ repoId: repo.id ?? "" })
|
onSync={() => onSync({ repoId: repo.id ?? "" })}
|
||||||
}
|
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
||||||
onSync={({ repoId }) => onSync({ repoId: repo.id ?? "" })}
|
|
||||||
onRetry={({ repoId }) => onRetry({ repoId: repo.id ?? "" })}
|
|
||||||
/>
|
/>
|
||||||
|
{(() => {
|
||||||
|
const giteaUrl = getGiteaRepoUrl(repo);
|
||||||
|
|
||||||
|
// Determine tooltip based on status and configuration
|
||||||
|
let tooltip: string;
|
||||||
|
if (!giteaConfig?.url) {
|
||||||
|
tooltip = "Gitea not configured";
|
||||||
|
} else if (repo.status === 'imported') {
|
||||||
|
tooltip = "Repository not yet mirrored to Gitea";
|
||||||
|
} else if (repo.status === 'failed') {
|
||||||
|
tooltip = "Repository mirroring failed";
|
||||||
|
} else if (repo.status === 'mirroring') {
|
||||||
|
tooltip = "Repository is being mirrored to Gitea";
|
||||||
|
} else if (giteaUrl) {
|
||||||
|
tooltip = "View on Gitea";
|
||||||
|
} else {
|
||||||
|
tooltip = "Gitea repository not available";
|
||||||
|
}
|
||||||
|
|
||||||
|
return giteaUrl ? (
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={giteaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<SiGitea className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||||
|
<SiGitea className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" asChild>
|
||||||
<a
|
<a
|
||||||
href={repo.url}
|
href={repo.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
>
|
>
|
||||||
<SiGithub className="h-4 w-4" />
|
<SiGithub className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
@@ -333,12 +360,10 @@ function RepoActionButton({
|
|||||||
}: {
|
}: {
|
||||||
repo: { id: string; status: string };
|
repo: { id: string; status: string };
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onMirror: ({ repoId }: { repoId: string }) => void;
|
onMirror: () => void;
|
||||||
onSync: ({ repoId }: { repoId: string }) => void;
|
onSync: () => void;
|
||||||
onRetry: ({ repoId }: { repoId: string }) => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
const repoId = repo.id ?? "";
|
|
||||||
|
|
||||||
let label = "";
|
let label = "";
|
||||||
let icon = <></>;
|
let icon = <></>;
|
||||||
let onClick = () => {};
|
let onClick = () => {};
|
||||||
@@ -347,23 +372,28 @@ function RepoActionButton({
|
|||||||
if (repo.status === "failed") {
|
if (repo.status === "failed") {
|
||||||
label = "Retry";
|
label = "Retry";
|
||||||
icon = <RotateCcw className="h-4 w-4 mr-1" />;
|
icon = <RotateCcw className="h-4 w-4 mr-1" />;
|
||||||
onClick = () => onRetry({ repoId });
|
onClick = onRetry;
|
||||||
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
||||||
label = "Sync";
|
label = "Sync";
|
||||||
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
||||||
onClick = () => onSync({ repoId });
|
onClick = onSync;
|
||||||
disabled ||= repo.status === "syncing";
|
disabled ||= repo.status === "syncing";
|
||||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||||
label = "Mirror";
|
label = "Mirror";
|
||||||
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
icon = <FlipHorizontal className="h-4 w-4 mr-1" />;
|
||||||
onClick = () => onMirror({ repoId });
|
onClick = onMirror;
|
||||||
disabled ||= repo.status === "mirroring";
|
disabled ||= repo.status === "mirroring";
|
||||||
} else {
|
} else {
|
||||||
return null; // unsupported status
|
return null; // unsupported status
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" disabled={disabled} onClick={onClick}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="min-w-[80px] justify-start"
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
|||||||
@@ -145,3 +145,10 @@ export function useConfigStatus(): ConfigStatus {
|
|||||||
export function invalidateConfigCache() {
|
export function invalidateConfigCache() {
|
||||||
configCache = { data: null, timestamp: 0, userId: null };
|
configCache = { data: null, timestamp: 0, userId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export function to get cached config data for other hooks
|
||||||
|
export function getCachedConfig(): ConfigApiResponse | null {
|
||||||
|
const now = Date.now();
|
||||||
|
const isCacheValid = configCache.data && (now - configCache.timestamp) < CACHE_DURATION;
|
||||||
|
return isCacheValid ? configCache.data : null;
|
||||||
|
}
|
||||||
|
|||||||
73
src/hooks/useGiteaConfig.ts
Normal file
73
src/hooks/useGiteaConfig.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { apiRequest } from '@/lib/utils';
|
||||||
|
import type { ConfigApiResponse, GiteaConfig } from '@/types/config';
|
||||||
|
import { getCachedConfig } from './useConfigStatus';
|
||||||
|
|
||||||
|
interface GiteaConfigHook {
|
||||||
|
giteaConfig: GiteaConfig | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get Gitea configuration data
|
||||||
|
* Uses the same cache as useConfigStatus to prevent duplicate API calls
|
||||||
|
*/
|
||||||
|
export function useGiteaConfig(): GiteaConfigHook {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [giteaConfigState, setGiteaConfigState] = useState<GiteaConfigHook>({
|
||||||
|
giteaConfig: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchGiteaConfig = useCallback(async () => {
|
||||||
|
if (!user?.id) {
|
||||||
|
setGiteaConfigState({
|
||||||
|
giteaConfig: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'User not authenticated',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cachedConfig = getCachedConfig();
|
||||||
|
if (cachedConfig) {
|
||||||
|
setGiteaConfigState({
|
||||||
|
giteaConfig: cachedConfig.giteaConfig || null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGiteaConfigState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
const configResponse = await apiRequest<ConfigApiResponse>(
|
||||||
|
`/config?userId=${user.id}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
);
|
||||||
|
|
||||||
|
setGiteaConfigState({
|
||||||
|
giteaConfig: configResponse?.giteaConfig || null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setGiteaConfigState({
|
||||||
|
giteaConfig: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch Gitea configuration',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGiteaConfig();
|
||||||
|
}, [fetchGiteaConfig]);
|
||||||
|
|
||||||
|
return giteaConfigState;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user