Added gitea-mirror
This commit is contained in:
80
Divers/gitea-mirror/src/components/NotFound.tsx
Normal file
80
Divers/gitea-mirror/src/components/NotFound.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="h-dvh bg-muted/30 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<FileQuestion className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">404</h1>
|
||||
<h2 className="text-xl font-semibold mt-2">Page Not Found</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild className="w-full">
|
||||
<a href="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={() => window.history.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or visit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<a
|
||||
href="/repositories"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Repositories</span>
|
||||
</a>
|
||||
<a
|
||||
href="/config"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Config</span>
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Docs</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Error Code */}
|
||||
<div className="text-center pt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error Code: <code className="font-mono">404_NOT_FOUND</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
Divers/gitea-mirror/src/components/activity/ActivityList.tsx
Normal file
352
Divers/gitea-mirror/src/components/activity/ActivityList.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Button } from '../ui/button';
|
||||
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import type { FilterParams } from '@/types/filter';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../ui/tooltip';
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
|
||||
interface ActivityListProps {
|
||||
activities: MirrorJobWithKey[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
}
|
||||
|
||||
export default function ActivityList({
|
||||
activities,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
}: ActivityListProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// We keep the ref only for possible future scroll-to-row logic.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
let result = activities;
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((a) => a.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
result =
|
||||
filter.type === 'repository'
|
||||
? result.filter((a) => !!a.repositoryId)
|
||||
: filter.type === 'organization'
|
||||
? result.filter((a) => !!a.organizationId)
|
||||
: result;
|
||||
}
|
||||
|
||||
if (filter.name) {
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.repositoryName === filter.name ||
|
||||
a.organizationName === filter.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((r) => r.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activities, filter]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredActivities.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (idx) =>
|
||||
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
|
||||
overscan: 5,
|
||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [expandedItems, virtualizer]);
|
||||
|
||||
/* ------------------------------ render ------------------------------ */
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4'>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className='h-28 w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
const hasFilter =
|
||||
filter.searchTerm || filter.status || filter.type || filter.name;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<RefreshCw className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||
<h3 className='text-lg font-medium'>No activities found</h3>
|
||||
<p className='mt-1 mb-4 max-w-md text-sm text-muted-foreground'>
|
||||
{hasFilter
|
||||
? 'Try adjusting your search or filter criteria.'
|
||||
: 'No mirroring activities have been recorded yet.'}
|
||||
</p>
|
||||
{hasFilter && (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setFilter({ searchTerm: '', status: '', type: '', name: '' })
|
||||
}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-md">
|
||||
<Card
|
||||
ref={parentRef}
|
||||
className='relative max-h-[calc(100dvh-231px)] overflow-y-auto rounded-none border-0'
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const activity = filteredActivities[vRow.index];
|
||||
const isExpanded = expandedItems.has(activity._rowKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity._rowKey}
|
||||
ref={(node) => {
|
||||
rowRefs.current.set(activity._rowKey, node);
|
||||
if (node) virtualizer.measureElement(node);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
paddingBottom: '8px',
|
||||
}}
|
||||
className='border-b px-4 pt-4'
|
||||
>
|
||||
<div className='flex items-start gap-3 sm:gap-4'>
|
||||
<div className='relative mt-2 flex-shrink-0'>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status,
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='mb-1 flex items-start justify-between gap-2'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* Mobile: Show simplified status-based message */}
|
||||
<div className='block sm:hidden'>
|
||||
<p className='font-medium flex items-center gap-1.5'>
|
||||
{activity.status === 'synced' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400' />
|
||||
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||
</>
|
||||
) : activity.status === 'mirrored' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400' />
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||
</>
|
||||
) : activity.status === 'failed' ? (
|
||||
<>
|
||||
<X className='h-4 w-4 text-rose-600 dark:text-rose-400' />
|
||||
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||
</>
|
||||
) : activity.status === 'syncing' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin' />
|
||||
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||
</>
|
||||
) : activity.status === 'mirroring' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin' />
|
||||
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||
</>
|
||||
) : activity.status === 'imported' ? (
|
||||
<>
|
||||
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400' />
|
||||
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{activity.message}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Desktop: Show status with icon and full message in tooltip */}
|
||||
<div className='hidden sm:block'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className='font-medium flex items-center gap-1.5 cursor-help'>
|
||||
{activity.status === 'synced' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400 flex-shrink-0' />
|
||||
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||
</>
|
||||
) : activity.status === 'mirrored' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0' />
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||
</>
|
||||
) : activity.status === 'failed' ? (
|
||||
<>
|
||||
<X className='h-4 w-4 text-rose-600 dark:text-rose-400 flex-shrink-0' />
|
||||
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||
</>
|
||||
) : activity.status === 'syncing' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin flex-shrink-0' />
|
||||
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||
</>
|
||||
) : activity.status === 'mirroring' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin flex-shrink-0' />
|
||||
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||
</>
|
||||
) : activity.status === 'imported' ? (
|
||||
<>
|
||||
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
|
||||
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='truncate'>{activity.message}</span>
|
||||
)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[400px]">
|
||||
<p className="whitespace-pre-wrap break-words">{activity.message}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground whitespace-nowrap flex-shrink-0 ml-2'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
|
||||
{activity.repositoryName && (
|
||||
<p className='text-sm text-muted-foreground truncate'>
|
||||
<span className='font-medium'>Repo:</span> {activity.repositoryName}
|
||||
</p>
|
||||
)}
|
||||
{activity.organizationName && (
|
||||
<p className='text-sm text-muted-foreground truncate'>
|
||||
<span className='font-medium'>Org:</span> {activity.organizationName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activity.details && (
|
||||
<div className='mt-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<pre className='mt-2 min-h-[100px] whitespace-pre-wrap overflow-auto rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{filteredActivities.length} {filteredActivities.length === 1 ? 'activity' : 'activities'} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(filter.searchTerm || filter.status || filter.type || filter.name) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
752
Divers/gitea-mirror/src/components/activity/ActivityLog.tsx
Normal file
752
Divers/gitea-mirror/src/components/activity/ActivityLog.tsx
Normal file
@@ -0,0 +1,752 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import type { ActivityApiResponse } from '@/types/activities';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
||||
import ActivityList from './ActivityList';
|
||||
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
||||
import { useSSE } from '@/hooks/useSEE';
|
||||
import { useFilterParams } from '@/hooks/useFilterParams';
|
||||
import { toast } from 'sonner';
|
||||
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
||||
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
||||
import { useNavigation } from '@/components/layout/MainLayout';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
|
||||
// Maximum number of activities to keep in memory to prevent performance issues
|
||||
const MAX_ACTIVITIES = 1000;
|
||||
|
||||
// More robust key generation to prevent collisions
|
||||
function genKey(job: MirrorJob, index?: number): string {
|
||||
const baseId = job.id || `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const timestamp = job.timestamp instanceof Date ? job.timestamp.getTime() : new Date(job.timestamp).getTime();
|
||||
const indexSuffix = index !== undefined ? `-${index}` : '';
|
||||
return `${baseId}-${timestamp}${indexSuffix}`;
|
||||
}
|
||||
|
||||
// Create a deep clone without structuredClone for better browser compatibility
|
||||
function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (Array.isArray(obj)) return obj.map(item => deepClone(item)) as T;
|
||||
|
||||
const cloned = {} as T;
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
cloned[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function ActivityLog() {
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
|
||||
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: '',
|
||||
status: '',
|
||||
type: '',
|
||||
name: '',
|
||||
});
|
||||
|
||||
/* ----------------------------- SSE hook ----------------------------- */
|
||||
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setActivities((prev) => {
|
||||
// Create a deep clone of the new activity
|
||||
const clonedData = deepClone(data);
|
||||
|
||||
// Check if this activity already exists to prevent duplicates
|
||||
const existingIndex = prev.findIndex(activity =>
|
||||
activity.id === clonedData.id ||
|
||||
(activity.repositoryId === clonedData.repositoryId &&
|
||||
activity.organizationId === clonedData.organizationId &&
|
||||
activity.message === clonedData.message &&
|
||||
Math.abs(new Date(activity.timestamp).getTime() - new Date(clonedData.timestamp).getTime()) < 1000)
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing activity instead of adding duplicate
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = {
|
||||
...clonedData,
|
||||
_rowKey: prev[existingIndex]._rowKey, // Keep the same key
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Add new activity with unique key
|
||||
const withKey: MirrorJobWithKey = {
|
||||
...clonedData,
|
||||
_rowKey: genKey(clonedData, prev.length),
|
||||
};
|
||||
|
||||
// Limit the number of activities to prevent memory issues
|
||||
const newActivities = [withKey, ...prev];
|
||||
return newActivities.slice(0, MAX_ACTIVITIES);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { connected } = useSSE({
|
||||
userId: user?.id,
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
/* ------------------------- initial fetch --------------------------- */
|
||||
|
||||
const fetchActivities = useCallback(async (isLiveRefresh = false) => {
|
||||
if (!user?.id) return false;
|
||||
|
||||
try {
|
||||
// Set appropriate loading state based on refresh type
|
||||
if (!isLiveRefresh) {
|
||||
setIsInitialLoading(true);
|
||||
}
|
||||
|
||||
const res = await apiRequest<ActivityApiResponse>(
|
||||
`/activities?userId=${user.id}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
showErrorToast(res.message ?? 'Failed to fetch activities.', toast);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process activities with robust cloning and unique keys
|
||||
const data: MirrorJobWithKey[] = res.activities.map((activity, index) => {
|
||||
const clonedActivity = deepClone(activity);
|
||||
return {
|
||||
...clonedActivity,
|
||||
_rowKey: genKey(clonedActivity, index),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by timestamp (newest first) to ensure consistent ordering
|
||||
data.sort((a, b) => {
|
||||
const timeA = new Date(a.timestamp).getTime();
|
||||
const timeB = new Date(b.timestamp).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setActivities(data);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
showErrorToast(err, toast);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isMountedRef.current && !isLiveRefresh) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user?.id]); // Only depend on user.id, not entire user object
|
||||
|
||||
useEffect(() => {
|
||||
// Reset loading state when component becomes active
|
||||
setIsInitialLoading(true);
|
||||
fetchActivities(false); // Manual refresh, not live
|
||||
}, [fetchActivities, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Register with global live refresh system
|
||||
useEffect(() => {
|
||||
// Only register for live refresh if configuration is complete
|
||||
// Activity logs can exist from previous runs, but new activities won't be generated without config
|
||||
if (!isFullyConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchActivities(true); // Live refresh
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchActivities, isFullyConfigured]);
|
||||
|
||||
/* ---------------------- filtering + exporting ---------------------- */
|
||||
|
||||
const applyLightFilter = (list: MirrorJobWithKey[]) => {
|
||||
return list.filter((a) => {
|
||||
if (filter.status && a.status !== filter.status) return false;
|
||||
|
||||
if (filter.type === 'repository' && !a.repositoryId) return false;
|
||||
if (filter.type === 'organization' && !a.organizationId) return false;
|
||||
|
||||
if (
|
||||
filter.name &&
|
||||
a.repositoryName !== filter.name &&
|
||||
a.organizationName !== filter.name
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const exportAsCSV = () => {
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!rows.length) return toast.error('No activities to export.');
|
||||
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Message',
|
||||
'Status',
|
||||
'Repository',
|
||||
'Organization',
|
||||
'Details',
|
||||
];
|
||||
|
||||
const escape = (v: string | null | undefined) =>
|
||||
v && /[,\"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v ?? '';
|
||||
|
||||
const csv = [
|
||||
headers.join(','),
|
||||
...rows.map((a) =>
|
||||
[
|
||||
formatDate(a.timestamp),
|
||||
escape(a.message),
|
||||
a.status,
|
||||
escape(a.repositoryName),
|
||||
escape(a.organizationName),
|
||||
escape(a.details),
|
||||
].join(','),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
downloadFile(csv, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
|
||||
toast.success('CSV exported.');
|
||||
};
|
||||
|
||||
const exportAsJSON = () => {
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!rows.length) return toast.error('No activities to export.');
|
||||
|
||||
const json = JSON.stringify(
|
||||
rows.map((a) => ({
|
||||
...a,
|
||||
formattedTime: formatDate(a.timestamp),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
downloadFile(json, 'application/json', 'activity_log_export.json');
|
||||
toast.success('JSON exported.');
|
||||
};
|
||||
|
||||
const downloadFile = (
|
||||
content: string,
|
||||
mime: string,
|
||||
filename: string,
|
||||
): void => {
|
||||
const date = new Date().toISOString().slice(0, 10); // yyyy-mm-dd
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||
link.download = filename.replace('.', `_${date}.`);
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleCleanupClick = () => {
|
||||
setShowCleanupDialog(true);
|
||||
};
|
||||
|
||||
const confirmCleanup = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsInitialLoading(true);
|
||||
setShowCleanupDialog(false);
|
||||
|
||||
const response = await fetch('/api/activities/cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error occurred' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (res.success) {
|
||||
// Clear the activities from the UI
|
||||
setActivities([]);
|
||||
toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`);
|
||||
} else {
|
||||
showErrorToast(res.error || 'Failed to cleanup activities.', toast);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up activities:', error);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCleanup = () => {
|
||||
setShowCleanupDialog(false);
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
|
||||
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setFilter({
|
||||
searchTerm: filter.searchTerm,
|
||||
status: '',
|
||||
type: '',
|
||||
name: '',
|
||||
});
|
||||
};
|
||||
|
||||
/* ------------------------------ UI ------------------------------ */
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4 sm:gap-y-8'>
|
||||
{/* Mobile: Search bar with filter and action buttons */}
|
||||
<div className="flex flex-col gap-2 sm:hidden">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search activities..."
|
||||
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={filter.searchTerm}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter Drawer */}
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative h-10 w-10 shrink-0"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle className="text-lg font-semibold">Filter Activities</DrawerTitle>
|
||||
<DrawerDescription className="text-sm text-muted-foreground">
|
||||
Narrow down your activity log
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-4 py-6 space-y-6 overflow-y-auto">
|
||||
{/* Active filters summary */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-sm font-medium">
|
||||
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Status
|
||||
{filter.status && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
)}
|
||||
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Type
|
||||
{filter.type && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{filter.type.charAt(0).toUpperCase() + filter.type.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={filter.type || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', 'repository', 'organization'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`} />
|
||||
)}
|
||||
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Name Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Name
|
||||
{filter.name && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<ActivityNameCombobox
|
||||
activities={activities}
|
||||
value={filter.name || ''}
|
||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||
<DrawerClose asChild>
|
||||
<Button className="w-full" size="sm">
|
||||
Apply Filters
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline" className="w-full" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchActivities(false)}
|
||||
title="Refresh activity log"
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
className="text-destructive hover:text-destructive h-10 w-10 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original layout */}
|
||||
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||
{/* search input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search activities..."
|
||||
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={filter.searchTerm}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
searchTerm: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* status select */}
|
||||
<Select
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
)}
|
||||
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* type select */}
|
||||
<Select
|
||||
value={filter.type || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', 'repository', 'organization'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`} />
|
||||
)}
|
||||
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* repo/org name combobox */}
|
||||
<ActivityNameCombobox
|
||||
activities={activities}
|
||||
value={filter.name || ''}
|
||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* export dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-10">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={exportAsCSV}>
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportAsJSON}>
|
||||
Export as JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* refresh */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchActivities(false)}
|
||||
title="Refresh activity log"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* cleanup all activities */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
className="text-destructive hover:text-destructive h-10 w-10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* activity list */}
|
||||
<ActivityList
|
||||
activities={applyLightFilter(activities)}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
|
||||
{/* cleanup confirmation dialog */}
|
||||
<Dialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Activities</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete ALL activities? This action cannot be undone and will remove all mirror jobs and events from the database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={cancelCleanup}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmCleanup}
|
||||
disabled={isInitialLoading}
|
||||
>
|
||||
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Mobile FAB for Export - only visible on mobile */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 sm:hidden"
|
||||
variant="default"
|
||||
>
|
||||
<Download className="h-6 w-6" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" className="mb-2">
|
||||
<DropdownMenuItem onClick={exportAsCSV}>
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportAsJSON}>
|
||||
Export as JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ActivityNameComboboxProps = {
|
||||
activities: any[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) {
|
||||
// Collect unique names from repositoryName and organizationName
|
||||
const names = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
activities.forEach((a) => {
|
||||
if (a.repositoryName) set.add(a.repositoryName);
|
||||
if (a.organizationName) set.add(a.organizationName);
|
||||
});
|
||||
return Array.from(set).sort();
|
||||
}, [activities]);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[180px] justify-between h-10"
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
{value || "All names"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search name..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No name found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
value=""
|
||||
onSelect={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
All names
|
||||
</CommandItem>
|
||||
{names.map((name) => (
|
||||
<CommandItem
|
||||
key={name}
|
||||
value={name}
|
||||
onSelect={() => {
|
||||
onChange(name);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === name ? "opacity-100" : "opacity-0")} />
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
297
Divers/gitea-mirror/src/components/auth/LoginForm.tsx
Normal file
297
Divers/gitea-mirror/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useAuthMethods } from '@/hooks/useAuthMethods';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [ssoEmail, setSsoEmail] = useState('');
|
||||
const { login } = useAuth();
|
||||
const { authMethods, isLoading: isLoadingMethods } = useAuthMethods();
|
||||
|
||||
// Determine which tab to show by default
|
||||
const getDefaultTab = () => {
|
||||
if (authMethods.emailPassword) return 'email';
|
||||
if (authMethods.sso.enabled) return 'sso';
|
||||
return 'email'; // fallback
|
||||
};
|
||||
|
||||
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const email = formData.get('email') as string | null;
|
||||
const password = formData.get('password') as string | null;
|
||||
|
||||
if (!email || !password) {
|
||||
toast.error('Please enter both email and password');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success('Login successful!');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSSOLogin(domain?: string, providerId?: string) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!domain && !ssoEmail) {
|
||||
toast.error('Please enter your email or select a provider');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
|
||||
await authClient.signIn.sso({
|
||||
email: ssoEmail || undefined,
|
||||
domain: domain,
|
||||
providerId: providerId,
|
||||
callbackURL: `${baseURL}/`,
|
||||
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
|
||||
<CardDescription>
|
||||
Log in to manage your GitHub to Gitea mirroring
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{isLoadingMethods ? (
|
||||
<CardContent>
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
{/* Show tabs only if multiple auth methods are available */}
|
||||
{authMethods.sso.enabled && authMethods.emailPassword ? (
|
||||
<Tabs defaultValue={getDefaultTab()} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mx-6" style={{ width: 'calc(100% - 3rem)' }}>
|
||||
<TabsTrigger value="email">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sso">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
SSO
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email">
|
||||
<CardContent>
|
||||
<form id="login-form" onSubmit={handleLogin}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Log In'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sso">
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{authMethods.sso.providers.length > 0 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Sign in with your organization account
|
||||
</p>
|
||||
{authMethods.sso.providers.map(provider => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleSSOLogin(provider.domain, provider.providerId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
Sign in with {provider.domain}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="sso-email" className="block text-sm font-medium mb-1">
|
||||
Work Email
|
||||
</label>
|
||||
<input
|
||||
id="sso-email"
|
||||
type="email"
|
||||
value={ssoEmail}
|
||||
onChange={(e) => setSsoEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your work email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
We'll redirect you to your organization's SSO provider
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleSSOLogin(undefined, undefined)}
|
||||
disabled={isLoading || !ssoEmail}
|
||||
>
|
||||
{isLoading ? 'Redirecting...' : 'Continue with SSO'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// Single auth method - show email/password only
|
||||
<>
|
||||
<CardContent>
|
||||
<form id="login-form" onSubmit={handleLogin}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Log In'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-6 pb-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account? Contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
Divers/gitea-mirror/src/components/auth/LoginPage.tsx
Normal file
10
Divers/gitea-mirror/src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { LoginForm } from './LoginForm';
|
||||
import Providers from '@/components/layout/Providers';
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<LoginForm />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
156
Divers/gitea-mirror/src/components/auth/SignupForm.tsx
Normal file
156
Divers/gitea-mirror/src/components/auth/SignupForm.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export function SignupForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const { register } = useAuth();
|
||||
|
||||
async function handleSignup(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const email = formData.get('email') as string | null;
|
||||
const password = formData.get('password') as string | null;
|
||||
const confirmPassword = formData.get('confirmPassword') as string | null;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
toast.error('Please fill in all fields');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Derive username from email (part before @)
|
||||
const username = email.split('@')[0];
|
||||
await register(username, email, password);
|
||||
toast.success('Account created successfully! Redirecting to dashboard...');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
|
||||
<CardDescription>
|
||||
Set up your administrator account for Gitea Mirror
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="signup-form" onSubmit={handleSignup}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Create a password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Confirm your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" form="signup-form" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating Account...' : 'Create Admin Account'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
Divers/gitea-mirror/src/components/auth/SignupPage.tsx
Normal file
10
Divers/gitea-mirror/src/components/auth/SignupPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SignupForm } from './SignupForm';
|
||||
import Providers from '@/components/layout/Providers';
|
||||
|
||||
export function SignupPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<SignupForm />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { AdvancedOptions } from "@/types/config";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface AdvancedOptionsFormProps {
|
||||
config: AdvancedOptions;
|
||||
setConfig: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
|
||||
onAutoSave?: (config: AdvancedOptions) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function AdvancedOptionsForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: AdvancedOptionsFormProps) {
|
||||
const handleChange = (name: string, checked: boolean) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: checked,
|
||||
};
|
||||
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="self-start">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center justify-between">
|
||||
Advanced Options
|
||||
{isAutoSaving && (
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
|
||||
<span className="text-xs">Auto-saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-forks"
|
||||
checked={config.skipForks}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("skipForks", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-forks"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Skip forks
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Don't mirror repositories that are forks of other repositories
|
||||
</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="starred-code-only"
|
||||
checked={config.starredCodeOnly}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("starredCodeOnly", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="starred-code-only"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Code-only mode for starred repos
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Mirror only source code for starred repositories, skipping all metadata (issues, PRs, labels, milestones, wiki, releases)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
446
Divers/gitea-mirror/src/components/config/AutomationSettings.tsx
Normal file
446
Divers/gitea-mirror/src/components/config/AutomationSettings.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Clock,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Activity,
|
||||
Zap,
|
||||
Info,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
onScheduleChange: (config: ScheduleConfig) => void;
|
||||
onCleanupChange: (config: DatabaseCleanupConfig) => void;
|
||||
isAutoSavingSchedule?: boolean;
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
const scheduleIntervals = [
|
||||
{ label: "Every hour", value: 3600 },
|
||||
{ label: "Every 2 hours", value: 7200 },
|
||||
{ label: "Every 4 hours", value: 14400 },
|
||||
{ label: "Every 8 hours", value: 28800 },
|
||||
{ label: "Every 12 hours", value: 43200 },
|
||||
{ label: "Daily", value: 86400 },
|
||||
{ label: "Every 2 days", value: 172800 },
|
||||
{ label: "Weekly", value: 604800 },
|
||||
];
|
||||
|
||||
const retentionPeriods = [
|
||||
{ label: "1 day", value: 86400 },
|
||||
{ label: "3 days", value: 259200 },
|
||||
{ label: "1 week", value: 604800 },
|
||||
{ label: "2 weeks", value: 1209600 },
|
||||
{ label: "1 month", value: 2592000 },
|
||||
{ label: "2 months", value: 5184000 },
|
||||
{ label: "3 months", value: 7776000 },
|
||||
];
|
||||
|
||||
function getCleanupInterval(retentionSeconds: number): number {
|
||||
const days = retentionSeconds / 86400;
|
||||
if (days <= 1) return 21600; // 6 hours
|
||||
if (days <= 3) return 43200; // 12 hours
|
||||
if (days <= 7) return 86400; // 24 hours
|
||||
if (days <= 30) return 172800; // 48 hours
|
||||
return 604800; // 1 week
|
||||
}
|
||||
|
||||
function getCleanupFrequencyText(retentionSeconds: number): string {
|
||||
const days = retentionSeconds / 86400;
|
||||
if (days <= 1) return "every 6 hours";
|
||||
if (days <= 3) return "every 12 hours";
|
||||
if (days <= 7) return "daily";
|
||||
if (days <= 30) return "every 2 days";
|
||||
return "weekly";
|
||||
}
|
||||
|
||||
export function AutomationSettings({
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
onScheduleChange,
|
||||
onCleanupChange,
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
// Update nextRun for cleanup when settings change
|
||||
useEffect(() => {
|
||||
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
|
||||
const cleanupInterval = getCleanupInterval(cleanupConfig.retentionDays);
|
||||
const nextRun = new Date(Date.now() + cleanupInterval * 1000);
|
||||
onCleanupChange({ ...cleanupConfig, nextRun });
|
||||
}
|
||||
}, [cleanupConfig.enabled, cleanupConfig.retentionDays]);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Automation & Maintenance
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 bg-muted hover:bg-muted/80 transition-colors">
|
||||
<Info className="h-3 w-3" />
|
||||
<span className="sr-only">Background operations info</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Background Operations</p>
|
||||
<p className="text-xs">
|
||||
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
|
||||
Choose intervals that match your workflow and repository update frequency.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
Automatic Syncing
|
||||
</h3>
|
||||
{isAutoSavingSchedule && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="enable-auto-mirror"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Enable automatic repository syncing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Periodically check GitHub for changes and mirror them to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mirror-interval" className="text-sm">
|
||||
Sync frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={scheduleConfig.interval.toString()}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
interval: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mirror-interval" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleIntervals.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
{scheduleConfig.enabled ? (
|
||||
scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
Database Maintenance
|
||||
</h3>
|
||||
{isAutoSavingCleanup && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
checked={cleanupConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="enable-auto-cleanup"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Enable automatic database cleanup
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old activity logs and events to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Activity logs and events older than this will be removed.
|
||||
Cleanup frequency is automatically optimized based on your retention period.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<Select
|
||||
value={cleanupConfig.retentionDays.toString()}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
retentionDays: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="retention-period" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{retentionPeriods.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last cleanup
|
||||
</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{cleanupConfig.lastRun
|
||||
? formatDate(cleanupConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
{cleanupConfig.enabled ? (
|
||||
cleanupConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next cleanup
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(cleanupConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic cleanup to optimize database storage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50 md:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Archive className="h-4 w-4 text-primary" />
|
||||
Repository Cleanup (orphaned mirrors)
|
||||
</h3>
|
||||
{isAutoSavingCleanup && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="cleanup-handle-orphans"
|
||||
checked={Boolean(cleanupConfig.deleteIfNotInGitHub)}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
deleteIfNotInGitHub: Boolean(checked),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="cleanup-handle-orphans"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Handle orphaned repositories automatically
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep your Gitea backups when GitHub repos disappear. Archive is the safest option—it preserves data and disables automatic syncs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.deleteIfNotInGitHub && (
|
||||
<div className="space-y-3 ml-6">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cleanup-orphaned-action" className="text-sm font-medium">
|
||||
Action for orphaned repositories
|
||||
</Label>
|
||||
<Select
|
||||
value={cleanupConfig.orphanedRepoAction ?? "archive"}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
orphanedRepoAction: value as DatabaseCleanupConfig["orphanedRepoAction"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="cleanup-orphaned-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="archive">Archive (preserve data)</SelectItem>
|
||||
<SelectItem value="skip">Skip (leave as-is)</SelectItem>
|
||||
<SelectItem value="delete">Delete from Gitea</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Archive renames mirror backups with an <code>archived-</code> prefix and disables automatic syncs—use Manual Sync when you want to refresh.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="cleanup-dry-run"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Dry run (log only)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-xl">
|
||||
When enabled, cleanup logs the planned action without modifying repositories.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cleanup-dry-run"
|
||||
checked={Boolean(cleanupConfig.dryRun)}
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
dryRun: Boolean(checked),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
703
Divers/gitea-mirror/src/components/config/ConfigTabs.tsx
Normal file
703
Divers/gitea-mirror/src/components/config/ConfigTabs.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { AutomationSettings } from './AutomationSettings';
|
||||
import { SSOSettings } from './SSOSettings';
|
||||
import type {
|
||||
ConfigApiResponse,
|
||||
GiteaConfig,
|
||||
GitHubConfig,
|
||||
SaveConfigApiRequest,
|
||||
SaveConfigApiResponse,
|
||||
ScheduleConfig,
|
||||
DatabaseCleanupConfig,
|
||||
MirrorOptions,
|
||||
AdvancedOptions,
|
||||
} from '@/types/config';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { apiRequest, showErrorToast } from '@/lib/utils';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
type ConfigState = {
|
||||
githubConfig: GitHubConfig;
|
||||
giteaConfig: GiteaConfig;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
mirrorOptions: MirrorOptions;
|
||||
advancedOptions: AdvancedOptions;
|
||||
};
|
||||
|
||||
export function ConfigTabs() {
|
||||
const [config, setConfig] = useState<ConfigState>({
|
||||
githubConfig: {
|
||||
username: '',
|
||||
token: '',
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
username: '',
|
||||
token: '',
|
||||
organization: 'github-mirrors',
|
||||
visibility: 'public',
|
||||
starredReposOrg: 'starred',
|
||||
preserveOrgStructure: false,
|
||||
},
|
||||
scheduleConfig: {
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
interval: 0, // Will be replaced with actual value from API
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
retentionDays: 0, // Will be replaced with actual value from API
|
||||
deleteIfNotInGitHub: true,
|
||||
orphanedRepoAction: "archive",
|
||||
dryRun: false,
|
||||
deleteFromGitea: false,
|
||||
protectedRepos: [],
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorReleases: false,
|
||||
mirrorLFS: false,
|
||||
mirrorMetadata: false,
|
||||
metadataComponents: {
|
||||
issues: false,
|
||||
pullRequests: false,
|
||||
labels: false,
|
||||
milestones: false,
|
||||
wiki: false,
|
||||
},
|
||||
},
|
||||
advancedOptions: {
|
||||
skipForks: false,
|
||||
starredCodeOnly: false,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||
|
||||
const [isAutoSavingSchedule, setIsAutoSavingSchedule] = useState<boolean>(false);
|
||||
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
||||
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
|
||||
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
|
||||
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isConfigFormValid = (): boolean => {
|
||||
const { githubConfig, giteaConfig } = config;
|
||||
const isGitHubValid = !!(
|
||||
githubConfig.username.trim() && githubConfig.token.trim()
|
||||
);
|
||||
const isGiteaValid = !!(
|
||||
giteaConfig.url.trim() &&
|
||||
giteaConfig.username.trim() &&
|
||||
giteaConfig.token.trim()
|
||||
);
|
||||
return isGitHubValid && isGiteaValid;
|
||||
};
|
||||
|
||||
const isGitHubConfigValid = (): boolean => {
|
||||
const { githubConfig } = config;
|
||||
return !!(githubConfig.username.trim() && githubConfig.token.trim());
|
||||
};
|
||||
|
||||
// Removed the problematic useEffect that was causing circular dependencies
|
||||
// The lastRun and nextRun should be managed by the backend and fetched via API
|
||||
|
||||
const handleImportGitHubData = async () => {
|
||||
if (!user?.id) return;
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const result = await apiRequest<{ success: boolean; message?: string }>(
|
||||
`/sync?userId=${user.id}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
result.success
|
||||
? toast.success(
|
||||
'GitHub data imported successfully! Head to the Repositories page to start mirroring.',
|
||||
)
|
||||
: toast.error(
|
||||
`Failed to import GitHub data: ${
|
||||
result.message || 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error importing GitHub data: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save function specifically for schedule config changes
|
||||
const autoSaveScheduleConfig = useCallback(async (scheduleConfig: ScheduleConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveScheduleTimeoutRef.current) {
|
||||
clearTimeout(autoSaveScheduleTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveScheduleTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingSchedule(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
// Removed refreshUser() call to prevent page reload
|
||||
// Invalidate config cache so other components get fresh data
|
||||
invalidateConfigCache();
|
||||
|
||||
// Fetch updated config to get the recalculated nextRun time
|
||||
try {
|
||||
const updatedResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
if (updatedResponse && !updatedResponse.error) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
scheduleConfig: updatedResponse.scheduleConfig || prev.scheduleConfig,
|
||||
}));
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
||||
}
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingSchedule(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.cleanupConfig]);
|
||||
|
||||
// Auto-save function specifically for cleanup config changes
|
||||
const autoSaveCleanupConfig = useCallback(async (cleanupConfig: DatabaseCleanupConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveCleanupTimeoutRef.current) {
|
||||
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveCleanupTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingCleanup(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
// Invalidate config cache so other components get fresh data
|
||||
invalidateConfigCache();
|
||||
|
||||
// Fetch updated config to get the recalculated nextRun time
|
||||
try {
|
||||
const updatedResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
if (updatedResponse && !updatedResponse.error) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
cleanupConfig: updatedResponse.cleanupConfig || prev.cleanupConfig,
|
||||
}));
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
||||
}
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingCleanup(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig]);
|
||||
|
||||
// Auto-save function specifically for GitHub config changes
|
||||
const autoSaveGitHubConfig = useCallback(async (githubConfig: GitHubConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveGitHubTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGitHubTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveGitHubTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingGitHub(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
// Invalidate config cache so other components get fresh data
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingGitHub(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.giteaConfig, config.scheduleConfig, config.cleanupConfig]);
|
||||
|
||||
// Auto-save function specifically for Gitea config changes
|
||||
const autoSaveGiteaConfig = useCallback(async (giteaConfig: GiteaConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveGiteaTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingGitea(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
// Invalidate config cache so other components get fresh data
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingGitea(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.scheduleConfig, config.cleanupConfig]);
|
||||
|
||||
// Auto-save function for mirror options (handled within GitHub config)
|
||||
const autoSaveMirrorOptions = useCallback(async (mirrorOptions: MirrorOptions) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.advancedOptions]);
|
||||
|
||||
// Auto-save function for advanced options (handled within GitHub config)
|
||||
const autoSaveAdvancedOptions = useCallback(async (advancedOptions: AdvancedOptions) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveScheduleTimeoutRef.current) {
|
||||
clearTimeout(autoSaveScheduleTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveCleanupTimeoutRef.current) {
|
||||
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveGitHubTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGitHubTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
if (response && !response.error) {
|
||||
setConfig({
|
||||
githubConfig:
|
||||
response.githubConfig || config.githubConfig,
|
||||
giteaConfig:
|
||||
response.giteaConfig || config.giteaConfig,
|
||||
scheduleConfig:
|
||||
response.scheduleConfig || config.scheduleConfig,
|
||||
cleanupConfig: {
|
||||
...config.cleanupConfig,
|
||||
...response.cleanupConfig, // Merge to preserve all fields
|
||||
},
|
||||
mirrorOptions: {
|
||||
...config.mirrorOptions,
|
||||
...response.mirrorOptions, // Merge to preserve all fields including new mirrorLFS
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || config.advancedOptions,
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Could not fetch configuration, using defaults:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, [user?.id]); // Only depend on user.id, not the entire user object
|
||||
|
||||
function ConfigCardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header section */}
|
||||
<div className="flex flex-row justify-between items-start">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<Skeleton className="h-10 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content section - Grid layout */}
|
||||
<div className="space-y-4">
|
||||
{/* GitHub & Gitea connections - Side by side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-1 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Automation & Maintenance - Full width */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<Skeleton className="h-8 w-48 mb-4" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isLoading ? (
|
||||
<div className="space-y-6">
|
||||
<ConfigCardSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Header section */}
|
||||
<div className="flex flex-col md:flex-row justify-between gap-y-4 items-start">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<h1 className="text-2xl font-semibold leading-none tracking-tight">
|
||||
Configuration
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your GitHub and Gitea connections, and set up automatic
|
||||
mirroring.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-x-4 w-full md:w-auto">
|
||||
<Button
|
||||
onClick={handleImportGitHubData}
|
||||
disabled={isSyncing || !isGitHubConfigValid()}
|
||||
title={
|
||||
!isGitHubConfigValid()
|
||||
? 'Please fill GitHub username and token fields'
|
||||
: isSyncing
|
||||
? 'Import in progress'
|
||||
: 'Import GitHub Data'
|
||||
}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
Import GitHub Data
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Import GitHub Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content section - Tabs layout */}
|
||||
<Tabs defaultValue="connections" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||
<TabsTrigger value="automation">Automation</TabsTrigger>
|
||||
<TabsTrigger value="sso">Authentication</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connections" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
|
||||
<GitHubConfigForm
|
||||
config={config.githubConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
githubConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.githubConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
mirrorOptions={config.mirrorOptions}
|
||||
setMirrorOptions={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
mirrorOptions:
|
||||
typeof update === 'function'
|
||||
? update(prev.mirrorOptions)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
advancedOptions={config.advancedOptions}
|
||||
setAdvancedOptions={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
advancedOptions:
|
||||
typeof update === 'function'
|
||||
? update(prev.advancedOptions)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGitHubConfig}
|
||||
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
|
||||
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
|
||||
isAutoSaving={isAutoSavingGitHub}
|
||||
/>
|
||||
<GiteaConfigForm
|
||||
config={config.giteaConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
giteaConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.giteaConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGiteaConfig}
|
||||
isAutoSaving={isAutoSavingGitea}
|
||||
githubUsername={config.githubConfig.username}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="automation" className="space-y-4">
|
||||
<AutomationSettings
|
||||
scheduleConfig={config.scheduleConfig}
|
||||
cleanupConfig={config.cleanupConfig}
|
||||
onScheduleChange={(newConfig) => {
|
||||
setConfig(prev => ({ ...prev, scheduleConfig: newConfig }));
|
||||
autoSaveScheduleConfig(newConfig);
|
||||
}}
|
||||
onCleanupChange={(newConfig) => {
|
||||
setConfig(prev => ({ ...prev, cleanupConfig: newConfig }));
|
||||
autoSaveCleanupConfig(newConfig);
|
||||
}}
|
||||
isAutoSavingSchedule={isAutoSavingSchedule}
|
||||
isAutoSavingCleanup={isAutoSavingCleanup}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sso" className="space-y-4">
|
||||
<SSOSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
Divers/gitea-mirror/src/components/config/GitHubConfigForm.tsx
Normal file
220
Divers/gitea-mirror/src/components/config/GitHubConfigForm.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { githubApi } from "@/lib/api";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import { Input } from "../ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Info } from "lucide-react";
|
||||
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
interface GitHubConfigFormProps {
|
||||
config: GitHubConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
|
||||
mirrorOptions: MirrorOptions;
|
||||
setMirrorOptions: React.Dispatch<React.SetStateAction<MirrorOptions>>;
|
||||
advancedOptions: AdvancedOptions;
|
||||
setAdvancedOptions: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
|
||||
onAutoSave?: (githubConfig: GitHubConfig) => Promise<void>;
|
||||
onMirrorOptionsAutoSave?: (mirrorOptions: MirrorOptions) => Promise<void>;
|
||||
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function GitHubConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
mirrorOptions,
|
||||
setMirrorOptions,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
onAutoSave,
|
||||
onMirrorOptionsAutoSave,
|
||||
onAdvancedOptionsAutoSave,
|
||||
isAutoSaving
|
||||
}: GitHubConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
};
|
||||
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save for all field changes
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!config.token) {
|
||||
toast.error("GitHub token is required to test the connection");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await githubApi.testConnection(config.token);
|
||||
if (result.success) {
|
||||
toast.success("Successfully connected to GitHub!");
|
||||
} else {
|
||||
toast.error("Failed to connect to GitHub. Please check your token.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "An unknown error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
GitHub Configuration
|
||||
</CardTitle>
|
||||
{/* Desktop: Show button in header */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={testConnection}
|
||||
disabled={isLoading || !config.token}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
{isLoading ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="github-username"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
GitHub Username
|
||||
</label>
|
||||
<Input
|
||||
id="github-username"
|
||||
name="username"
|
||||
type="text"
|
||||
value={config.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Your GitHub username"
|
||||
required
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
GitHub Token
|
||||
</label>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-flex p-0.5 hover:bg-muted rounded-sm transition-colors cursor-help">
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">GitHub Token Requirements</h4>
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
You need to create a <span className="font-medium">Classic GitHub PAT Token</span> with the following scopes:
|
||||
</p>
|
||||
<ul className="ml-4 space-y-1 list-disc">
|
||||
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">repo</code></li>
|
||||
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">admin:org</code></li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">
|
||||
The organization access is required for mirroring organization repositories.
|
||||
</p>
|
||||
<p>
|
||||
Generate tokens at{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<Input
|
||||
id="github-token"
|
||||
name="token"
|
||||
type="password"
|
||||
value={config.token}
|
||||
onChange={handleChange}
|
||||
className="bg-background"
|
||||
placeholder="Your GitHub token (classic) with repo and admin:org scopes"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Required for private repositories, organizations, and starred
|
||||
repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<GitHubMirrorSettings
|
||||
githubConfig={config}
|
||||
mirrorOptions={mirrorOptions}
|
||||
advancedOptions={advancedOptions}
|
||||
onGitHubConfigChange={(newConfig) => {
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onMirrorOptionsChange={(newOptions) => {
|
||||
setMirrorOptions(newOptions);
|
||||
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
|
||||
}}
|
||||
onAdvancedOptionsChange={(newOptions) => {
|
||||
setAdvancedOptions(newOptions);
|
||||
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mobile: Show button at bottom */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={testConnection}
|
||||
disabled={isLoading || !config.token}
|
||||
className="sm:hidden w-full"
|
||||
>
|
||||
{isLoading ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
Archive,
|
||||
GitPullRequest,
|
||||
Tag,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Target,
|
||||
BookOpen,
|
||||
GitFork,
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive,
|
||||
FileCode2
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
mirrorOptions: MirrorOptions;
|
||||
advancedOptions: AdvancedOptions;
|
||||
onGitHubConfigChange: (config: GitHubConfig) => void;
|
||||
onMirrorOptionsChange: (options: MirrorOptions) => void;
|
||||
onAdvancedOptionsChange: (options: AdvancedOptions) => void;
|
||||
}
|
||||
|
||||
export function GitHubMirrorSettings({
|
||||
githubConfig,
|
||||
mirrorOptions,
|
||||
advancedOptions,
|
||||
onGitHubConfigChange,
|
||||
onMirrorOptionsChange,
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean | number) => {
|
||||
onMirrorOptionsChange({ ...mirrorOptions, [field]: value });
|
||||
};
|
||||
|
||||
const handleMetadataComponentChange = (component: keyof MirrorOptions['metadataComponents'], value: boolean) => {
|
||||
onMirrorOptionsChange({
|
||||
...mirrorOptions,
|
||||
metadataComponents: {
|
||||
...mirrorOptions.metadataComponents,
|
||||
[component]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdvancedChange = (field: keyof AdvancedOptions, value: boolean) => {
|
||||
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
|
||||
};
|
||||
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
// Calculate what content is included for starred repos
|
||||
const starredRepoContent = {
|
||||
code: true, // Always included
|
||||
releases: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorReleases,
|
||||
issues: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
pullRequests: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
wiki: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
};
|
||||
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Repository Selection
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose which repositories to include in mirroring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="private-repos"
|
||||
checked={githubConfig.privateRepositories}
|
||||
onCheckedChange={(checked) => handleGitHubChange('privateRepositories', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="private-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Include private repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mirror your private repositories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="starred-repos"
|
||||
checked={githubConfig.mirrorStarred}
|
||||
onCheckedChange={(checked) => handleGitHubChange('mirrorStarred', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="starred-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Mirror starred repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include repositories you've starred on GitHub
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Starred repos content selection - responsive layout */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
|
||||
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 hidden pointer-events-none"
|
||||
)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!githubConfig.mirrorStarred}
|
||||
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{advancedOptions.starredCodeOnly ? (
|
||||
"Code only"
|
||||
) : starredContentCount === 0 ? (
|
||||
"Code only"
|
||||
) : starredContentCount === totalStarredOptions ? (
|
||||
"Full content"
|
||||
) : (
|
||||
`${starredContentCount + 1} of ${totalStarredOptions + 1} selected`
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Starred repos content</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Choose what content to mirror from starred repositories.
|
||||
Selecting "Lightweight mode" will only mirror code for better performance.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="starred-lightweight"
|
||||
checked={advancedOptions.starredCodeOnly}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('starredCodeOnly', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="starred-lightweight"
|
||||
className="text-sm font-normal cursor-pointer flex-1"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">Lightweight mode</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only mirror code, skip all metadata
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!advancedOptions.starredCodeOnly && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Content included for starred repos:
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs pl-2">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Source code</span>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-2 h-4">Always</Badge>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.releases ? "" : "opacity-50"
|
||||
)}>
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Releases & Tags</span>
|
||||
{starredRepoContent.releases && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.issues ? "" : "opacity-50"
|
||||
)}>
|
||||
<MessageSquare className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Issues</span>
|
||||
{starredRepoContent.issues && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.pullRequests ? "" : "opacity-50"
|
||||
)}>
|
||||
<GitPullRequest className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Pull Requests</span>
|
||||
{starredRepoContent.pullRequests && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.wiki ? "" : "opacity-50"
|
||||
)}>
|
||||
<BookOpen className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Wiki</span>
|
||||
{starredRepoContent.wiki && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
To include more content, enable them in the Content & Data section below
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Duplicate name handling
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">Name collision strategy</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How to handle repos with the same name from different owners
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={githubConfig.starredDuplicateStrategy || "suffix"}
|
||||
onValueChange={(value) => handleGitHubChange('starredDuplicateStrategy', value as DuplicateNameStrategy)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="suffix" className="text-xs">
|
||||
<span className="font-mono">repo-owner</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="prefix" className="text-xs">
|
||||
<span className="font-mono">owner-repo</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Content & Data Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Archive className="h-4 w-4" />
|
||||
Content & Data
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Select what content to mirror from each repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Code is always mirrored - shown as info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-muted/50 dark:bg-muted/20 rounded-md">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">Source code & branches</p>
|
||||
<p className="text-xs text-muted-foreground">Always included</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">Default</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-releases"
|
||||
checked={mirrorOptions.mirrorReleases}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorReleases', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-releases"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
Releases & Tags
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include GitHub releases, tags, and associated assets
|
||||
</p>
|
||||
</div>
|
||||
{mirrorOptions.mirrorReleases && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<label htmlFor="release-limit" className="text-xs text-muted-foreground">
|
||||
Latest
|
||||
</label>
|
||||
<input
|
||||
id="release-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={mirrorOptions.releaseLimit || 10}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 10;
|
||||
const clampedValue = Math.min(100, Math.max(1, value));
|
||||
handleMirrorChange('releaseLimit', clampedValue);
|
||||
}}
|
||||
className="w-16 px-2 py-1 text-xs border border-input rounded bg-background text-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">releases</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-lfs"
|
||||
checked={mirrorOptions.mirrorLFS}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorLFS', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-lfs"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
Git LFS (Large File Storage)
|
||||
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mirror Git LFS objects. Requires LFS to be enabled on your Gitea server and Git v2.1.2+
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-metadata"
|
||||
checked={mirrorOptions.mirrorMetadata}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorMetadata', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-metadata"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Repository Metadata
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mirror issues, pull requests, and other repository data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata multi-select - responsive layout */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
|
||||
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 hidden pointer-events-none"
|
||||
)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!mirrorOptions.mirrorMetadata}
|
||||
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{(() => {
|
||||
const selectedCount = Object.values(mirrorOptions.metadataComponents).filter(Boolean).length;
|
||||
const totalCount = Object.keys(mirrorOptions.metadataComponents).length;
|
||||
if (selectedCount === 0) return "No items selected";
|
||||
if (selectedCount === totalCount) return "All items selected";
|
||||
return `${selectedCount} of ${totalCount} selected`;
|
||||
})()}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Metadata to mirror</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-1 text-xs font-normal text-primary hover:text-primary/80"
|
||||
onClick={() => {
|
||||
const allSelected = Object.values(mirrorOptions.metadataComponents).every(Boolean);
|
||||
const newValue = !allSelected;
|
||||
|
||||
// Update all metadata components at once
|
||||
onMirrorOptionsChange({
|
||||
...mirrorOptions,
|
||||
metadataComponents: {
|
||||
issues: newValue,
|
||||
pullRequests: newValue,
|
||||
labels: newValue,
|
||||
milestones: newValue,
|
||||
wiki: newValue,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Object.values(mirrorOptions.metadataComponents).every(Boolean) ? 'Deselect all' : 'Select all'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-issues-popup"
|
||||
checked={mirrorOptions.metadataComponents.issues}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('issues', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-issues-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Issues
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-prs-popup"
|
||||
checked={mirrorOptions.metadataComponents.pullRequests}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('pullRequests', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-prs-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Pull Requests
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-sm">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold">Pull Requests are mirrored as issues</p>
|
||||
<p className="text-xs">
|
||||
Due to Gitea API limitations, PRs cannot be created as actual pull requests.
|
||||
Instead, they are mirrored as issues with:
|
||||
</p>
|
||||
<ul className="text-xs space-y-1 ml-3">
|
||||
<li>• [PR #number] prefix in title</li>
|
||||
<li>• Full PR description and metadata</li>
|
||||
<li>• Commit history (up to 10 commits)</li>
|
||||
<li>• File changes summary</li>
|
||||
<li>• Diff preview (first 5 files)</li>
|
||||
<li>• Review comments preserved</li>
|
||||
<li>• Merge/close status tracking</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-labels-popup"
|
||||
checked={mirrorOptions.metadataComponents.labels}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('labels', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-labels-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Labels
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-milestones-popup"
|
||||
checked={mirrorOptions.metadataComponents.milestones}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('milestones', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-milestones-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Target className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Milestones
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-wiki-popup"
|
||||
checked={mirrorOptions.metadataComponents.wiki}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('wiki', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-wiki-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Wiki
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Filtering & Behavior Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Funnel className="h-4 w-4" />
|
||||
Filtering & Behavior
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Fine-tune what gets excluded from mirroring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="skip-forks"
|
||||
checked={advancedOptions.skipForks}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipForks', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="skip-forks"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
Skip forked repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Exclude repositories that are forks of other projects
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
Divers/gitea-mirror/src/components/config/GiteaConfigForm.tsx
Normal file
275
Divers/gitea-mirror/src/components/config/GiteaConfigForm.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { giteaApi } from "@/lib/api";
|
||||
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationStrategy } from "./OrganizationStrategy";
|
||||
import { OrganizationConfiguration } from "./OrganizationConfiguration";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
interface GiteaConfigFormProps {
|
||||
config: GiteaConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
|
||||
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
githubUsername?: string;
|
||||
}
|
||||
|
||||
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Derive the mirror strategy from existing config for backward compatibility
|
||||
const getMirrorStrategy = (): MirrorStrategy => {
|
||||
if (config.mirrorStrategy) return config.mirrorStrategy;
|
||||
// Check for mixed mode: when we have both organization and personalReposOrg defined
|
||||
if (config.organization && config.personalReposOrg && !config.preserveOrgStructure) return "mixed";
|
||||
if (config.preserveOrgStructure) return "preserve";
|
||||
if (config.organization && config.organization !== config.username) return "single-org";
|
||||
return "flat-user";
|
||||
};
|
||||
|
||||
const [mirrorStrategy, setMirrorStrategy] = useState<MirrorStrategy>(getMirrorStrategy());
|
||||
|
||||
// Update config when strategy changes
|
||||
useEffect(() => {
|
||||
const newConfig = { ...config };
|
||||
|
||||
switch (mirrorStrategy) {
|
||||
case "preserve":
|
||||
newConfig.preserveOrgStructure = true;
|
||||
newConfig.mirrorStrategy = "preserve";
|
||||
newConfig.personalReposOrg = undefined; // Clear personal repos org in preserve mode
|
||||
break;
|
||||
case "single-org":
|
||||
newConfig.preserveOrgStructure = false;
|
||||
newConfig.mirrorStrategy = "single-org";
|
||||
// Reset to default if coming from mixed mode where it was personal repos org
|
||||
if (config.mirrorStrategy === "mixed" || !newConfig.organization || newConfig.organization === "github-personal") {
|
||||
newConfig.organization = "github-mirrors";
|
||||
}
|
||||
break;
|
||||
case "flat-user":
|
||||
newConfig.preserveOrgStructure = false;
|
||||
newConfig.mirrorStrategy = "flat-user";
|
||||
newConfig.organization = "";
|
||||
break;
|
||||
case "mixed":
|
||||
newConfig.preserveOrgStructure = false;
|
||||
newConfig.mirrorStrategy = "mixed";
|
||||
// In mixed mode, organization field represents personal repos org
|
||||
// Reset it to default if coming from single-org mode
|
||||
if (config.mirrorStrategy === "single-org" || !newConfig.organization || newConfig.organization === "github-mirrors") {
|
||||
newConfig.organization = "github-personal";
|
||||
}
|
||||
if (!newConfig.personalReposOrg) {
|
||||
newConfig.personalReposOrg = "github-personal";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
}, [mirrorStrategy]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
const checked = type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
|
||||
|
||||
// Special handling for preserveOrgStructure changes
|
||||
if (
|
||||
name === "preserveOrgStructure" &&
|
||||
config.preserveOrgStructure !== checked
|
||||
) {
|
||||
toast.info(
|
||||
"Changing this setting may affect how repositories are accessed in Gitea. " +
|
||||
"Existing mirrored repositories will still be accessible during sync operations.",
|
||||
{
|
||||
duration: 6000,
|
||||
position: "top-center",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
};
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save for all field changes
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!config.url || !config.token) {
|
||||
toast.error("Gitea URL and token are required to test the connection");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await giteaApi.testConnection(config.url, config.token);
|
||||
if (result.success) {
|
||||
toast.success("Successfully connected to Gitea!");
|
||||
} else {
|
||||
toast.error(
|
||||
"Failed to connect to Gitea. Please check your URL and token."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "An unknown error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Gitea Configuration
|
||||
</CardTitle>
|
||||
{/* Desktop: Show button in header */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={testConnection}
|
||||
disabled={isLoading || !config.url || !config.token}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
{isLoading ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-username"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Gitea Username
|
||||
</label>
|
||||
<input
|
||||
id="gitea-username"
|
||||
name="username"
|
||||
type="text"
|
||||
value={config.username}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Your Gitea username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-url"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Gitea URL
|
||||
</label>
|
||||
<input
|
||||
id="gitea-url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={config.url}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="https://your-gitea-instance.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-token"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Gitea Token
|
||||
</label>
|
||||
<input
|
||||
id="gitea-token"
|
||||
name="token"
|
||||
type="password"
|
||||
value={config.token}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Your Gitea access token"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Create a token in your Gitea instance under Settings >
|
||||
Applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<OrganizationStrategy
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
onStrategyChange={setMirrorStrategy}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<OrganizationConfiguration
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
personalReposOrg={config.personalReposOrg}
|
||||
visibility={config.visibility}
|
||||
onDestinationOrgChange={(org) => {
|
||||
const newConfig = { ...config, organization: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onStarredReposOrgChange={(org) => {
|
||||
const newConfig = { ...config, starredReposOrg: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onPersonalReposOrgChange={(org) => {
|
||||
const newConfig = { ...config, personalReposOrg: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onVisibilityChange={(visibility) => {
|
||||
const newConfig = { ...config, visibility };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mobile: Show button at bottom */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={testConnection}
|
||||
disabled={isLoading || !config.url || !config.token}
|
||||
className="sm:hidden w-full"
|
||||
>
|
||||
{isLoading ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Star, Globe, Lock, Shield, Info, MonitorCog } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MirrorStrategy, GiteaOrgVisibility } from "@/types/config";
|
||||
|
||||
interface OrganizationConfigurationProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
personalReposOrg?: string;
|
||||
visibility: GiteaOrgVisibility;
|
||||
onDestinationOrgChange: (org: string) => void;
|
||||
onStarredReposOrgChange: (org: string) => void;
|
||||
onPersonalReposOrgChange: (org: string) => void;
|
||||
onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
|
||||
}
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "public" as GiteaOrgVisibility, label: "Public", icon: Globe, description: "Visible to everyone" },
|
||||
{ value: "private" as GiteaOrgVisibility, label: "Private", icon: Lock, description: "Visible to members only" },
|
||||
{ value: "limited" as GiteaOrgVisibility, label: "Limited", icon: Shield, description: "Visible to logged-in users" },
|
||||
];
|
||||
|
||||
export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps> = ({
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
personalReposOrg,
|
||||
visibility,
|
||||
onDestinationOrgChange,
|
||||
onStarredReposOrgChange,
|
||||
onPersonalReposOrgChange,
|
||||
onVisibilityChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<MonitorCog className="h-4 w-4" />
|
||||
Organization Configuration
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* First row - Organization inputs with consistent layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Left column - always shows starred repos org */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Starred Repos Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Starred repositories will be organized separately in this organization</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="starredReposOrg"
|
||||
value={starredReposOrg || ""}
|
||||
onChange={(e) => onStarredReposOrgChange(e.target.value)}
|
||||
placeholder="starred"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Keep starred repos organized separately
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right column - shows destination org for single-org/mixed, personal repos org for preserve, empty div for others */}
|
||||
{strategy === "single-org" || strategy === "mixed" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
{strategy === "mixed" ? "Personal Repos Organization" : "Destination Organization"}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{strategy === "mixed"
|
||||
? "Personal repositories will be mirrored to this organization, while organization repos preserve their structure"
|
||||
: "All repositories will be mirrored to this organization"
|
||||
}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="destinationOrg"
|
||||
value={destinationOrg || ""}
|
||||
onChange={(e) => onDestinationOrgChange(e.target.value)}
|
||||
placeholder={strategy === "mixed" ? "github-personal" : "github-mirrors"}
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{strategy === "mixed"
|
||||
? "All personal repos will go to this organization"
|
||||
: "Organization for consolidated repositories"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Second row - Organization Visibility (always shown) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal flex items-center gap-2">
|
||||
Organization Visibility
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Default visibility for newly created organizations</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{visibilityOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = visibility === option.value;
|
||||
return (
|
||||
<TooltipProvider key={option.value}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVisibilityChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
|
||||
"border group",
|
||||
isSelected
|
||||
? "bg-accent border-accent-foreground/20"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity hidden sm:inline-block" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">{option.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Info, GitBranch, FolderTree, Star, Building2, User, Building } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
|
||||
|
||||
interface OrganizationStrategyProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
onStrategyChange: (strategy: MirrorStrategy) => void;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}
|
||||
|
||||
const strategyConfig = {
|
||||
preserve: {
|
||||
title: "Preserve Structure",
|
||||
icon: FolderTree,
|
||||
description: "Keep the exact same org structure as GitHub",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||
borderColor: "border-blue-200 dark:border-blue-900",
|
||||
repoColors: {
|
||||
bg: "bg-blue-50 dark:bg-blue-950/30",
|
||||
icon: "text-blue-600 dark:text-blue-400"
|
||||
}
|
||||
},
|
||||
"single-org": {
|
||||
title: "Single Organization",
|
||||
icon: Building2,
|
||||
description: "Consolidate all repositories into one Gitea organization",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-950/20",
|
||||
borderColor: "border-purple-200 dark:border-purple-900",
|
||||
repoColors: {
|
||||
bg: "bg-purple-50 dark:bg-purple-950/30",
|
||||
icon: "text-purple-600 dark:text-purple-400"
|
||||
}
|
||||
},
|
||||
"flat-user": {
|
||||
title: "User Repositories",
|
||||
icon: User,
|
||||
description: "Place all repositories directly under your user account",
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-50 dark:bg-green-950/20",
|
||||
borderColor: "border-green-200 dark:border-green-900",
|
||||
repoColors: {
|
||||
bg: "bg-green-50 dark:bg-green-950/30",
|
||||
icon: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
},
|
||||
"mixed": {
|
||||
title: "Mixed Mode",
|
||||
icon: GitBranch,
|
||||
description: "Personal repos in single org, org repos preserve structure",
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-50 dark:bg-orange-950/20",
|
||||
borderColor: "border-orange-200 dark:border-orange-900",
|
||||
repoColors: {
|
||||
bg: "bg-orange-50 dark:bg-orange-950/30",
|
||||
icon: "text-orange-600 dark:text-orange-400"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MappingPreview: React.FC<{
|
||||
strategy: MirrorStrategy;
|
||||
config: typeof strategyConfig.preserve;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}> = ({ strategy, config, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => {
|
||||
const displayGithubUsername = githubUsername || "<username>";
|
||||
const displayGiteaUsername = giteaUsername || "<username>";
|
||||
const isGithubPlaceholder = !githubUsername;
|
||||
const isGiteaPlaceholder = !giteaUsername;
|
||||
|
||||
if (strategy === "preserve") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "single-org") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "flat-user") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "mixed") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
onStrategyChange,
|
||||
githubUsername,
|
||||
giteaUsername,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Building className="h-4 w-4" />
|
||||
Organization Strategy
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how your repositories will be organized in Gitea
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<span>Override Options</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-[380px]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After selecting a strategy, you can customize destinations for specific organizations and repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-2 border-t">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium">Organization Overrides</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Click the edit button on any organization card to redirect all its repositories to a different Gitea organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium">Repository Overrides</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Use the inline editor in the repository table's "Destination" column to set custom destinations for individual repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="text-xs font-medium">Starred Repositories</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Always go to the configured starred repos organization and cannot be overridden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Priority:</span> Repository override → Organization override → Strategy default
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadioGroup value={strategy} onValueChange={onStrategyChange}>
|
||||
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4">
|
||||
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
|
||||
const isSelected = strategy === key;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<label htmlFor={key} className="cursor-pointer">
|
||||
<Card
|
||||
className={cn(
|
||||
"relative",
|
||||
isSelected && `${config.borderColor} border-2`,
|
||||
!isSelected && "border-muted"
|
||||
)}
|
||||
>
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<RadioGroupItem
|
||||
value={key}
|
||||
id={key}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
<div className={cn(
|
||||
"rounded-lg p-2 flex-shrink-0",
|
||||
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-4 w-4",
|
||||
isSelected ? config.color : "text-muted-foreground dark:text-muted-foreground/70"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-sm">{config.title}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span
|
||||
className="inline-flex p-1 sm:p-1.5 hover:bg-muted rounded-md transition-colors cursor-help flex-shrink-0 ml-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="left" align="center" className="w-[500px]">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
|
||||
<MappingPreview
|
||||
strategy={key}
|
||||
config={config}
|
||||
destinationOrg={destinationOrg}
|
||||
starredReposOrg={starredReposOrg}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
727
Divers/gitea-mirror/src/components/config/SSOSettings.tsx
Normal file
727
Divers/gitea-mirror/src/components/config/SSOSettings.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { apiRequest, showErrorToast } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
|
||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||
try {
|
||||
const url = new URL(issuer);
|
||||
return allowedHosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`));
|
||||
} catch {
|
||||
return false; // Return false if the URL is invalid
|
||||
}
|
||||
}
|
||||
interface SSOProvider {
|
||||
id: string;
|
||||
issuer: string;
|
||||
domain: string;
|
||||
providerId: string;
|
||||
organizationId?: string;
|
||||
oidcConfig?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
discoveryEndpoint?: string;
|
||||
scopes?: string[];
|
||||
pkce?: boolean;
|
||||
};
|
||||
samlConfig?: {
|
||||
entryPoint: string;
|
||||
cert: string;
|
||||
callbackUrl?: string;
|
||||
audience?: string;
|
||||
wantAssertionsSigned?: boolean;
|
||||
signatureAlgorithm?: string;
|
||||
digestAlgorithm?: string;
|
||||
identifierFormat?: string;
|
||||
};
|
||||
mapping?: {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerified?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function SSOSettings() {
|
||||
const [providers, setProviders] = useState<SSOProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showProviderDialog, setShowProviderDialog] = useState(false);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<SSOProvider | null>(null);
|
||||
|
||||
// Form states for new provider
|
||||
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
// Common fields
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
// OIDC fields
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
// SAML fields
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [providersRes, headerAuthStatus] = await Promise.all([
|
||||
apiRequest<SSOProvider[] | { providers: SSOProvider[] }>('/sso/providers'),
|
||||
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
|
||||
]);
|
||||
|
||||
setProviders(Array.isArray(providersRes) ? providersRes : providersRes?.providers || []);
|
||||
setHeaderAuthEnabled(headerAuthStatus.enabled);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const discoverOIDC = async () => {
|
||||
if (!providerForm.issuer) {
|
||||
toast.error('Please enter an issuer URL');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDiscovering(true);
|
||||
try {
|
||||
const discovered = await apiRequest<any>('/sso/discover', {
|
||||
method: 'POST',
|
||||
data: { issuer: providerForm.issuer },
|
||||
});
|
||||
|
||||
setProviderForm(prev => ({
|
||||
...prev,
|
||||
authorizationEndpoint: discovered.authorizationEndpoint || '',
|
||||
tokenEndpoint: discovered.tokenEndpoint || '',
|
||||
jwksEndpoint: discovered.jwksEndpoint || '',
|
||||
userInfoEndpoint: discovered.userInfoEndpoint || '',
|
||||
discoveryEndpoint: discovered.discoveryEndpoint || `${providerForm.issuer}/.well-known/openid-configuration`,
|
||||
domain: discovered.suggestedDomain || prev.domain,
|
||||
}));
|
||||
|
||||
toast.success('OIDC configuration discovered successfully');
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createProvider = async () => {
|
||||
setAddingProvider(true);
|
||||
try {
|
||||
const requestData: any = {
|
||||
providerId: providerForm.providerId,
|
||||
issuer: providerForm.issuer,
|
||||
domain: providerForm.domain,
|
||||
organizationId: providerForm.organizationId || undefined,
|
||||
providerType,
|
||||
};
|
||||
|
||||
if (providerType === 'oidc') {
|
||||
requestData.clientId = providerForm.clientId;
|
||||
requestData.clientSecret = providerForm.clientSecret;
|
||||
requestData.authorizationEndpoint = providerForm.authorizationEndpoint;
|
||||
requestData.tokenEndpoint = providerForm.tokenEndpoint;
|
||||
requestData.jwksEndpoint = providerForm.jwksEndpoint;
|
||||
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
|
||||
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
|
||||
requestData.scopes = providerForm.scopes;
|
||||
requestData.pkce = providerForm.pkce;
|
||||
} else {
|
||||
requestData.entryPoint = providerForm.entryPoint;
|
||||
requestData.cert = providerForm.cert;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
|
||||
requestData.audience = providerForm.audience || window.location.origin;
|
||||
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
|
||||
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
|
||||
requestData.digestAlgorithm = providerForm.digestAlgorithm;
|
||||
requestData.identifierFormat = providerForm.identifierFormat;
|
||||
}
|
||||
|
||||
if (editingProvider) {
|
||||
// Update existing provider
|
||||
const updatedProvider = await apiRequest<SSOProvider>(`/sso/providers?id=${editingProvider.id}`, {
|
||||
method: 'PUT',
|
||||
data: requestData,
|
||||
});
|
||||
setProviders(providers.map(p => p.id === editingProvider.id ? updatedProvider : p));
|
||||
toast.success('SSO provider updated successfully');
|
||||
} else {
|
||||
// Create new provider
|
||||
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
||||
method: 'POST',
|
||||
data: requestData,
|
||||
});
|
||||
setProviders([...providers, newProvider]);
|
||||
toast.success('SSO provider created successfully');
|
||||
}
|
||||
|
||||
setShowProviderDialog(false);
|
||||
setEditingProvider(null);
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setAddingProvider(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditProvider = (provider: SSOProvider) => {
|
||||
setEditingProvider(provider);
|
||||
setProviderType(provider.samlConfig ? 'saml' : 'oidc');
|
||||
|
||||
if (provider.oidcConfig) {
|
||||
setProviderForm({
|
||||
...providerForm,
|
||||
providerId: provider.providerId,
|
||||
issuer: provider.issuer,
|
||||
domain: provider.domain,
|
||||
organizationId: provider.organizationId || '',
|
||||
clientId: provider.oidcConfig.clientId || '',
|
||||
clientSecret: provider.oidcConfig.clientSecret || '',
|
||||
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint || '',
|
||||
tokenEndpoint: provider.oidcConfig.tokenEndpoint || '',
|
||||
jwksEndpoint: provider.oidcConfig.jwksEndpoint || '',
|
||||
userInfoEndpoint: provider.oidcConfig.userInfoEndpoint || '',
|
||||
discoveryEndpoint: provider.oidcConfig.discoveryEndpoint || '',
|
||||
scopes: provider.oidcConfig.scopes || ['openid', 'email', 'profile'],
|
||||
pkce: provider.oidcConfig.pkce !== false,
|
||||
});
|
||||
}
|
||||
|
||||
setShowProviderDialog(true);
|
||||
};
|
||||
|
||||
const deleteProvider = async (id: string) => {
|
||||
try {
|
||||
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
|
||||
setProviders(providers.filter(p => p.id !== id));
|
||||
toast.success('Provider deleted successfully');
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with status indicators */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Authentication & SSO</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure how users authenticate with your application
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{providers.length} Provider{providers.length !== 1 ? 's' : ''} configured
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Methods Overview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Email & Password - Always enabled */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-medium">Email & Password</span>
|
||||
<Badge variant="secondary" className="text-xs">Default</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Always enabled</span>
|
||||
</div>
|
||||
|
||||
{/* Header Authentication Status */}
|
||||
{headerAuthEnabled && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-medium">Header Authentication</span>
|
||||
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSO Providers Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
|
||||
<span className="text-sm font-medium">SSO/OIDC Providers</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Auth Info */}
|
||||
{headerAuthEnabled && (
|
||||
<Alert className="mt-4">
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SSO Providers */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">External Identity Providers</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
? 'Update the configuration for this identity provider'
|
||||
: 'Configure an external identity provider for user authentication'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1">
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
|
||||
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
||||
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
<Input
|
||||
id="providerId"
|
||||
value={providerForm.providerId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||
placeholder="google-sso"
|
||||
disabled={!!editingProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">Email Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={providerForm.domain}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer">Issuer URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="issuer"
|
||||
value={providerForm.issuer}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
|
||||
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
|
||||
/>
|
||||
{providerType === 'oidc' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverOIDC}
|
||||
disabled={isDiscovering}
|
||||
>
|
||||
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
|
||||
<Input
|
||||
id="organizationId"
|
||||
value={providerForm.organizationId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
|
||||
placeholder="org_123"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
value={providerForm.clientId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={providerForm.clientSecret}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
|
||||
<Input
|
||||
id="authEndpoint"
|
||||
value={providerForm.authorizationEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
|
||||
placeholder="https://accounts.google.com/o/oauth2/auth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
|
||||
<Input
|
||||
id="tokenEndpoint"
|
||||
value={providerForm.tokenEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
|
||||
placeholder="https://oauth2.googleapis.com/token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">OAuth Scopes</Label>
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ label: "OpenID", value: "openid" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Profile", value: "profile" },
|
||||
{ label: "Offline Access", value: "offline_access" },
|
||||
]}
|
||||
selected={providerForm.scopes}
|
||||
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
|
||||
placeholder="Select scopes..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the OAuth scopes to request from the provider
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="pkce"
|
||||
checked={providerForm.pkce}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
|
||||
/>
|
||||
<Label htmlFor="pkce">Enable PKCE</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="saml" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entryPoint">SAML Entry Point</Label>
|
||||
<Input
|
||||
id="entryPoint"
|
||||
value={providerForm.entryPoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
|
||||
placeholder="https://idp.example.com/sso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert">X.509 Certificate</Label>
|
||||
<Textarea
|
||||
id="cert"
|
||||
value={providerForm.cert}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
|
||||
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="wantAssertionsSigned"
|
||||
checked={providerForm.wantAssertionsSigned}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
|
||||
/>
|
||||
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowProviderDialog(false);
|
||||
setEditingProvider(null);
|
||||
// Reset form
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createProvider} disabled={addingProvider}>
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{editingProvider ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
editingProvider ? 'Update Provider' : 'Create Provider'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{providers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium">No SSO providers configured</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
Enable Single Sign-On by adding an external identity provider like Google, Azure AD, or any OIDC-compliant service.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={() => setShowProviderDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{providers.map(provider => (
|
||||
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Issuer:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.issuer}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Client ID:</span>
|
||||
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Scopes:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{provider.oidcConfig.scopes.map(scope => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider.samlConfig && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Entry Point:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider.organizationId && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Organization:</span>
|
||||
<span className="text-muted-foreground">{provider.organizationId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => startEditProvider(provider)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteProvider(provider.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
Divers/gitea-mirror/src/components/config/ScheduleConfigForm.tsx
Normal file
157
Divers/gitea-mirror/src/components/config/ScheduleConfigForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { ScheduleConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface ScheduleConfigFormProps {
|
||||
config: ScheduleConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<ScheduleConfig>>;
|
||||
onAutoSave?: (config: ScheduleConfig) => void;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: ScheduleConfigFormProps) {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]:
|
||||
type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
|
||||
};
|
||||
setConfig(newConfig);
|
||||
|
||||
// Trigger auto-save for schedule config changes
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// Predefined intervals
|
||||
const intervals: { value: number; label: string }[] = [
|
||||
{ value: 3600, label: "1 hour" },
|
||||
{ value: 7200, label: "2 hours" },
|
||||
{ value: 14400, label: "4 hours" },
|
||||
{ value: 28800, label: "8 hours" },
|
||||
{ value: 43200, label: "12 hours" },
|
||||
{ value: 86400, label: "1 day" },
|
||||
{ value: 172800, label: "2 days" },
|
||||
{ value: 604800, label: "1 week" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="self-start">
|
||||
<CardContent className="pt-6 relative">
|
||||
{isAutoSaving && (
|
||||
<div className="absolute top-4 right-4 flex items-center text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
|
||||
<span className="text-xs">Auto-saving...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enabled"
|
||||
className="select-none ml-2 block text-sm font-medium"
|
||||
>
|
||||
Enable Automatic Syncing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="interval"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Sync Interval
|
||||
</label>
|
||||
|
||||
<Select
|
||||
name="interval"
|
||||
value={String(config.interval)}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "interval", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{intervals.map((interval) => (
|
||||
<SelectItem
|
||||
key={interval.value}
|
||||
value={interval.value.toString()}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{interval.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
How often the sync process should run.
|
||||
</p>
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Sync Schedule:</strong> Repositories will be synchronized at the specified interval.
|
||||
Choose shorter intervals for frequently updated repositories, longer intervals for stable ones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Last Sync</label>
|
||||
<div className="text-sm">
|
||||
{config.lastRun ? formatDate(config.lastRun) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Next Sync</label>
|
||||
<div className="text-sm">
|
||||
{config.nextRun ? formatDate(config.nextRun) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
342
Divers/gitea-mirror/src/components/dashboard/Dashboard.tsx
Normal file
342
Divers/gitea-mirror/src/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { StatusCard } from "./StatusCard";
|
||||
import { RecentActivity } from "./RecentActivity";
|
||||
import { RepositoryList } from "./RepositoryList";
|
||||
import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect as useEffectForToasts } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const syncDate = new Date(date);
|
||||
const diffMs = now.getTime() - syncDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
// Show relative time for recent syncs
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For older syncs, show week count
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For even older, show month count
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
// Helper function to format full timestamp
|
||||
function formatFullTimestamp(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true
|
||||
}).replace(',', '');
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const isPageVisible = usePageVisibility();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [repoCount, setRepoCount] = useState<number>(0);
|
||||
const [orgCount, setOrgCount] = useState<number>(0);
|
||||
const [mirroredCount, setMirroredCount] = useState<number>(0);
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
|
||||
// Dashboard auto-refresh timer (30 seconds)
|
||||
const dashboardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DASHBOARD_REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
if (data.repositoryId) {
|
||||
setRepositories((prevRepos) =>
|
||||
prevRepos.map((repo) =>
|
||||
repo.id === data.repositoryId
|
||||
? { ...repo, status: data.status, details: data.details }
|
||||
: repo
|
||||
)
|
||||
);
|
||||
} else if (data.organizationId) {
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) =>
|
||||
org.id === data.organizationId
|
||||
? { ...org, status: data.status, details: data.details }
|
||||
: org
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
setActivities((prevActivities) => [data, ...prevActivities]);
|
||||
}, []);
|
||||
|
||||
// Use the SSE hook
|
||||
const { connected } = useSSE({
|
||||
userId: user?.id,
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
// Setup rate limit event listener for toast notifications
|
||||
useEffectForToasts(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
||||
|
||||
eventSource.addEventListener("rate-limit", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case "warning":
|
||||
// 80% threshold warning
|
||||
toast.warning("GitHub API Rate Limit Warning", {
|
||||
description: data.message,
|
||||
duration: 8000,
|
||||
});
|
||||
break;
|
||||
|
||||
case "exceeded":
|
||||
// 100% rate limit exceeded
|
||||
toast.error("GitHub API Rate Limit Exceeded", {
|
||||
description: data.message,
|
||||
duration: 10000,
|
||||
});
|
||||
break;
|
||||
|
||||
case "resumed":
|
||||
// Rate limit reset notification
|
||||
toast.success("Rate Limit Reset", {
|
||||
description: "API operations have resumed.",
|
||||
duration: 5000,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing rate limit event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Extract fetchDashboardData as a stable callback
|
||||
const fetchDashboardData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (!user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't fetch data if configuration is not complete
|
||||
if (!isFullyConfigured) {
|
||||
if (showToast) {
|
||||
toast.info("Please configure GitHub and Gitea settings first");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await apiRequest<DashboardApiResponse>(
|
||||
`/dashboard?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setRepositories(response.repositories);
|
||||
setOrganizations(response.organizations);
|
||||
setActivities(response.activities);
|
||||
setRepoCount(response.repoCount);
|
||||
setOrgCount(response.orgCount);
|
||||
setMirroredCount(response.mirroredCount);
|
||||
setLastSync(response.lastSync);
|
||||
|
||||
if (showToast) {
|
||||
toast.success("Dashboard data refreshed successfully");
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
showErrorToast(response.error || "Error fetching dashboard data", toast);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user?.id, isFullyConfigured]); // Only depend on user.id, not entire user object
|
||||
|
||||
// Initial data fetch and reset loading state when component becomes active
|
||||
useEffect(() => {
|
||||
// Reset loading state when component mounts or becomes active
|
||||
setIsLoading(true);
|
||||
fetchDashboardData();
|
||||
}, [fetchDashboardData, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Setup dashboard auto-refresh (30 seconds) and register with live refresh
|
||||
useEffect(() => {
|
||||
// Clear any existing timer
|
||||
if (dashboardTimerRef.current) {
|
||||
clearInterval(dashboardTimerRef.current);
|
||||
dashboardTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Set up 30-second auto-refresh only when page is visible and configuration is complete
|
||||
if (isPageVisible && isFullyConfigured) {
|
||||
dashboardTimerRef.current = setInterval(() => {
|
||||
fetchDashboardData();
|
||||
}, DASHBOARD_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when page becomes invisible
|
||||
return () => {
|
||||
if (dashboardTimerRef.current) {
|
||||
clearInterval(dashboardTimerRef.current);
|
||||
dashboardTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPageVisible, isFullyConfigured, fetchDashboardData]);
|
||||
|
||||
// Register with global live refresh system
|
||||
useEffect(() => {
|
||||
// Only register if configuration is complete
|
||||
if (!isFullyConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchDashboardData();
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchDashboardData, isFullyConfigured]);
|
||||
|
||||
// Status Card Skeleton component
|
||||
function StatusCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardTitle>
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16 mb-1" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return isLoading || !connected ? (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||
<StatusCardSkeleton />
|
||||
<StatusCardSkeleton />
|
||||
<StatusCardSkeleton />
|
||||
<StatusCardSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||
{/* Repository List Skeleton */}
|
||||
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Skeleton */}
|
||||
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||
<StatusCard
|
||||
title="Repositories"
|
||||
value={repoCount}
|
||||
icon={<GitFork className="h-4 w-4" />}
|
||||
description="Total imported repositories"
|
||||
/>
|
||||
<StatusCard
|
||||
title="Mirrored"
|
||||
value={mirroredCount}
|
||||
icon={<FlipHorizontal className="h-4 w-4" />}
|
||||
description="Synced to Gitea"
|
||||
/>
|
||||
<StatusCard
|
||||
title="Organizations"
|
||||
value={orgCount}
|
||||
icon={<Building2 className="h-4 w-4" />}
|
||||
description="From GitHub"
|
||||
/>
|
||||
<StatusCard
|
||||
title="Last Sync"
|
||||
value={formatLastSyncTime(lastSync)}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
description={formatFullTimestamp(lastSync)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||
<div className="w-full lg:w-1/2">
|
||||
<RepositoryList repositories={repositories.slice(0, 8)} />
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-1/2">
|
||||
<RecentActivity activities={activities.slice(0, 8)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
}
|
||||
|
||||
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/activity">View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Clock className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No recent activity</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4">
|
||||
Activity will appear here when you start mirroring repositories.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/activity">
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
View History
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{activities.map((activity, index) => (
|
||||
<div key={index} className="flex items-center gap-x-3 py-3.5">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{activity.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(activity.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
156
Divers/gitea-mirror/src/components/dashboard/RepositoryList.tsx
Normal file
156
Divers/gitea-mirror/src/components/dashboard/RepositoryList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitFork } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
|
||||
interface RepositoryListProps {
|
||||
repositories: Repository[];
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/repositories">View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No repositories found</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4">
|
||||
Configure your GitHub connection to start mirroring repositories.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/config">Configure GitHub</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{repositories.map((repo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-x-3 py-3.5"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
repo.status
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium truncate">{repo.name}</h4>
|
||||
{repo.isPrivate && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<span className="truncate">{repo.owner}</span>
|
||||
{repo.organization && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="truncate">{repo.organization}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-medium mr-2
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 dark:text-green-400' :
|
||||
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' :
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 dark:text-red-400' :
|
||||
'bg-muted text-muted-foreground'}`}>
|
||||
{repo.status}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaRepoUrl(repo);
|
||||
const giteaEnabled = giteaUrl && ['mirrored', 'synced'].includes(repo.status);
|
||||
|
||||
return giteaEnabled ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on Gitea"
|
||||
>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled title="Not mirrored yet">
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
33
Divers/gitea-mirror/src/components/dashboard/StatusCard.tsx
Normal file
33
Divers/gitea-mirror/src/components/dashboard/StatusCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
description,
|
||||
className,
|
||||
}: StatusCardProps) {
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="h-4 w-4 text-muted-foreground">{icon}</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
173
Divers/gitea-mirror/src/components/layout/Header.tsx
Normal file
173
Divers/gitea-mirror/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
onNavigate?: (page: string) => void;
|
||||
onMenuClick: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
isSidebarCollapsed?: boolean;
|
||||
isSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||
|
||||
// Show Live button on all pages except configuration
|
||||
const showLiveButton = currentPage && currentPage !== "configuration";
|
||||
|
||||
// Determine button state and tooltip
|
||||
const isLiveActive = isLiveEnabled && isFullyConfigured;
|
||||
const getTooltip = () => {
|
||||
if (configLoading) {
|
||||
return 'Loading configuration...';
|
||||
}
|
||||
if (!isFullyConfigured) {
|
||||
return isLiveEnabled
|
||||
? 'Live refresh enabled but requires GitHub and Gitea configuration to function'
|
||||
: 'Enable live refresh (requires GitHub and Gitea configuration)';
|
||||
}
|
||||
return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh';
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
// Small delay to show the toast before redirecting
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
logout();
|
||||
};
|
||||
|
||||
// Auth buttons skeleton loader
|
||||
function AuthButtonsSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-10 w-10 rounded-full" /> {/* Avatar placeholder */}
|
||||
<Skeleton className="h-10 w-24" /> {/* Button placeholder */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-b bg-background">
|
||||
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center lg:gap-12 md:gap-6 gap-4">
|
||||
{/* Sidebar Toggle - Mobile uses slide-in, Medium uses collapse */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="md:hidden h-10 w-10"
|
||||
onClick={onMenuClick}
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
|
||||
{/* Sidebar Collapse Toggle - Only on medium screens (768px - 1280px) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden md:flex xl:hidden h-10 w-10"
|
||||
onClick={onToggleCollapse}
|
||||
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
window.history.pushState({}, '', '/');
|
||||
onNavigate?.('dashboard');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-5 w-6"
|
||||
/>
|
||||
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
{showLiveButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="flex items-center gap-1.5 px-3 sm:px-4"
|
||||
onClick={toggleLive}
|
||||
title={getTooltip()}
|
||||
>
|
||||
<div className={`size-4 sm:size-3 rounded-full ${
|
||||
configLoading
|
||||
? 'bg-yellow-400 animate-pulse'
|
||||
: isLiveActive
|
||||
? 'bg-emerald-400 animate-pulse'
|
||||
: isLiveEnabled
|
||||
? 'bg-orange-400'
|
||||
: 'bg-gray-500'
|
||||
}`} />
|
||||
<span className="text-sm font-medium hidden sm:inline">LIVE</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{isLoading ? (
|
||||
<AuthButtonsSkeleton />
|
||||
) : user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
|
||||
<Avatar className="h-full w-full">
|
||||
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
|
||||
<AvatarFallback>
|
||||
{(user.name || user.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/login">Login</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
166
Divers/gitea-mirror/src/components/layout/MainLayout.tsx
Normal file
166
Divers/gitea-mirror/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||
import Repository from "../repositories/Repository";
|
||||
import Providers from "./Providers";
|
||||
import { ConfigTabs } from "../config/ConfigTabs";
|
||||
import { ActivityLog } from "../activity/ActivityLog";
|
||||
import { Organization } from "../organizations/Organization";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRepoSync } from "@/hooks/useSyncRepo";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
|
||||
// Navigation context to signal when navigation happens
|
||||
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
|
||||
|
||||
export const useNavigation = () => useContext(NavigationContext);
|
||||
|
||||
interface AppProps {
|
||||
page:
|
||||
| "dashboard"
|
||||
| "repositories"
|
||||
| "organizations"
|
||||
| "configuration"
|
||||
| "activity-log";
|
||||
"client:load"?: boolean;
|
||||
"client:idle"?: boolean;
|
||||
"client:visible"?: boolean;
|
||||
"client:media"?: string;
|
||||
"client:only"?: boolean | string;
|
||||
}
|
||||
|
||||
export default function App({ page }: AppProps) {
|
||||
return (
|
||||
<Providers>
|
||||
<AppWithProviders page={page} />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const { isLoading: configLoading } = useConfigStatus();
|
||||
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
||||
const [navigationKey, setNavigationKey] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Check if we're on medium screens (768px - 1280px)
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth >= 768 && window.innerWidth < 1280;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useRepoSync({
|
||||
userId: user?.id,
|
||||
enabled: false, // TODO: Get from config
|
||||
interval: 3600, // TODO: Get from config
|
||||
lastSync: null,
|
||||
nextSync: null,
|
||||
});
|
||||
|
||||
// Handle navigation from sidebar
|
||||
const handleNavigation = (pageName: string) => {
|
||||
setCurrentPage(pageName as AppProps['page']);
|
||||
// Increment navigation key to force components to refresh their loading state
|
||||
setNavigationKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname;
|
||||
const pageMap: Record<string, AppProps['page']> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
'/organizations': 'organizations',
|
||||
'/config': 'configuration',
|
||||
'/activity': 'activity-log'
|
||||
};
|
||||
|
||||
const pageName = pageMap[path] || 'dashboard';
|
||||
setCurrentPage(pageName);
|
||||
// Also increment navigation key for browser navigation to trigger loading states
|
||||
setNavigationKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
// Handle window resize to auto-collapse sidebar on medium screens
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
// Auto-collapse on medium screens (768px - 1280px)
|
||||
if (width >= 768 && width < 1280) {
|
||||
setSidebarCollapsed(true);
|
||||
} else if (width >= 1280) {
|
||||
// Expand on large screens
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Show loading state only during initial auth/config loading
|
||||
const isInitialLoading = authLoading || (configLoading && !user);
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!authLoading && !user) {
|
||||
// Use window.location for client-side redirect
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContext.Provider value={{ navigationKey }}>
|
||||
<main className="flex min-h-screen flex-col">
|
||||
<Header
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleNavigation}
|
||||
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
/>
|
||||
<div className="flex flex-1 relative">
|
||||
<Sidebar
|
||||
onNavigate={handleNavigation}
|
||||
isOpen={sidebarOpen}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<section className={`flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'md:w-[calc(100%-5rem)] xl:w-[calc(100%-16rem)]' : 'md:w-[calc(100%-16rem)]'
|
||||
}`}>
|
||||
{currentPage === "dashboard" && <Dashboard />}
|
||||
{currentPage === "repositories" && <Repository />}
|
||||
{currentPage === "organizations" && <Organization />}
|
||||
{currentPage === "configuration" && <ConfigTabs />}
|
||||
{currentPage === "activity-log" && <ActivityLog />}
|
||||
</section>
|
||||
</div>
|
||||
<Toaster />
|
||||
</main>
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
16
Divers/gitea-mirror/src/components/layout/Providers.tsx
Normal file
16
Divers/gitea-mirror/src/components/layout/Providers.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { AuthProvider } from "@/hooks/useAuth";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { LiveRefreshProvider } from "@/hooks/useLiveRefresh";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<LiveRefreshProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</LiveRefreshProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
207
Divers/gitea-mirror/src/components/layout/Sidebar.tsx
Normal file
207
Divers/gitea-mirror/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { links } from "@/data/Sidebar";
|
||||
import { VersionInfo } from "./VersionInfo";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
onNavigate?: (page: string) => void;
|
||||
isOpen: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onClose: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, onClose, onToggleCollapse }: SidebarProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Hydration happens here
|
||||
const path = window.location.pathname;
|
||||
setCurrentPath(path);
|
||||
}, []);
|
||||
|
||||
// Listen for URL changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const handleNavigation = (href: string, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Don't navigate if already on the same page
|
||||
if (currentPath === href) return;
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.pushState({}, '', href);
|
||||
setCurrentPath(href);
|
||||
|
||||
// Map href to page name for the parent component
|
||||
const pageMap: Record<string, string> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
'/organizations': 'organizations',
|
||||
'/config': 'configuration',
|
||||
'/activity': 'activity-log'
|
||||
};
|
||||
|
||||
const pageName = pageMap[href] || 'dashboard';
|
||||
onNavigate?.(pageName);
|
||||
|
||||
// Close sidebar on mobile after navigation
|
||||
if (window.innerWidth < 768) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-sm z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed md:static inset-y-0 left-0 z-50 bg-background border-r flex flex-col h-full md:h-[calc(100vh-4.5rem)] transition-all duration-200 ease-in-out md:translate-x-0",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isCollapsed ? "md:w-20 xl:w-64" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<nav className={cn(
|
||||
"flex flex-col pt-4 flex-shrink-0",
|
||||
isCollapsed
|
||||
? "md:gap-y-2 md:items-center md:px-2 xl:gap-y-1 xl:items-stretch xl:pl-2 xl:pr-3 gap-y-1 pl-2 pr-3"
|
||||
: "gap-y-1 pl-2 pr-3"
|
||||
)}>
|
||||
{links.map((link, index) => {
|
||||
const isActive = currentPath === link.href;
|
||||
const Icon = link.icon;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => handleNavigation(link.href, e)}
|
||||
className={cn(
|
||||
"flex items-center rounded-md text-sm font-medium transition-colors w-full",
|
||||
isCollapsed
|
||||
? "md:h-12 md:w-12 md:justify-center md:p-0 xl:h-auto xl:w-full xl:justify-start xl:px-3 xl:py-2 h-auto px-3 py-3"
|
||||
: "px-3 py-3 md:py-2",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"flex-shrink-0",
|
||||
isCollapsed
|
||||
? "md:h-5 md:w-5 md:mr-0 xl:h-4 xl:w-4 xl:mr-3 h-5 w-5 mr-3"
|
||||
: "h-5 w-5 md:h-4 md:w-4 mr-3"
|
||||
)} />
|
||||
<span className={cn(
|
||||
"transition-all duration-200",
|
||||
isCollapsed ? "md:hidden xl:inline" : "inline"
|
||||
)}>
|
||||
{link.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Wrap in tooltip when collapsed on medium screens
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="hidden md:block xl:hidden">
|
||||
{link.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-h-0" />
|
||||
|
||||
<div className={cn(
|
||||
"py-4 flex-shrink-0",
|
||||
isCollapsed ? "md:px-2 xl:px-4 px-4" : "px-4"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"rounded-md bg-muted transition-all duration-200",
|
||||
isCollapsed ? "md:p-0 xl:p-3 p-3" : "p-3"
|
||||
)}>
|
||||
<div className={cn(
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3 md:mb-2">
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="h-3.5 w-3.5 md:h-3 md:w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{/* Icon-only help button for collapsed state on medium screens */}
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md hover:bg-accent transition-colors",
|
||||
isCollapsed ? "md:h-12 md:w-12 xl:hidden hidden" : "hidden"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Documentation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className={cn(
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<VersionInfo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
Divers/gitea-mirror/src/components/layout/SponsorCard.tsx
Normal file
72
Divers/gitea-mirror/src/components/layout/SponsorCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Heart, Coffee, Sparkles } from "lucide-react";
|
||||
import { isSelfHostedMode } from "@/lib/deployment-mode";
|
||||
|
||||
export function SponsorCard() {
|
||||
// Only show in self-hosted mode
|
||||
if (!isSelfHostedMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-auto p-4 border-t">
|
||||
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 border-purple-500/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-pink-500" />
|
||||
Support Development
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Help us improve Gitea Mirror
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gitea Mirror is open source and free. Your sponsorship helps us maintain and improve it.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full h-8 text-xs"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/sponsors/RayLabsHQ"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Heart className="w-3 h-3 mr-2" />
|
||||
Sponsor on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full h-8 text-xs"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://buymeacoffee.com/raylabs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Coffee className="w-3 h-3 mr-2" />
|
||||
Buy us a coffee
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Pro features available in hosted version
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
Divers/gitea-mirror/src/components/layout/VersionInfo.tsx
Normal file
49
Divers/gitea-mirror/src/components/layout/VersionInfo.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { healthApi } from "@/lib/api";
|
||||
|
||||
export function VersionInfo() {
|
||||
const [versionInfo, setVersionInfo] = useState<{
|
||||
current: string;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
}>({
|
||||
current: "loading...",
|
||||
latest: "",
|
||||
updateAvailable: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const healthData = await healthApi.check();
|
||||
setVersionInfo({
|
||||
current: healthData.version || "unknown",
|
||||
latest: healthData.latestVersion || "unknown",
|
||||
updateAvailable: healthData.updateAvailable || false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch version:", error);
|
||||
setVersionInfo({
|
||||
current: "unknown",
|
||||
latest: "",
|
||||
updateAvailable: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchVersion();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2 pb-3 border-t border-border mt-2">
|
||||
{versionInfo.updateAvailable ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>v{versionInfo.current}</span>
|
||||
<span className="text-primary">v{versionInfo.latest} available</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>v{versionInfo.current}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
Divers/gitea-mirror/src/components/oauth/ConsentPage.tsx
Normal file
307
Divers/gitea-mirror/src/components/oauth/ConsentPage.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { apiRequest, showErrorToast } from '@/lib/utils';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { Shield, User, Mail, ChevronRight, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { isValidRedirectUri, parseRedirectUris } from '@/lib/utils/oauth-validation';
|
||||
|
||||
interface OAuthApplication {
|
||||
id: string;
|
||||
clientId: string;
|
||||
name: string;
|
||||
redirectURLs: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ConsentRequest {
|
||||
clientId: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
export default function ConsentPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [application, setApplication] = useState<OAuthApplication | null>(null);
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConsentDetails();
|
||||
}, []);
|
||||
|
||||
const loadConsentDetails = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clientId = params.get('client_id');
|
||||
const scope = params.get('scope');
|
||||
const redirectUri = params.get('redirect_uri');
|
||||
|
||||
if (!clientId) {
|
||||
setError('Invalid authorization request: missing client ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch application details
|
||||
const apps = await apiRequest<OAuthApplication[]>('/sso/applications');
|
||||
const app = apps.find(a => a.clientId === clientId);
|
||||
|
||||
if (!app) {
|
||||
setError('Invalid authorization request: unknown application');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate redirect URI if provided
|
||||
if (redirectUri) {
|
||||
const authorizedUris = parseRedirectUris(app.redirectURLs);
|
||||
|
||||
if (!isValidRedirectUri(redirectUri, authorizedUris)) {
|
||||
setError('Invalid authorization request: unauthorized redirect URI');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setApplication(app);
|
||||
|
||||
// Parse requested scopes
|
||||
const requestedScopes = scope ? scope.split(' ').filter(s => s) : ['openid'];
|
||||
setScopes(requestedScopes);
|
||||
|
||||
// By default, select all requested scopes
|
||||
setSelectedScopes(new Set(requestedScopes));
|
||||
} catch (error) {
|
||||
console.error('Failed to load consent details:', error);
|
||||
setError('Failed to load authorization details');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConsent = async (accept: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await authClient.oauth2.consent({
|
||||
accept,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'Consent failed');
|
||||
}
|
||||
|
||||
// The consent method should handle the redirect
|
||||
if (!accept) {
|
||||
// If denied, redirect back to the application with error
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectUri = params.get('redirect_uri');
|
||||
|
||||
if (redirectUri && application) {
|
||||
// Validate redirect URI against authorized URIs
|
||||
const authorizedUris = parseRedirectUris(application.redirectURLs);
|
||||
|
||||
if (isValidRedirectUri(redirectUri, authorizedUris)) {
|
||||
try {
|
||||
// Parse and reconstruct the URL to ensure it's safe
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
|
||||
// Safe to redirect - URI has been validated and sanitized
|
||||
window.location.href = url.toString();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse redirect URI:', e);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
} else {
|
||||
console.error('Unauthorized redirect URI:', redirectUri);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
// openid scope is always required
|
||||
if (scope === 'openid') return;
|
||||
|
||||
const newSelected = new Set(selectedScopes);
|
||||
if (newSelected.has(scope)) {
|
||||
newSelected.delete(scope);
|
||||
} else {
|
||||
newSelected.add(scope);
|
||||
}
|
||||
setSelectedScopes(newSelected);
|
||||
};
|
||||
|
||||
const getScopeDescription = (scope: string): { name: string; description: string; icon: any } => {
|
||||
const scopeDescriptions: Record<string, { name: string; description: string; icon: any }> = {
|
||||
openid: {
|
||||
name: 'Basic Information',
|
||||
description: 'Your user ID (required)',
|
||||
icon: User,
|
||||
},
|
||||
profile: {
|
||||
name: 'Profile Information',
|
||||
description: 'Your name, username, and profile picture',
|
||||
icon: User,
|
||||
},
|
||||
email: {
|
||||
name: 'Email Address',
|
||||
description: 'Your email address and verification status',
|
||||
icon: Mail,
|
||||
},
|
||||
};
|
||||
|
||||
return scopeDescriptions[scope] || {
|
||||
name: scope,
|
||||
description: `Access to ${scope} information`,
|
||||
icon: Shield,
|
||||
};
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Authorization Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Authorize {application?.name}</CardTitle>
|
||||
<CardDescription>
|
||||
This application is requesting access to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">Requested permissions:</p>
|
||||
<div className="space-y-3">
|
||||
{scopes.map(scope => {
|
||||
const scopeInfo = getScopeDescription(scope);
|
||||
const Icon = scopeInfo.icon;
|
||||
const isRequired = scope === 'openid';
|
||||
|
||||
return (
|
||||
<div key={scope} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={scope}
|
||||
checked={selectedScopes.has(scope)}
|
||||
onCheckedChange={() => toggleScope(scope)}
|
||||
disabled={isRequired || isSubmitting}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={scope}
|
||||
className="flex items-center gap-2 font-medium cursor-pointer"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{scopeInfo.name}
|
||||
{isRequired && (
|
||||
<span className="text-xs text-muted-foreground">(required)</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{scopeInfo.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
You'll be redirected to {application?.type === 'web' ? 'the website' : 'the application'}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 mt-1">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
You can revoke access at any time in your account settings
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => handleConsent(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => handleConsent(true)}
|
||||
disabled={isSubmitting || selectedScopes.size === 0}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Authorizing...
|
||||
</>
|
||||
) : (
|
||||
'Authorize'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { LoaderCircle, Plus } from "lucide-react";
|
||||
import type { MembershipRole } from "@/types/organizations";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
interface AddOrganizationDialogProps {
|
||||
isDialogOpen: boolean;
|
||||
setIsDialogOpen: (isOpen: boolean) => void;
|
||||
onAddOrganization: ({
|
||||
org,
|
||||
role,
|
||||
force,
|
||||
}: {
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
force?: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AddOrganizationDialog({
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
onAddOrganization,
|
||||
}: AddOrganizationDialogProps) {
|
||||
const [org, setOrg] = useState<string>("");
|
||||
const [role, setRole] = useState<MembershipRole>("member");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setError("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!org || org.trim() === "") {
|
||||
setError("Please enter a valid organization name.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await onAddOrganization({ org, role });
|
||||
|
||||
setError("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add organization.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
|
||||
<Plus className="h-6 w-6" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
You can add public organizations
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={org}
|
||||
onChange={(e) => setOrg(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="e.g., microsoft"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Membership Role
|
||||
</label>
|
||||
|
||||
<RadioGroup
|
||||
value={role}
|
||||
onValueChange={(val) => setRole(val as MembershipRole)}
|
||||
className="flex flex-col gap-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="member" id="r1" />
|
||||
<Label htmlFor="r1">Member</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="admin" id="r2" />
|
||||
<Label htmlFor="r2">Admin</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="billing_manager" id="r3" />
|
||||
<Label htmlFor="r3">Billing Manager</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Add Organization"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, Building2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MirrorDestinationEditorProps {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
currentDestination?: string;
|
||||
onUpdate: (newDestination: string | null) => Promise<void>;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MirrorDestinationEditor({
|
||||
organizationId,
|
||||
organizationName,
|
||||
currentDestination,
|
||||
onUpdate,
|
||||
isUpdating = false,
|
||||
className,
|
||||
}: MirrorDestinationEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState(currentDestination || "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasOverride = currentDestination && currentDestination !== organizationName;
|
||||
const effectiveDestination = currentDestination || organizationName;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === "" || trimmedValue === organizationName
|
||||
? null
|
||||
: trimmedValue;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onUpdate(newDestination);
|
||||
setIsOpen(false);
|
||||
toast.success(
|
||||
newDestination
|
||||
? `Destination updated to: ${newDestination}`
|
||||
: "Destination reset to default"
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to update destination");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setEditValue("");
|
||||
await handleSave();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(currentDestination || "");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 w-full", className)}>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
|
||||
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium truncate">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"font-medium truncate",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}>
|
||||
{effectiveDestination}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400 flex-shrink-0">
|
||||
custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 sm:h-6 sm:w-6 p-0 opacity-60 hover:opacity-100"
|
||||
title="Edit mirror destination"
|
||||
disabled={isUpdating || isLoading}
|
||||
>
|
||||
<Edit3 className="h-5 w-5 sm:h-3 sm:w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Mirror Destination</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customize where this organization's repositories are mirrored to in Gitea.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Visual Preview */}
|
||||
<div className="rounded-md bg-muted/50 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Preview</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{organizationName}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-primary">
|
||||
{editValue.trim() || organizationName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="destination" className="text-xs">
|
||||
Destination Organization
|
||||
</Label>
|
||||
<Input
|
||||
id="destination"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder={organizationName}
|
||||
className="h-8"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default GitHub organization name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="w-full h-8 text-xs"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-2" />
|
||||
Reset to Default ({organizationName})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2 } from "lucide-react";
|
||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||
import { OrganizationList } from "./OrganizationsList";
|
||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import {
|
||||
membershipRoleEnum,
|
||||
type AddOrganizationApiRequest,
|
||||
type AddOrganizationApiResponse,
|
||||
type MembershipRole,
|
||||
type OrganizationsApiResponse,
|
||||
} from "@/types/organizations";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
import { toast } from "sonner";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function Organization() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const { user } = useAuth();
|
||||
const { isGitHubConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
membershipRole: "",
|
||||
status: "",
|
||||
});
|
||||
const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
|
||||
const [duplicateOrgCandidate, setDuplicateOrgCandidate] = useState<{
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
} | null>(null);
|
||||
const [isDuplicateOrgDialogOpen, setIsDuplicateOrgDialogOpen] = useState(false);
|
||||
const [isProcessingDuplicateOrg, setIsProcessingDuplicateOrg] = useState(false);
|
||||
const [orgToDelete, setOrgToDelete] = useState<Organization | null>(null);
|
||||
const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false);
|
||||
const [isDeletingOrg, setIsDeletingOrg] = useState(false);
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
if (data.organizationId) {
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) =>
|
||||
org.id === data.organizationId
|
||||
? { ...org, status: data.status, details: data.details }
|
||||
: org
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Use the SSE hook
|
||||
const { connected } = useSSE({
|
||||
userId: user?.id,
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
const fetchOrganizations = useCallback(async (isLiveRefresh = false) => {
|
||||
if (!user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't fetch organizations if GitHub is not configured
|
||||
if (!isGitHubConfigured) {
|
||||
if (!isLiveRefresh) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isLiveRefresh) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
const response = await apiRequest<OrganizationsApiResponse>(
|
||||
`/github/organizations?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setOrganizations(response.organizations);
|
||||
return true;
|
||||
} else {
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(response.error || "Error fetching organizations");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error fetching organizations"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (!isLiveRefresh) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
|
||||
|
||||
useEffect(() => {
|
||||
// Reset loading state when component becomes active
|
||||
setIsLoading(true);
|
||||
fetchOrganizations(false); // Manual refresh, not live
|
||||
}, [fetchOrganizations, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Register with global live refresh system
|
||||
useEffect(() => {
|
||||
// Only register for live refresh if GitHub is configured
|
||||
if (!isGitHubConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchOrganizations(true); // Live refresh
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchOrganizations, isGitHubConfigured]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const success = await fetchOrganizations(false);
|
||||
if (success) {
|
||||
toast.success("Organizations refreshed successfully.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMirrorOrg = async ({ orgId }: { orgId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
|
||||
|
||||
const reqPayload: MirrorOrgRequest = {
|
||||
userId: user.id,
|
||||
organizationIds: [orgId],
|
||||
};
|
||||
|
||||
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
|
||||
method: "POST",
|
||||
data: reqPayload,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Mirroring started for organization ID: ${orgId}`);
|
||||
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) => {
|
||||
const updated = response.organizations.find((o) => o.id === org.id);
|
||||
return updated ? updated : org;
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh organization data to get updated repository breakdown
|
||||
// Use a small delay to allow the backend to process the mirroring request
|
||||
setTimeout(() => {
|
||||
fetchOrganizations(true);
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror job");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting mirror job"
|
||||
);
|
||||
} finally {
|
||||
setLoadingOrgIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(orgId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnoreOrg = async ({ orgId, ignore }: { orgId: string; ignore: boolean }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const org = organizations.find(o => o.id === orgId);
|
||||
|
||||
// Check if organization is currently being processed
|
||||
if (ignore && org && (org.status === "mirroring")) {
|
||||
toast.warning("Cannot ignore organization while it's being processed");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
|
||||
|
||||
const newStatus = ignore ? "ignored" : "imported";
|
||||
|
||||
const response = await apiRequest<{ success: boolean; organization?: Organization; error?: string }>(
|
||||
`/organizations/${orgId}/status`,
|
||||
{
|
||||
method: "PATCH",
|
||||
data: {
|
||||
status: newStatus,
|
||||
userId: user.id
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(ignore
|
||||
? `Organization will be ignored in future operations`
|
||||
: `Organization included for mirroring`
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) =>
|
||||
org.id === orgId ? { ...org, status: newStatus } : org
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || `Failed to ${ignore ? 'ignore' : 'include'} organization`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : `Error ${ignore ? 'ignoring' : 'including'} organization`
|
||||
);
|
||||
} finally {
|
||||
setLoadingOrgIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(orgId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrganization = async ({
|
||||
org,
|
||||
role,
|
||||
force = false,
|
||||
}: {
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedOrg = org.trim();
|
||||
const normalizedOrg = trimmedOrg.toLowerCase();
|
||||
|
||||
if (!trimmedOrg) {
|
||||
toast.error("Please enter a valid organization name.");
|
||||
throw new Error("Invalid organization name");
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
const alreadyExists = organizations.some(
|
||||
(existing) => existing.name?.trim().toLowerCase() === normalizedOrg
|
||||
);
|
||||
|
||||
if (alreadyExists) {
|
||||
toast.warning("Organization already exists.");
|
||||
setDuplicateOrgCandidate({ org: trimmedOrg, role });
|
||||
setIsDuplicateOrgDialogOpen(true);
|
||||
throw new Error("Organization already exists");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const reqPayload: AddOrganizationApiRequest = {
|
||||
userId: user.id,
|
||||
org: trimmedOrg,
|
||||
role,
|
||||
force,
|
||||
};
|
||||
|
||||
const response = await apiRequest<AddOrganizationApiResponse>(
|
||||
"/sync/organization",
|
||||
{
|
||||
method: "POST",
|
||||
data: reqPayload,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
const message = force
|
||||
? "Organization already exists; using existing entry."
|
||||
: "Organization added successfully";
|
||||
toast.success(message);
|
||||
|
||||
await fetchOrganizations(false);
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
searchTerm: trimmedOrg,
|
||||
}));
|
||||
|
||||
if (force) {
|
||||
setIsDuplicateOrgDialogOpen(false);
|
||||
setDuplicateOrgCandidate(null);
|
||||
}
|
||||
} else {
|
||||
showErrorToast(response.error || "Error adding organization", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDuplicateOrganization = async () => {
|
||||
if (!duplicateOrgCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingDuplicateOrg(true);
|
||||
try {
|
||||
await handleAddOrganization({
|
||||
org: duplicateOrgCandidate.org,
|
||||
role: duplicateOrgCandidate.role,
|
||||
force: true,
|
||||
});
|
||||
setIsDialogOpen(false);
|
||||
setDuplicateOrgCandidate(null);
|
||||
setIsDuplicateOrgDialogOpen(false);
|
||||
} catch (error) {
|
||||
// Error already surfaced via toast
|
||||
} finally {
|
||||
setIsProcessingDuplicateOrg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDuplicateOrganization = () => {
|
||||
setIsDuplicateOrgDialogOpen(false);
|
||||
setDuplicateOrgCandidate(null);
|
||||
};
|
||||
|
||||
const handleRequestDeleteOrganization = (orgId: string) => {
|
||||
const org = organizations.find((item) => item.id === orgId);
|
||||
if (!org) {
|
||||
toast.error("Organization not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setOrgToDelete(org);
|
||||
setIsDeleteOrgDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
if (!user || !user.id || !orgToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletingOrg(true);
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; error?: string }>(
|
||||
`/organizations/${orgToDelete.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Removed ${orgToDelete.name} from Gitea Mirror.`);
|
||||
await fetchOrganizations(false);
|
||||
} else {
|
||||
showErrorToast(response.error || "Failed to delete organization", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsDeletingOrg(false);
|
||||
setIsDeleteOrgDialogOpen(false);
|
||||
setOrgToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMirrorAllOrgs = async () => {
|
||||
try {
|
||||
if (!user || !user.id || organizations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out organizations that are already mirrored or ignored to avoid duplicate operations
|
||||
const eligibleOrgs = organizations.filter(
|
||||
(org) =>
|
||||
org.status !== "mirroring" && org.status !== "mirrored" && org.status !== "ignored" && org.id
|
||||
);
|
||||
|
||||
if (eligibleOrgs.length === 0) {
|
||||
toast.info("No eligible organizations to mirror");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all organization IDs
|
||||
const orgIds = eligibleOrgs.map((org) => org.id as string);
|
||||
|
||||
// Set loading state for all organizations being mirrored
|
||||
setLoadingOrgIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
orgIds.forEach((id) => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
const reqPayload: MirrorOrgRequest = {
|
||||
userId: user.id,
|
||||
organizationIds: orgIds,
|
||||
};
|
||||
|
||||
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
|
||||
method: "POST",
|
||||
data: reqPayload,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Mirroring started for ${orgIds.length} organizations`);
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) => {
|
||||
const updated = response.organizations.find((o) => o.id === org.id);
|
||||
return updated ? updated : org;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
// Reset loading states - we'll let the SSE updates handle status changes
|
||||
setLoadingOrgIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = !!(filter.membershipRole || filter.status);
|
||||
const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length;
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setFilter({
|
||||
searchTerm: filter.searchTerm,
|
||||
membershipRole: "",
|
||||
status: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 sm:gap-y-8">
|
||||
{/* Search and filters */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
|
||||
{/* Mobile: Search bar with filter button */}
|
||||
<div className="flex items-center gap-2 w-full sm:hidden">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search organizations..."
|
||||
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={filter.searchTerm}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter Drawer */}
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative h-10 w-10 shrink-0"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle className="text-lg font-semibold">Filter Organizations</DrawerTitle>
|
||||
<DrawerDescription className="text-sm text-muted-foreground">
|
||||
Narrow down your organization list
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-4 py-6 space-y-6 overflow-y-auto">
|
||||
{/* Active filters summary */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-sm font-medium">
|
||||
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Role
|
||||
{filter.membershipRole && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{filter.membershipRole
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={filter.membershipRole || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
membershipRole: value === "all" ? "" : (value as MembershipRole),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10">
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...membershipRoleEnum.options].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
<span className="flex items-center gap-2">
|
||||
{role !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
role === "admin" ? "bg-purple-500" : "bg-blue-500"
|
||||
}`} />
|
||||
)}
|
||||
{role === "all"
|
||||
? "All roles"
|
||||
: role
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Status
|
||||
{filter.status && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status:
|
||||
value === "all"
|
||||
? ""
|
||||
: (value as
|
||||
| ""
|
||||
| "imported"
|
||||
| "mirroring"
|
||||
| "mirrored"
|
||||
| "failed"
|
||||
| "syncing"
|
||||
| "synced"),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
"all",
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"syncing",
|
||||
"synced",
|
||||
].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className="flex items-center gap-2">
|
||||
{status !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
status === "synced" || status === "mirrored" ? "bg-green-500" :
|
||||
status === "failed" ? "bg-red-500" :
|
||||
status === "syncing" || status === "mirroring" ? "bg-blue-500" :
|
||||
"bg-yellow-500"
|
||||
}`} />
|
||||
)}
|
||||
{status === "all"
|
||||
? "All statuses"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||
<DrawerClose asChild>
|
||||
<Button className="w-full" size="sm">
|
||||
Apply Filters
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline" className="w-full" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh organizations"
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={handleMirrorAllOrgs}
|
||||
disabled={isLoading || loadingOrgIds.size > 0}
|
||||
title="Mirror all organizations"
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original layout */}
|
||||
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search organizations..."
|
||||
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={filter.searchTerm}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Membership Role Filter */}
|
||||
<Select
|
||||
value={filter.membershipRole || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
membershipRole: value === "all" ? "" : (value as MembershipRole),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...membershipRoleEnum.options].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
<span className="flex items-center gap-2">
|
||||
{role !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
role === "admin" ? "bg-purple-500" : "bg-blue-500"
|
||||
}`} />
|
||||
)}
|
||||
{role === "all"
|
||||
? "All roles"
|
||||
: role
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status:
|
||||
value === "all"
|
||||
? ""
|
||||
: (value as
|
||||
| ""
|
||||
| "imported"
|
||||
| "mirroring"
|
||||
| "mirrored"
|
||||
| "failed"
|
||||
| "syncing"
|
||||
| "synced"),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
"all",
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"syncing",
|
||||
"synced",
|
||||
].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className="flex items-center gap-2">
|
||||
{status !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
status === "synced" || status === "mirrored" ? "bg-green-500" :
|
||||
status === "failed" ? "bg-red-500" :
|
||||
status === "syncing" || status === "mirroring" ? "bg-blue-500" :
|
||||
"bg-yellow-500"
|
||||
}`} />
|
||||
)}
|
||||
{status === "all"
|
||||
? "All statuses"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh organizations"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllOrgs}
|
||||
disabled={isLoading || loadingOrgIds.size > 0}
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrganizationList
|
||||
organizations={organizations}
|
||||
isLoading={isLoading || !connected}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
onMirror={handleMirrorOrg}
|
||||
onIgnore={handleIgnoreOrg}
|
||||
onAddOrganization={() => setIsDialogOpen(true)}
|
||||
onDelete={handleRequestDeleteOrganization}
|
||||
onRefresh={async () => {
|
||||
await fetchOrganizations(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
onAddOrganization={handleAddOrganization}
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
/>
|
||||
|
||||
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancelDuplicateOrganization();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Organization already exists</DialogTitle>
|
||||
<DialogDescription>
|
||||
{duplicateOrgCandidate?.org ?? "This organization"} is already synced in Gitea Mirror.
|
||||
Continuing will reuse the existing entry without creating a duplicate. You can remove it later if needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelDuplicateOrganization} disabled={isProcessingDuplicateOrg}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmDuplicateOrganization} disabled={isProcessingDuplicateOrg}>
|
||||
{isProcessingDuplicateOrg ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDeleteOrgDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDeleteOrgDialogOpen(false);
|
||||
setOrgToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove organization from Gitea Mirror?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{orgToDelete?.name ?? "This organization"} will be deleted from Gitea Mirror only. Nothing will be removed from Gitea; you will need to clean it up manually in Gitea if desired.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setIsDeleteOrgDialogOpen(false);
|
||||
setOrgToDelete(null);
|
||||
}} disabled={isDeletingOrg}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteOrganization} disabled={isDeletingOrg}>
|
||||
{isDeletingOrg ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import Fuse from "fuse.js";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface OrganizationListProps {
|
||||
organizations: Organization[];
|
||||
isLoading: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
|
||||
loadingOrgIds: Set<string>;
|
||||
onAddOrganization?: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
|
||||
case "mirrored":
|
||||
return { variant: "default" as const, label: "Mirrored", icon: Check };
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
|
||||
case "ignored":
|
||||
return { variant: "outline" as const, label: "Ignored", icon: Ban };
|
||||
default:
|
||||
return { variant: "secondary" as const, label: "Unknown", icon: null };
|
||||
}
|
||||
};
|
||||
|
||||
export function OrganizationList({
|
||||
organizations,
|
||||
isLoading,
|
||||
filter,
|
||||
setFilter,
|
||||
onMirror,
|
||||
onIgnore,
|
||||
loadingOrgIds,
|
||||
onAddOrganization,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
// Helper function to construct Gitea organization URL
|
||||
const getGiteaOrgUrl = (organization: Organization): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for organizations that have been mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored'];
|
||||
if (!validStatuses.includes(organization.status || '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use destinationOrg if available, otherwise use the organization name
|
||||
const orgName = organization.destinationOrg || organization.name;
|
||||
if (!orgName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${orgName}`;
|
||||
};
|
||||
|
||||
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||
// Call API to update organization destination
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destinationOrg: newDestination,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to update organization");
|
||||
}
|
||||
|
||||
// Refresh organizations data
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const hasAnyFilter = Object.values(filter).some(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
);
|
||||
|
||||
const filteredOrganizations = useMemo(() => {
|
||||
let result = organizations;
|
||||
|
||||
if (filter.membershipRole) {
|
||||
result = result.filter((org) => org.membershipRole === filter.membershipRole);
|
||||
}
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((org) => org.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ["name", "type"],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [organizations, filter]);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(27rem,1fr))] gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[11.25rem] w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredOrganizations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No organizations found</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||
{hasAnyFilter
|
||||
? "Try adjusting your search or filter criteria."
|
||||
: "Add GitHub organizations to mirror their repositories."}
|
||||
</p>
|
||||
{hasAnyFilter ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFilter({
|
||||
searchTerm: "",
|
||||
membershipRole: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onAddOrganization}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Organization
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(27rem,1fr))] gap-4 pb-20 sm:pb-0">
|
||||
{filteredOrganizations.map((org, index) => {
|
||||
const isLoading = loadingOrgIds.has(org.id ?? "");
|
||||
const statusBadge = getStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"overflow-hidden p-4 sm:p-6 transition-all hover:shadow-lg hover:border-foreground/10 w-full",
|
||||
isLoading && "opacity-75"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Layout */}
|
||||
<div className="flex flex-col gap-3 sm:hidden">
|
||||
{/* Header with org name and badges */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer truncate"
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
</div>
|
||||
<Badge variant={statusBadge.variant} className="flex-shrink-0">
|
||||
{StatusIcon && <StatusIcon className={cn(
|
||||
"h-3 w-3",
|
||||
org.status === "mirroring" && "animate-pulse"
|
||||
)} />}
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold">{org.repositoryCount}</span>
|
||||
<span className="ml-1">repos</span>
|
||||
{/* Repository breakdown for mobile - only show non-zero counts */}
|
||||
{(() => {
|
||||
const parts = [];
|
||||
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
|
||||
parts.push(`${org.publicRepositoryCount} pub`);
|
||||
}
|
||||
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
|
||||
parts.push(`${org.privateRepositoryCount} priv`);
|
||||
}
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
parts.push(`${org.forkRepositoryCount} fork`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? (
|
||||
<span className="ml-1">({parts.join(' | ')})</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination override section */}
|
||||
<div>
|
||||
<MirrorDestinationEditor
|
||||
organizationId={org.id!}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg ?? undefined}
|
||||
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||
isUpdating={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden sm:block">
|
||||
{/* Header with org icon, name, role badge and status */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="text-xl font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<Badge
|
||||
variant={org.membershipRole === "member" ? "secondary" : "default"}
|
||||
className="capitalize"
|
||||
>
|
||||
{org.membershipRole}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
|
||||
{StatusIcon && <StatusIcon className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
org.status === "mirroring" && "animate-pulse"
|
||||
)} />}
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Destination override section */}
|
||||
<div className="mb-4">
|
||||
<MirrorDestinationEditor
|
||||
organizationId={org.id!}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg ?? undefined}
|
||||
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||
isUpdating={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repository statistics */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-lg">{org.repositoryCount}</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repository breakdown - only show non-zero counts */}
|
||||
{(() => {
|
||||
const counts = [];
|
||||
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
|
||||
counts.push(`${org.publicRepositoryCount} public`);
|
||||
}
|
||||
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
|
||||
counts.push(`${org.privateRepositoryCount} private`);
|
||||
}
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
counts.push(`${org.forkRepositoryCount} ${org.forkRepositoryCount === 1 ? 'fork' : 'forks'}`);
|
||||
}
|
||||
|
||||
return counts.length > 0 ? (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{counts.map((count, index) => (
|
||||
<span key={index} className={index > 0 ? "border-l pl-3" : ""}>
|
||||
{count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex flex-col gap-3 sm:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.status === "ignored" ? (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: false })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include Organization
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{org.status === "imported" && (
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Mirror Organization
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="default" disabled variant="outline" className="w-full h-10">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Mirroring...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="default" disabled variant="secondary" className="w-full h-10">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mirrored
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaOrgUrl(org);
|
||||
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (org.status === 'imported') {
|
||||
tooltip = "Organization not yet mirrored to Gitea";
|
||||
} else if (org.status === 'failed') {
|
||||
tooltip = "Organization mirroring failed";
|
||||
} else if (org.status === 'mirroring') {
|
||||
tooltip = "Organization is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea organization not available";
|
||||
}
|
||||
|
||||
return giteaUrl ? (
|
||||
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={tooltip}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<SiGitea className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs">Gitea</span>
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="default" disabled title={tooltip} className="flex-1 h-10">
|
||||
<SiGitea className="h-4 w-4" />
|
||||
<span className="text-xs ml-2">Gitea</span>
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<SiGithub className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs">GitHub</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden sm:flex items-center justify-between mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.status === "ignored" ? (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: false })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include Organization
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{org.status === "imported" && (
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Starting mirror...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Mirror Organization
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="default" disabled variant="outline">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Mirroring in progress...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="default" disabled variant="secondary">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Successfully mirrored
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaOrgUrl(org);
|
||||
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (org.status === 'imported') {
|
||||
tooltip = "Organization not yet mirrored to Gitea";
|
||||
} else if (org.status === 'failed') {
|
||||
tooltip = "Organization mirroring failed";
|
||||
} else if (org.status === 'mirroring') {
|
||||
tooltip = "Organization is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea organization not available";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild={!!giteaUrl}
|
||||
disabled={!giteaUrl}
|
||||
title={tooltip}
|
||||
className="rounded-none rounded-l-md border-r"
|
||||
>
|
||||
{giteaUrl ? (
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<SiGitea className="h-4 w-4 mr-2" />
|
||||
Gitea
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<SiGitea className="h-4 w-4 mr-2" />
|
||||
Gitea
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="rounded-none rounded-r-md"
|
||||
>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4 mr-2" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { LoaderCircle, Plus } from "lucide-react";
|
||||
|
||||
interface AddRepositoryDialogProps {
|
||||
isDialogOpen: boolean;
|
||||
setIsDialogOpen: (isOpen: boolean) => void;
|
||||
onAddRepository: ({
|
||||
repo,
|
||||
owner,
|
||||
force,
|
||||
}: {
|
||||
repo: string;
|
||||
owner: string;
|
||||
force?: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AddRepositoryDialog({
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
onAddRepository,
|
||||
}: AddRepositoryDialogProps) {
|
||||
const [repo, setRepo] = useState<string>("");
|
||||
const [owner, setOwner] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setError("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!repo || !owner || repo.trim() === "" || owner.trim() === "") {
|
||||
setError("Please enter a valid repository name and owner.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await onAddRepository({ repo, owner });
|
||||
|
||||
setError("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add repository.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
|
||||
<Plus className="h-6 w-6" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
You can add public repositories of others
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Repository Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="e.g., next.js"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Repository Owner
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="e.g., vercel"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Add Repository"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Edit3, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
|
||||
interface InlineDestinationEditorProps {
|
||||
repository: Repository;
|
||||
giteaConfig: any;
|
||||
onUpdate: (repoId: string, newDestination: string | null) => Promise<void>;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineDestinationEditor({
|
||||
repository,
|
||||
giteaConfig,
|
||||
onUpdate,
|
||||
isUpdating = false,
|
||||
className,
|
||||
}: InlineDestinationEditorProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Determine the default destination based on repository properties and config
|
||||
const getDefaultDestination = () => {
|
||||
// Starred repos always go to the configured starredReposOrg
|
||||
if (repository.isStarred && giteaConfig?.starredReposOrg) {
|
||||
return giteaConfig.starredReposOrg;
|
||||
}
|
||||
|
||||
// Check mirror strategy
|
||||
const strategy = giteaConfig?.mirrorStrategy || 'preserve';
|
||||
|
||||
if (strategy === 'single-org' && giteaConfig?.organization) {
|
||||
// All repos go to a single organization
|
||||
return giteaConfig.organization;
|
||||
} else if (strategy === 'flat-user') {
|
||||
// All repos go under the user account
|
||||
return giteaConfig?.username || repository.owner;
|
||||
} else {
|
||||
// 'preserve' strategy or default
|
||||
// For organization repos, use the organization name
|
||||
if (repository.organization) {
|
||||
return repository.organization;
|
||||
}
|
||||
// For personal repos, check if personalReposOrg is configured (but not in preserve mode)
|
||||
if (!repository.organization && giteaConfig?.personalReposOrg && strategy !== 'preserve') {
|
||||
return giteaConfig.personalReposOrg;
|
||||
}
|
||||
// Default to the gitea username or owner
|
||||
return giteaConfig?.username || repository.owner;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultDestination = getDefaultDestination();
|
||||
const currentDestination = repository.destinationOrg || defaultDestination;
|
||||
const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination;
|
||||
const isStarredRepo = repository.isStarred && giteaConfig?.starredReposOrg;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (isStarredRepo) return; // Don't allow editing starred repos
|
||||
setEditValue(currentDestination);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === defaultDestination ? null : trimmedValue;
|
||||
|
||||
if (trimmedValue === currentDestination) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onUpdate(repository.id!, newDestination);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setEditValue(currentDestination);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(currentDestination);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleCancel}
|
||||
className="h-6 text-sm px-2 py-0 w-24"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-0.5", className)}>
|
||||
{/* Show GitHub org if exists */}
|
||||
{repository.organization && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repository.organization}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Show Gitea destination */}
|
||||
<div className="flex items-center gap-1 group">
|
||||
<span className="text-sm">
|
||||
{currentDestination || "-"}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[10px] ml-1">
|
||||
custom
|
||||
</Badge>
|
||||
)}
|
||||
{isStarredRepo && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[10px] ml-1">
|
||||
starred
|
||||
</Badge>
|
||||
)}
|
||||
{!isStarredRepo && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-60 hover:opacity-100 ml-1"
|
||||
onClick={handleStartEdit}
|
||||
disabled={isUpdating || isLoading}
|
||||
title="Edit destination"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1399
Divers/gitea-mirror/src/components/repositories/Repository.tsx
Normal file
1399
Divers/gitea-mirror/src/components/repositories/Repository.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
import * as React from "react";
|
||||
import { ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ComboboxProps = {
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function OwnerCombobox({ options, value, onChange, placeholder = "Owner" }: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[160px] justify-between h-10"
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
{value || "All owners"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search owners..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No owners found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
value=""
|
||||
onSelect={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
All owners
|
||||
</CommandItem>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganizationCombobox({ options, value, onChange, placeholder = "Organization" }: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[160px] justify-between h-10"
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
{value || "All organizations"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search organizations..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No organizations found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
value=""
|
||||
onSelect={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
All organizations
|
||||
</CommandItem>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onSkip: ({ repoId, skip }: { repoId: string; skip: boolean }) => Promise<void>;
|
||||
loadingRepoIds: Set<string>;
|
||||
selectedRepoIds: Set<string>;
|
||||
onSelectionChange: (selectedIds: Set<string>) => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onDelete?: (repoId: string) => void;
|
||||
}
|
||||
|
||||
export default function RepositoryTable({
|
||||
repositories,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
onMirror,
|
||||
onSync,
|
||||
onRetry,
|
||||
onSkip,
|
||||
loadingRepoIds,
|
||||
selectedRepoIds,
|
||||
onSelectionChange,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
}: RepositoryTableProps) {
|
||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
||||
// Call API to update repository destination
|
||||
const response = await fetch(`/api/repositories/${repoId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destinationOrg: newDestination,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to update repository");
|
||||
}
|
||||
|
||||
// Refresh repositories data
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
// 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', 'archived'];
|
||||
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(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
);
|
||||
|
||||
const filteredRepositories = useMemo(() => {
|
||||
let result = repositories;
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((repo) => repo.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.owner) {
|
||||
result = result.filter((repo) => repo.owner === filter.owner);
|
||||
}
|
||||
|
||||
if (filter.organization) {
|
||||
result = result.filter(
|
||||
(repo) => repo.organization === filter.organization
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ["name", "fullName", "owner", "organization"],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [repositories, filter]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredRepositories.length,
|
||||
getScrollElement: () => tableParentRef.current,
|
||||
estimateSize: () => 65,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
|
||||
onSelectionChange(allIds);
|
||||
} else {
|
||||
onSelectionChange(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRepo = (repoId: string, checked: boolean) => {
|
||||
const newSelection = new Set(selectedRepoIds);
|
||||
if (checked) {
|
||||
newSelection.add(repoId);
|
||||
} else {
|
||||
newSelection.delete(repoId);
|
||||
}
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
const isAllSelected = filteredRepositories.length > 0 &&
|
||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||
|
||||
// Mobile card layout for repository
|
||||
const RepositoryCard = ({ repo }: { repo: Repository }) => {
|
||||
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
|
||||
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
|
||||
const giteaUrl = getGiteaRepoUrl(repo);
|
||||
|
||||
return (
|
||||
<Card className="mb-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header with checkbox and repo name */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
|
||||
className="mt-1 h-5 w-5"
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-base truncate">{repo.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
|
||||
{repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
|
||||
{repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository details */}
|
||||
<div className="space-y-2">
|
||||
{/* Owner & Organization */}
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Owner:</span>
|
||||
<span className="truncate">{repo.owner}</span>
|
||||
</div>
|
||||
{repo.organization && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Org:</span>
|
||||
<span className="truncate">{repo.organization}</span>
|
||||
</div>
|
||||
)}
|
||||
{repo.destinationOrg && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Dest:</span>
|
||||
<span className="truncate">{repo.destinationOrg}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Last Mirrored */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
|
||||
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400' :
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
|
||||
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
|
||||
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
|
||||
'bg-muted hover:bg-muted/80'}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Primary action button */}
|
||||
{(repo.status === "imported" || repo.status === "failed") && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="default"
|
||||
onClick={() => repo.id && onMirror({ repoId: repo.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2 animate-spin" />
|
||||
Mirroring...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Repository
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(repo.status === "mirrored" || repo.status === "synced") && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => repo.id && onSync({ repoId: repo.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Syncing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Repository
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{repo.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => repo.id && onRetry({ repoId: repo.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RotateCcw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Ignore/Include button */}
|
||||
{repo.status === "ignored" ? (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => repo.id && onSkip({ repoId: repo.id, skip: false })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include Repository
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="default"
|
||||
variant="ghost"
|
||||
onClick={() => repo.id && onSkip({ repoId: repo.id, skip: true })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Repository
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<SiGithub className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs">GitHub</span>
|
||||
</a>
|
||||
</Button>
|
||||
{giteaUrl ? (
|
||||
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on Gitea"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<SiGitea className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs">Gitea</span>
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="default" disabled className="flex-1 h-10 min-w-0">
|
||||
<SiGitea className="h-4 w-4" />
|
||||
<span className="text-xs ml-2">Gitea</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return isLoading ? (
|
||||
<div className="space-y-3 lg:space-y-0">
|
||||
{/* Mobile skeleton */}
|
||||
<div className="lg:hidden">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-4 w-4 mt-1" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block border rounded-md">
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Organization
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Last Mirrored
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Actions
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
Links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
||||
>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[2.3]">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-3 w-24 mt-1" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{hasAnyFilter && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {filteredRepositories.length} of {repositories.length} repositories
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setFilter({
|
||||
searchTerm: "",
|
||||
status: "",
|
||||
organization: "",
|
||||
owner: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredRepositories.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{hasAnyFilter
|
||||
? "No repositories match the current filters"
|
||||
: "No repositories found"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile card view */}
|
||||
<div className="lg:hidden pb-20">
|
||||
{/* Select all checkbox */}
|
||||
<div className="flex items-center gap-3 mb-3 p-3 bg-muted/50 rounded-md">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="Select all repositories"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
Select All ({filteredRepositories.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repository cards */}
|
||||
{filteredRepositories.map((repo) => (
|
||||
<RepositoryCard key={repo.id} repo={repo} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden lg:flex flex-col border rounded-md">
|
||||
{/* Table header */}
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="Select all repositories"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Organization
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Last Mirrored
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Actions
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
Links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table body wrapper (for a parent in virtualization) */}
|
||||
<div
|
||||
ref={tableParentRef}
|
||||
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||
const repo = filteredRepositories[virtualRow.index];
|
||||
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
data-index={virtualRow.index}
|
||||
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Checkbox
|
||||
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.isStarred && (
|
||||
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{repo.fullName}
|
||||
</div>
|
||||
</div>
|
||||
{repo.isPrivate && (
|
||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Owner */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<p className="text-sm">{repo.owner}</p>
|
||||
</div>
|
||||
|
||||
{/* Organization */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<InlineDestinationEditor
|
||||
repository={repo}
|
||||
giteaConfig={giteaConfig}
|
||||
onUpdate={handleUpdateDestination}
|
||||
isUpdating={loadingRepoIds.has(repo.id ?? "")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Mirrored */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<p className="text-sm">
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
{repo.status === "failed" && repo.errorMessage ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="cursor-help capitalize"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-sm">{repo.errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
|
||||
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400' :
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
|
||||
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
|
||||
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
|
||||
'bg-muted hover:bg-muted/80'}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="h-full p-3 flex items-center justify-start flex-[1]">
|
||||
<RepoActionButton
|
||||
repo={{ id: repo.id ?? "", status: repo.status }}
|
||||
isLoading={isLoading}
|
||||
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
|
||||
onSync={() => onSync({ repoId: repo.id ?? "" })}
|
||||
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
||||
onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })}
|
||||
onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/* Links */}
|
||||
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
|
||||
{(() => {
|
||||
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>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{hasAnyFilter
|
||||
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
|
||||
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyFilter && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoActionButton({
|
||||
repo,
|
||||
isLoading,
|
||||
onMirror,
|
||||
onSync,
|
||||
onRetry,
|
||||
onSkip,
|
||||
onDelete,
|
||||
}: {
|
||||
repo: { id: string; status: string };
|
||||
isLoading: boolean;
|
||||
onMirror: () => void;
|
||||
onSync: () => void;
|
||||
onRetry: () => void;
|
||||
onSkip: (skip: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
// For ignored repos, show an "Include" action
|
||||
if (repo.status === "ignored") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => onSkip(false)}
|
||||
className="min-w-[80px] justify-start"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Include
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// For actionable statuses, show action + dropdown for skip
|
||||
let primaryLabel = "";
|
||||
let primaryIcon = <></>;
|
||||
let primaryOnClick = () => {};
|
||||
let primaryDisabled = isLoading;
|
||||
let showPrimaryAction = true;
|
||||
|
||||
if (repo.status === "failed") {
|
||||
primaryLabel = "Retry";
|
||||
primaryIcon = <RotateCcw className="h-4 w-4" />;
|
||||
primaryOnClick = onRetry;
|
||||
} else if (["mirrored", "synced", "syncing", "archived"].includes(repo.status)) {
|
||||
primaryLabel = repo.status === "archived" ? "Manual Sync" : "Sync";
|
||||
primaryIcon = <RefreshCw className="h-4 w-4" />;
|
||||
primaryOnClick = onSync;
|
||||
primaryDisabled ||= repo.status === "syncing";
|
||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||
primaryLabel = "Mirror";
|
||||
primaryIcon = <FlipHorizontal className="h-4 w-4" />;
|
||||
primaryOnClick = onMirror;
|
||||
primaryDisabled ||= repo.status === "mirroring";
|
||||
} else {
|
||||
showPrimaryAction = false;
|
||||
}
|
||||
|
||||
// If there's no primary action, just show ignore button
|
||||
if (!showPrimaryAction) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isLoading}
|
||||
onClick={() => onSkip(true)}
|
||||
className="min-w-[80px] justify-start"
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Ignore
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Show primary action with dropdown for additional actions
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={primaryDisabled}
|
||||
onClick={primaryOnClick}
|
||||
className="min-w-[80px] justify-start rounded-r-none"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
{primaryLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{primaryIcon}
|
||||
<span className="ml-1">{primaryLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isLoading}
|
||||
className="rounded-l-none px-2 border-l"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSkip(true)}>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Repository
|
||||
</DropdownMenuItem>
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
105
Divers/gitea-mirror/src/components/sponsors/GitHubSponsors.tsx
Normal file
105
Divers/gitea-mirror/src/components/sponsors/GitHubSponsors.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Heart, Coffee, Zap } from "lucide-react";
|
||||
import { isSelfHostedMode } from "@/lib/deployment-mode";
|
||||
|
||||
export function GitHubSponsors() {
|
||||
// Only show in self-hosted mode
|
||||
if (!isSelfHostedMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-purple-900 dark:text-purple-100">
|
||||
<Heart className="w-5 h-5 text-pink-500" />
|
||||
Support Gitea Mirror
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200">
|
||||
Gitea Mirror is open source and free to use. If you find it helpful,
|
||||
consider supporting the project!
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/sponsors/RayLabsHQ"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Heart className="w-4 h-4 mr-2" />
|
||||
Become a Sponsor
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-purple-300 hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/RayLabsHQ/gitea-mirror"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
⭐ Star on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-purple-300 hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://buymeacoffee.com/raylabs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Coffee className="w-4 h-4 mr-1" />
|
||||
Buy Coffee
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-purple-600 dark:text-purple-300 space-y-1">
|
||||
<p className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Your support helps maintain and improve the project
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Smaller inline sponsor button for headers/navbars
|
||||
export function SponsorButton() {
|
||||
if (!isSelfHostedMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href="https://github.com/sponsors/RayLabsHQ"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Heart className="w-4 h-4 mr-2" />
|
||||
Sponsor
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
52
Divers/gitea-mirror/src/components/theme/ModeToggle.tsx
Normal file
52
Divers/gitea-mirror/src/components/theme/ModeToggle.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ModeToggle() {
|
||||
const [theme, setThemeState] = React.useState<"light" | "dark" | "system">(
|
||||
"light"
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
setThemeState(isDarkMode ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg" className="has-[>svg]:px-3">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setThemeState("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setThemeState("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setThemeState("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
21
Divers/gitea-mirror/src/components/theme/ThemeScript.astro
Normal file
21
Divers/gitea-mirror/src/components/theme/ThemeScript.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
const getThemePreference = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
const isDark = getThemePreference() === 'dark';
|
||||
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
</script>
|
||||
64
Divers/gitea-mirror/src/components/ui/accordion.tsx
Normal file
64
Divers/gitea-mirror/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
70
Divers/gitea-mirror/src/components/ui/alert.tsx
Normal file
70
Divers/gitea-mirror/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
warning:
|
||||
"bg-amber-50 border-amber-200 text-amber-800 dark:bg-amber-950/30 dark:border-amber-800 dark:text-amber-300 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-500",
|
||||
note:
|
||||
"bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/30 dark:border-blue-800 dark:text-blue-200 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
48
Divers/gitea-mirror/src/components/ui/avatar.tsx
Normal file
48
Divers/gitea-mirror/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
46
Divers/gitea-mirror/src/components/ui/badge.tsx
Normal file
46
Divers/gitea-mirror/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
59
Divers/gitea-mirror/src/components/ui/button.tsx
Normal file
59
Divers/gitea-mirror/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
79
Divers/gitea-mirror/src/components/ui/card.tsx
Normal file
79
Divers/gitea-mirror/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
Divers/gitea-mirror/src/components/ui/checkbox.tsx
Normal file
28
Divers/gitea-mirror/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
31
Divers/gitea-mirror/src/components/ui/collapsible.tsx
Normal file
31
Divers/gitea-mirror/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
175
Divers/gitea-mirror/src/components/ui/command.tsx
Normal file
175
Divers/gitea-mirror/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
135
Divers/gitea-mirror/src/components/ui/dialog.tsx
Normal file
135
Divers/gitea-mirror/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
133
Divers/gitea-mirror/src/components/ui/drawer.tsx
Normal file
133
Divers/gitea-mirror/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
255
Divers/gitea-mirror/src/components/ui/dropdown-menu.tsx
Normal file
255
Divers/gitea-mirror/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
42
Divers/gitea-mirror/src/components/ui/hover-card.tsx
Normal file
42
Divers/gitea-mirror/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
22
Divers/gitea-mirror/src/components/ui/input.tsx
Normal file
22
Divers/gitea-mirror/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
26
Divers/gitea-mirror/src/components/ui/label.tsx
Normal file
26
Divers/gitea-mirror/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
137
Divers/gitea-mirror/src/components/ui/multi-select.tsx
Normal file
137
Divers/gitea-mirror/src/components/ui/multi-select.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: { label: string; value: string }[]
|
||||
selected: string[]
|
||||
onChange: (selected: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = "Select items...",
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const handleUnselect = (item: string) => {
|
||||
onChange(selected.filter((i) => i !== item))
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={`w-full justify-between ${selected.length > 0 ? "h-full" : ""} ${className}`}
|
||||
>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{selected.length > 0 ? (
|
||||
selected.map((item) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={item}
|
||||
className="mr-1 mb-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleUnselect(item)
|
||||
}}
|
||||
>
|
||||
{options.find((option) => option.value === item)?.label || item}
|
||||
<button
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(item)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleUnselect(item)
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command className={className}>
|
||||
<CommandInput placeholder="Search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No item found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
onChange(
|
||||
selected.includes(option.value)
|
||||
? selected.filter((item) => item !== option.value)
|
||||
: [...selected, option.value]
|
||||
)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary ${
|
||||
selected.includes(option.value)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
46
Divers/gitea-mirror/src/components/ui/popover.tsx
Normal file
46
Divers/gitea-mirror/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
30
Divers/gitea-mirror/src/components/ui/progress.tsx
Normal file
30
Divers/gitea-mirror/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
43
Divers/gitea-mirror/src/components/ui/radio-group.tsx
Normal file
43
Divers/gitea-mirror/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
44
Divers/gitea-mirror/src/components/ui/radio.tsx
Normal file
44
Divers/gitea-mirror/src/components/ui/radio.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
56
Divers/gitea-mirror/src/components/ui/scroll-area.tsx
Normal file
56
Divers/gitea-mirror/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
183
Divers/gitea-mirror/src/components/ui/select.tsx
Normal file
183
Divers/gitea-mirror/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input cursor-pointer data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
26
Divers/gitea-mirror/src/components/ui/separator.tsx
Normal file
26
Divers/gitea-mirror/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
15
Divers/gitea-mirror/src/components/ui/skeleton.tsx
Normal file
15
Divers/gitea-mirror/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
24
Divers/gitea-mirror/src/components/ui/sonner.tsx
Normal file
24
Divers/gitea-mirror/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
import type { ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
29
Divers/gitea-mirror/src/components/ui/switch.tsx
Normal file
29
Divers/gitea-mirror/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
53
Divers/gitea-mirror/src/components/ui/tabs.tsx
Normal file
53
Divers/gitea-mirror/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
Divers/gitea-mirror/src/components/ui/textarea.tsx
Normal file
18
Divers/gitea-mirror/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
59
Divers/gitea-mirror/src/components/ui/tooltip.tsx
Normal file
59
Divers/gitea-mirror/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
4
Divers/gitea-mirror/src/content/config.ts
Normal file
4
Divers/gitea-mirror/src/content/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Export empty collections since docs have been moved
|
||||
export const collections = {};
|
||||
16
Divers/gitea-mirror/src/data/Sidebar.ts
Normal file
16
Divers/gitea-mirror/src/data/Sidebar.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
GitFork,
|
||||
Settings,
|
||||
Activity,
|
||||
Building2,
|
||||
} from "lucide-react";
|
||||
import type { SidebarItem } from "@/types/Sidebar";
|
||||
|
||||
export const links: SidebarItem[] = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/repositories", label: "Repositories", icon: GitFork },
|
||||
{ href: "/organizations", label: "Organizations", icon: Building2 },
|
||||
{ href: "/config", label: "Configuration", icon: Settings },
|
||||
{ href: "/activity", label: "Activity Log", icon: Activity },
|
||||
];
|
||||
147
Divers/gitea-mirror/src/hooks/useAuth-legacy.ts
Normal file
147
Divers/gitea-mirror/src/hooks/useAuth-legacy.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
createContext,
|
||||
useContext,
|
||||
type Context,
|
||||
} from "react";
|
||||
import { authApi } from "@/lib/api";
|
||||
import type { ExtendedUser } from "@/types/user";
|
||||
|
||||
interface AuthContextType {
|
||||
user: ExtendedUser | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>; // Added refreshUser function
|
||||
}
|
||||
|
||||
const AuthContext: Context<AuthContextType | undefined> = createContext<
|
||||
AuthContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<ExtendedUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to refetch the user data
|
||||
const refreshUser = async () => {
|
||||
// not using loading state to keep the ui seamless and refresh the data in bg
|
||||
// setIsLoading(true);
|
||||
try {
|
||||
const user = await authApi.getCurrentUser();
|
||||
setUser(user);
|
||||
} catch (err: any) {
|
||||
setUser(null);
|
||||
console.error("Failed to refresh user data", err);
|
||||
} finally {
|
||||
// setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Automatically check the user status when the app loads
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const user = await authApi.getCurrentUser();
|
||||
|
||||
console.log("User data fetched:", user);
|
||||
|
||||
setUser(user);
|
||||
} catch (err: any) {
|
||||
setUser(null);
|
||||
|
||||
// Redirect user based on error
|
||||
if (err?.message === "No users found") {
|
||||
window.location.href = "/signup";
|
||||
} else {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
console.error("Auth check failed", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await authApi.login(username, password);
|
||||
setUser(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await authApi.register(username, email, password);
|
||||
setUser(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Registration failed");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authApi.logout();
|
||||
setUser(null);
|
||||
window.location.href = "/login";
|
||||
} catch (err) {
|
||||
console.error("Logout error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create the context value with the added refreshUser function
|
||||
const contextValue = {
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
// Return the provider with the context value
|
||||
return React.createElement(
|
||||
AuthContext.Provider,
|
||||
{ value: contextValue },
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
143
Divers/gitea-mirror/src/hooks/useAuth.ts
Normal file
143
Divers/gitea-mirror/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
createContext,
|
||||
useContext,
|
||||
type Context,
|
||||
} from "react";
|
||||
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
|
||||
import type { Session, AuthUser } from "@/lib/auth-client";
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
session: Session | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (email: string, password: string, username?: string) => Promise<void>;
|
||||
register: (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext: Context<AuthContextType | undefined> = createContext<
|
||||
AuthContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const betterAuthSession = useBetterAuthSession();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Derive user and session from Better Auth hook
|
||||
const user = betterAuthSession.data?.user || null;
|
||||
const session = betterAuthSession.data || null;
|
||||
|
||||
// Don't do any redirects here - let the pages handle their own redirect logic
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || "Login failed");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Login failed";
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: username, // Better Auth uses 'name' field for display name
|
||||
callbackURL: "/",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || "Registration failed");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Registration failed";
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = "/login";
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Logout error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
// Better Auth automatically handles session refresh
|
||||
// We can force a refetch if needed
|
||||
await betterAuthSession.refetch();
|
||||
};
|
||||
|
||||
// Create the context value
|
||||
const contextValue = {
|
||||
user: user as AuthUser | null,
|
||||
session: session as Session | null,
|
||||
isLoading: isLoading || betterAuthSession.isPending,
|
||||
error,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
// Return the provider with the context value
|
||||
return React.createElement(
|
||||
AuthContext.Provider,
|
||||
{ value: contextValue },
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Export the Better Auth session hook for direct use when needed
|
||||
export { useBetterAuthSession };
|
||||
65
Divers/gitea-mirror/src/hooks/useAuthMethods.ts
Normal file
65
Divers/gitea-mirror/src/hooks/useAuthMethods.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiRequest } from '@/lib/utils';
|
||||
|
||||
interface AuthMethods {
|
||||
emailPassword: boolean;
|
||||
sso: {
|
||||
enabled: boolean;
|
||||
providers: Array<{
|
||||
id: string;
|
||||
providerId: string;
|
||||
domain: string;
|
||||
}>;
|
||||
};
|
||||
oidc: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthMethods() {
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods>({
|
||||
emailPassword: true,
|
||||
sso: {
|
||||
enabled: false,
|
||||
providers: [],
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthMethods();
|
||||
}, []);
|
||||
|
||||
const loadAuthMethods = async () => {
|
||||
try {
|
||||
// Check SSO providers - use public endpoint since this is used on login page
|
||||
const providers = await apiRequest<any[]>('/sso/providers/public').catch(() => []);
|
||||
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
|
||||
|
||||
setAuthMethods({
|
||||
emailPassword: true, // Always enabled
|
||||
sso: {
|
||||
enabled: providers.length > 0,
|
||||
providers: providers.map(p => ({
|
||||
id: p.id,
|
||||
providerId: p.providerId,
|
||||
domain: p.domain,
|
||||
})),
|
||||
},
|
||||
oidc: {
|
||||
enabled: applications.length > 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// If we can't load auth methods, default to email/password only
|
||||
console.error('Failed to load auth methods:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { authMethods, isLoading };
|
||||
}
|
||||
154
Divers/gitea-mirror/src/hooks/useConfigStatus.ts
Normal file
154
Divers/gitea-mirror/src/hooks/useConfigStatus.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
import { apiRequest } from '@/lib/utils';
|
||||
import type { ConfigApiResponse } from '@/types/config';
|
||||
|
||||
interface ConfigStatus {
|
||||
isGitHubConfigured: boolean;
|
||||
isGiteaConfigured: boolean;
|
||||
isFullyConfigured: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Cache to prevent duplicate API calls across components
|
||||
let configCache: { data: ConfigApiResponse | null; timestamp: number; userId: string | null } = {
|
||||
data: null,
|
||||
timestamp: 0,
|
||||
userId: null
|
||||
};
|
||||
|
||||
const CACHE_DURATION = 30000; // 30 seconds cache
|
||||
|
||||
/**
|
||||
* Hook to check if GitHub and Gitea are properly configured
|
||||
* Returns configuration status and prevents unnecessary API calls when not configured
|
||||
* Uses caching to prevent duplicate API calls across components
|
||||
*/
|
||||
export function useConfigStatus(): ConfigStatus {
|
||||
const { user } = useAuth();
|
||||
const [configStatus, setConfigStatus] = useState<ConfigStatus>({
|
||||
isGitHubConfigured: false,
|
||||
isGiteaConfigured: false,
|
||||
isFullyConfigured: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Track if this hook has already checked config to prevent multiple calls
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
const checkConfiguration = useCallback(async () => {
|
||||
if (!user?.id) {
|
||||
setConfigStatus({
|
||||
isGitHubConfigured: false,
|
||||
isGiteaConfigured: false,
|
||||
isFullyConfigured: false,
|
||||
isLoading: false,
|
||||
error: 'No user found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const now = Date.now();
|
||||
const isCacheValid = configCache.data &&
|
||||
configCache.userId === user.id &&
|
||||
(now - configCache.timestamp) < CACHE_DURATION;
|
||||
|
||||
if (isCacheValid && hasCheckedRef.current) {
|
||||
const configResponse = configCache.data!;
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
const isFullyConfigured = isGitHubConfigured && isGiteaConfigured;
|
||||
|
||||
setConfigStatus({
|
||||
isGitHubConfigured,
|
||||
isGiteaConfigured,
|
||||
isFullyConfigured,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Only show loading if we haven't checked before or cache is invalid
|
||||
if (!hasCheckedRef.current) {
|
||||
setConfigStatus(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
}
|
||||
|
||||
const configResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
// Update cache
|
||||
configCache = {
|
||||
data: configResponse,
|
||||
timestamp: now,
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
const isFullyConfigured = isGitHubConfigured && isGiteaConfigured;
|
||||
|
||||
setConfigStatus({
|
||||
isGitHubConfigured,
|
||||
isGiteaConfigured,
|
||||
isFullyConfigured,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
hasCheckedRef.current = true;
|
||||
} catch (error) {
|
||||
setConfigStatus({
|
||||
isGitHubConfigured: false,
|
||||
isGiteaConfigured: false,
|
||||
isFullyConfigured: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check configuration',
|
||||
});
|
||||
hasCheckedRef.current = true;
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
checkConfiguration();
|
||||
}, [checkConfiguration]);
|
||||
|
||||
return configStatus;
|
||||
}
|
||||
|
||||
// Export function to invalidate cache when config is updated
|
||||
export function invalidateConfigCache() {
|
||||
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;
|
||||
}
|
||||
59
Divers/gitea-mirror/src/hooks/useFilterParams.ts
Normal file
59
Divers/gitea-mirror/src/hooks/useFilterParams.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
|
||||
const FILTER_KEYS: (keyof FilterParams)[] = [
|
||||
"searchTerm",
|
||||
"status",
|
||||
"membershipRole",
|
||||
"owner",
|
||||
"organization",
|
||||
"type",
|
||||
"name",
|
||||
];
|
||||
|
||||
export const useFilterParams = (
|
||||
defaultFilters: FilterParams,
|
||||
debounceDelay = 300
|
||||
) => {
|
||||
const getInitialFilter = (): FilterParams => {
|
||||
if (typeof window === "undefined") return defaultFilters;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: FilterParams = { ...defaultFilters };
|
||||
|
||||
FILTER_KEYS.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value !== null) {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const [filter, setFilter] = useState<FilterParams>(() => getInitialFilter());
|
||||
|
||||
// Debounced URL update
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
FILTER_KEYS.forEach((key) => {
|
||||
const value = filter[key];
|
||||
if (value) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}, debounceDelay);
|
||||
|
||||
return () => clearTimeout(handler); // Cleanup on unmount or when `filter` changes
|
||||
}, [filter, debounceDelay]);
|
||||
|
||||
return {
|
||||
filter,
|
||||
setFilter,
|
||||
};
|
||||
};
|
||||
73
Divers/gitea-mirror/src/hooks/useGiteaConfig.ts
Normal file
73
Divers/gitea-mirror/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;
|
||||
}
|
||||
102
Divers/gitea-mirror/src/hooks/useLiveRefresh.ts
Normal file
102
Divers/gitea-mirror/src/hooks/useLiveRefresh.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, createContext, useContext, useCallback, useRef } from "react";
|
||||
import { usePageVisibility } from "./usePageVisibility";
|
||||
import { useConfigStatus } from "./useConfigStatus";
|
||||
|
||||
interface LiveRefreshContextType {
|
||||
isLiveEnabled: boolean;
|
||||
toggleLive: () => void;
|
||||
registerRefreshCallback: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
const LiveRefreshContext = createContext<LiveRefreshContextType | undefined>(undefined);
|
||||
|
||||
const LIVE_REFRESH_INTERVAL = 3000; // 3 seconds
|
||||
const SESSION_STORAGE_KEY = 'gitea-mirror-live-refresh';
|
||||
|
||||
export function LiveRefreshProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
|
||||
const isPageVisible = usePageVisibility();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const refreshCallbacksRef = useRef<Set<() => void>>(new Set());
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load initial state from session storage
|
||||
useEffect(() => {
|
||||
const savedState = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (savedState === 'true') {
|
||||
setIsLiveEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save state to session storage whenever it changes
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, isLiveEnabled.toString());
|
||||
}, [isLiveEnabled]);
|
||||
|
||||
// Execute all registered refresh callbacks
|
||||
const executeRefreshCallbacks = useCallback(() => {
|
||||
refreshCallbacksRef.current.forEach(callback => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error executing refresh callback:', error);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Setup/cleanup the refresh interval
|
||||
useEffect(() => {
|
||||
// Clear existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Only set up interval if live is enabled, page is visible, and configuration is complete
|
||||
if (isLiveEnabled && isPageVisible && isFullyConfigured) {
|
||||
intervalRef.current = setInterval(executeRefreshCallbacks, LIVE_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isLiveEnabled, isPageVisible, isFullyConfigured, executeRefreshCallbacks]);
|
||||
|
||||
const toggleLive = useCallback(() => {
|
||||
setIsLiveEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const registerRefreshCallback = useCallback((callback: () => void) => {
|
||||
refreshCallbacksRef.current.add(callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
refreshCallbacksRef.current.delete(callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = {
|
||||
isLiveEnabled,
|
||||
toggleLive,
|
||||
registerRefreshCallback,
|
||||
};
|
||||
|
||||
return React.createElement(
|
||||
LiveRefreshContext.Provider,
|
||||
{ value: contextValue },
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export function useLiveRefresh() {
|
||||
const context = useContext(LiveRefreshContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useLiveRefresh must be used within a LiveRefreshProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
28
Divers/gitea-mirror/src/hooks/usePageVisibility.ts
Normal file
28
Divers/gitea-mirror/src/hooks/usePageVisibility.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect if the page/tab is currently visible
|
||||
* Returns false when user switches to another tab or minimizes the window
|
||||
*/
|
||||
export function usePageVisibility(): boolean {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
// Set initial state
|
||||
setIsVisible(!document.hidden);
|
||||
|
||||
// Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
111
Divers/gitea-mirror/src/hooks/useSEE.ts
Normal file
111
Divers/gitea-mirror/src/hooks/useSEE.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
|
||||
interface UseSSEOptions {
|
||||
userId?: string;
|
||||
onMessage: (data: MirrorJob) => void;
|
||||
maxReconnectAttempts?: number;
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
export const useSSE = ({
|
||||
userId,
|
||||
onMessage,
|
||||
maxReconnectAttempts = 5,
|
||||
reconnectDelay = 3000
|
||||
}: UseSSEOptions) => {
|
||||
const [connected, setConnected] = useState<boolean>(false);
|
||||
const [reconnectCount, setReconnectCount] = useState<number>(0);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// Update the ref when onMessage changes
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
|
||||
// Create a stable connect function that can be called for reconnection
|
||||
const connect = useCallback(() => {
|
||||
if (!userId) return;
|
||||
|
||||
// Clean up any existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
// Clear any pending reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Create new EventSource connection
|
||||
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
// Check if this is an error message from our server
|
||||
if (event.data.startsWith('{"error":')) {
|
||||
console.warn("SSE server error:", event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMessage: MirrorJob = JSON.parse(event.data);
|
||||
onMessageRef.current(parsedMessage);
|
||||
} catch (error) {
|
||||
console.error("Error parsing SSE message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onmessage = handleMessage;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
setReconnectCount(0); // Reset reconnect counter on successful connection
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE connection error:", error);
|
||||
setConnected(false);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Attempt to reconnect if we haven't exceeded max attempts
|
||||
if (reconnectCount < maxReconnectAttempts) {
|
||||
const nextReconnectDelay = Math.min(reconnectDelay * Math.pow(1.5, reconnectCount), 30000);
|
||||
console.log(`Attempting to reconnect in ${nextReconnectDelay}ms (attempt ${reconnectCount + 1}/${maxReconnectAttempts})`);
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
setReconnectCount(prev => prev + 1);
|
||||
connect();
|
||||
}, nextReconnectDelay);
|
||||
} else {
|
||||
console.error(`Failed to reconnect after ${maxReconnectAttempts} attempts`);
|
||||
}
|
||||
};
|
||||
}, [userId, maxReconnectAttempts, reconnectDelay, reconnectCount]);
|
||||
|
||||
// Set up the connection
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, connect]);
|
||||
|
||||
return { connected };
|
||||
};
|
||||
100
Divers/gitea-mirror/src/hooks/useSyncRepo.ts
Normal file
100
Divers/gitea-mirror/src/hooks/useSyncRepo.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
interface UseRepoSyncOptions {
|
||||
userId?: string;
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
lastSync?: Date | null;
|
||||
nextSync?: Date | null;
|
||||
}
|
||||
|
||||
export function useRepoSync({
|
||||
userId,
|
||||
enabled = true,
|
||||
interval = 3600,
|
||||
lastSync,
|
||||
nextSync,
|
||||
}: UseRepoSyncOptions) {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { refreshUser } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !userId) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to convert possible nextSync types to Date
|
||||
const getNextSyncDate = () => {
|
||||
if (!nextSync) return null;
|
||||
if (nextSync instanceof Date) return nextSync;
|
||||
return new Date(nextSync); // Handles strings and numbers
|
||||
};
|
||||
|
||||
const getLastSyncDate = () => {
|
||||
if (!lastSync) return null;
|
||||
if (lastSync instanceof Date) return lastSync;
|
||||
return new Date(lastSync);
|
||||
};
|
||||
|
||||
const isTimeToSync = () => {
|
||||
const nextSyncDate = getNextSyncDate();
|
||||
if (!nextSyncDate) return true; // No nextSync means sync immediately
|
||||
|
||||
const currentTime = new Date();
|
||||
return currentTime >= nextSyncDate;
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/job/schedule-sync-repo", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Sync failed:", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshUser(); // refresh user data to get latest sync times. this can be taken from the schedule-sync-repo response but might not be reliable in cases of errors
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Sync failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if sync is overdue when the component mounts or interval passes
|
||||
if (isTimeToSync()) {
|
||||
sync();
|
||||
}
|
||||
|
||||
// Periodically check if it's time to sync
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (isTimeToSync()) {
|
||||
sync();
|
||||
}
|
||||
}, interval * 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
interval,
|
||||
userId,
|
||||
nextSync instanceof Date ? nextSync.getTime() : nextSync,
|
||||
lastSync instanceof Date ? lastSync.getTime() : lastSync,
|
||||
]);
|
||||
}
|
||||
21
Divers/gitea-mirror/src/layouts/main.astro
Normal file
21
Divers/gitea-mirror/src/layouts/main.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import '../styles/docs.css';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
|
||||
// Accept title as a prop with a default value
|
||||
const { title = 'Gitea Mirror' } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
171
Divers/gitea-mirror/src/lib/api.ts
Normal file
171
Divers/gitea-mirror/src/lib/api.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// Base API URL
|
||||
const API_BASE = "/api";
|
||||
|
||||
// Helper function for API requests
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
message: "An unknown error occurred",
|
||||
}));
|
||||
throw new Error(error.message || "An unknown error occurred");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: async (username: string, password: string) => {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include", // Send cookies
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Login failed");
|
||||
return await res.json(); // returns user
|
||||
},
|
||||
|
||||
register: async (username: string, email: string, password: string) => {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Registration failed");
|
||||
return await res.json(); // returns user
|
||||
},
|
||||
|
||||
getCurrentUser: async () => {
|
||||
const res = await fetch(`${API_BASE}/auth`, {
|
||||
method: "GET",
|
||||
credentials: "include", // Send cookies
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub API
|
||||
export const githubApi = {
|
||||
testConnection: (token: string) =>
|
||||
apiRequest<{ success: boolean }>("/github/test-connection", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Gitea API
|
||||
export const giteaApi = {
|
||||
testConnection: (url: string, token: string) =>
|
||||
apiRequest<{ success: boolean }>("/gitea/test-connection", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, token }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Health API
|
||||
export interface HealthResponse {
|
||||
status: "ok" | "error";
|
||||
timestamp: string;
|
||||
version: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
database: {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
};
|
||||
system: {
|
||||
uptime: {
|
||||
startTime: string;
|
||||
uptimeMs: number;
|
||||
formatted: string;
|
||||
};
|
||||
memory: {
|
||||
rss: string;
|
||||
heapTotal: string;
|
||||
heapUsed: string;
|
||||
external: string;
|
||||
systemTotal: string;
|
||||
systemFree: string;
|
||||
};
|
||||
os: {
|
||||
platform: string;
|
||||
version: string;
|
||||
arch: string;
|
||||
};
|
||||
env: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
check: async (): Promise<HealthResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/health`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
status: "error",
|
||||
error: "Failed to parse error response",
|
||||
}));
|
||||
|
||||
return {
|
||||
...errorData,
|
||||
status: "error",
|
||||
timestamp: new Date().toISOString(),
|
||||
} as HealthResponse;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : "Unknown error checking health",
|
||||
version: "unknown",
|
||||
latestVersion: "unknown",
|
||||
updateAvailable: false,
|
||||
database: { connected: false, message: "Failed to connect to API" },
|
||||
system: {
|
||||
uptime: { startTime: "", uptimeMs: 0, formatted: "N/A" },
|
||||
memory: {
|
||||
rss: "N/A",
|
||||
heapTotal: "N/A",
|
||||
heapUsed: "N/A",
|
||||
external: "N/A",
|
||||
systemTotal: "N/A",
|
||||
systemFree: "N/A",
|
||||
},
|
||||
os: { platform: "", version: "", arch: "" },
|
||||
env: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
64
Divers/gitea-mirror/src/lib/auth-client.ts
Normal file
64
Divers/gitea-mirror/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
// This allows the client to connect to the auth server even when accessed from different origins
|
||||
baseURL: (() => {
|
||||
let url: string | undefined;
|
||||
|
||||
// Check for public environment variable first (for client-side access)
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
|
||||
url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
// Validate and clean the URL if provided
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
try {
|
||||
// Validate URL format and remove trailing slash
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin; // Use origin to ensure clean URL without path
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to current origin if running in browser
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
ssoClient(),
|
||||
],
|
||||
});
|
||||
|
||||
// Export commonly used methods for convenience
|
||||
export const {
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
useSession,
|
||||
sendVerificationEmail,
|
||||
resetPassword,
|
||||
requestPasswordReset,
|
||||
getSession
|
||||
} = authClient;
|
||||
|
||||
// Export types - directly use the types from better-auth
|
||||
export type Session = BetterAuthSession & {
|
||||
user: BetterAuthUser & {
|
||||
username?: string | null;
|
||||
};
|
||||
};
|
||||
export type AuthUser = BetterAuthUser & {
|
||||
username?: string | null;
|
||||
};
|
||||
139
Divers/gitea-mirror/src/lib/auth-header.ts
Normal file
139
Divers/gitea-mirror/src/lib/auth-header.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { db, users } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export interface HeaderAuthConfig {
|
||||
enabled: boolean;
|
||||
userHeader: string;
|
||||
emailHeader?: string;
|
||||
nameHeader?: string;
|
||||
autoProvision: boolean;
|
||||
allowedDomains?: string[];
|
||||
}
|
||||
|
||||
// Default configuration - DISABLED by default
|
||||
export const defaultHeaderAuthConfig: HeaderAuthConfig = {
|
||||
enabled: false,
|
||||
userHeader: "X-Authentik-Username", // Common header name
|
||||
emailHeader: "X-Authentik-Email",
|
||||
nameHeader: "X-Authentik-Name",
|
||||
autoProvision: false,
|
||||
allowedDomains: [],
|
||||
};
|
||||
|
||||
// Get header auth config from environment or database
|
||||
export function getHeaderAuthConfig(): HeaderAuthConfig {
|
||||
// Check environment variables for header auth config
|
||||
const envConfig: Partial<HeaderAuthConfig> = {
|
||||
enabled: process.env.HEADER_AUTH_ENABLED === "true",
|
||||
userHeader: process.env.HEADER_AUTH_USER_HEADER || defaultHeaderAuthConfig.userHeader,
|
||||
emailHeader: process.env.HEADER_AUTH_EMAIL_HEADER || defaultHeaderAuthConfig.emailHeader,
|
||||
nameHeader: process.env.HEADER_AUTH_NAME_HEADER || defaultHeaderAuthConfig.nameHeader,
|
||||
autoProvision: process.env.HEADER_AUTH_AUTO_PROVISION === "true",
|
||||
allowedDomains: process.env.HEADER_AUTH_ALLOWED_DOMAINS?.split(",").map(d => d.trim()),
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultHeaderAuthConfig,
|
||||
...envConfig,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if header authentication is enabled
|
||||
export function isHeaderAuthEnabled(): boolean {
|
||||
const config = getHeaderAuthConfig();
|
||||
return config.enabled === true;
|
||||
}
|
||||
|
||||
// Extract user info from headers
|
||||
export function extractUserFromHeaders(headers: Headers): {
|
||||
username?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
} | null {
|
||||
const config = getHeaderAuthConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = headers.get(config.userHeader);
|
||||
const email = config.emailHeader ? headers.get(config.emailHeader) : undefined;
|
||||
const name = config.nameHeader ? headers.get(config.nameHeader) : undefined;
|
||||
|
||||
if (!username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If allowed domains are configured, check email domain
|
||||
if (config.allowedDomains && config.allowedDomains.length > 0 && email) {
|
||||
const domain = email.split("@")[1];
|
||||
if (!config.allowedDomains.includes(domain)) {
|
||||
console.warn(`Header auth rejected: email domain ${domain} not in allowed list`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
username: username || undefined,
|
||||
email: email || undefined,
|
||||
name: name || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user from header auth
|
||||
export async function authenticateWithHeaders(headers: Headers) {
|
||||
const userInfo = extractUserFromHeaders(headers);
|
||||
|
||||
if (!userInfo || !userInfo.username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getHeaderAuthConfig();
|
||||
|
||||
// Try to find existing user by username or email
|
||||
let existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, userInfo.username))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0 && userInfo.email) {
|
||||
existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, userInfo.email))
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return existingUser[0];
|
||||
}
|
||||
|
||||
// If auto-provisioning is disabled, don't create new users
|
||||
if (!config.autoProvision) {
|
||||
console.warn(`Header auth: User ${userInfo.username} not found and auto-provisioning is disabled`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new user if auto-provisioning is enabled
|
||||
try {
|
||||
const newUser = {
|
||||
id: nanoid(),
|
||||
username: userInfo.username,
|
||||
email: userInfo.email || `${userInfo.username}@header-auth.local`,
|
||||
emailVerified: true, // Trust the auth provider
|
||||
name: userInfo.name || userInfo.username,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(users).values(newUser);
|
||||
console.log(`Header auth: Auto-provisioned new user ${userInfo.username}`);
|
||||
|
||||
return newUser;
|
||||
} catch (error) {
|
||||
console.error("Failed to auto-provision user from header auth:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
190
Divers/gitea-mirror/src/lib/auth-multi-url.test.ts
Normal file
190
Divers/gitea-mirror/src/lib/auth-multi-url.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
describe("Multiple URL Support in BETTER_AUTH_URL", () => {
|
||||
let originalAuthUrl: string | undefined;
|
||||
let originalTrustedOrigins: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment variables
|
||||
originalAuthUrl = process.env.BETTER_AUTH_URL;
|
||||
originalTrustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment variables
|
||||
if (originalAuthUrl !== undefined) {
|
||||
process.env.BETTER_AUTH_URL = originalAuthUrl;
|
||||
} else {
|
||||
delete process.env.BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
if (originalTrustedOrigins !== undefined) {
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = originalTrustedOrigins;
|
||||
} else {
|
||||
delete process.env.BETTER_AUTH_TRUSTED_ORIGINS;
|
||||
}
|
||||
});
|
||||
|
||||
test("should parse single URL correctly", () => {
|
||||
process.env.BETTER_AUTH_URL = "https://gitea-mirror.mydomain.tld";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
// Find first valid URL
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
return { primary: url, all: urls };
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return { primary: "http://localhost:4321", all: [] };
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("https://gitea-mirror.mydomain.tld");
|
||||
expect(result.all).toEqual(["https://gitea-mirror.mydomain.tld"]);
|
||||
});
|
||||
|
||||
test("should parse multiple URLs and use first as primary", () => {
|
||||
process.env.BETTER_AUTH_URL = "http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
// Find first valid URL
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
return { primary: url, all: urls };
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return { primary: "http://localhost:4321", all: [] };
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("http://10.10.20.45:4321");
|
||||
expect(result.all).toEqual([
|
||||
"http://10.10.20.45:4321",
|
||||
"https://gitea-mirror.mydomain.tld"
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle invalid URLs gracefully", () => {
|
||||
process.env.BETTER_AUTH_URL = "not-a-url,http://valid.url:4321,also-invalid";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
const validUrls: string[] = [];
|
||||
let primaryUrl = "";
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
validUrls.push(url);
|
||||
if (!primaryUrl) {
|
||||
primaryUrl = url;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid URLs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primary: primaryUrl || "http://localhost:4321",
|
||||
all: validUrls
|
||||
};
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("http://valid.url:4321");
|
||||
expect(result.all).toEqual(["http://valid.url:4321"]);
|
||||
});
|
||||
|
||||
test("should include all URLs in trusted origins", () => {
|
||||
process.env.BETTER_AUTH_URL = "http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld";
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://auth.provider.com";
|
||||
|
||||
const getTrustedOrigins = () => {
|
||||
const origins = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080",
|
||||
];
|
||||
|
||||
// Add all URLs from BETTER_AUTH_URL
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "";
|
||||
if (urlEnv) {
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
urls.forEach(url => {
|
||||
try {
|
||||
new URL(url);
|
||||
origins.push(url);
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add additional trusted origins
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim()));
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(origins.filter(Boolean))];
|
||||
};
|
||||
|
||||
const origins = getTrustedOrigins();
|
||||
expect(origins).toContain("http://10.10.20.45:4321");
|
||||
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
|
||||
expect(origins).toContain("https://auth.provider.com");
|
||||
expect(origins).toContain("http://localhost:4321");
|
||||
});
|
||||
|
||||
test("should handle empty BETTER_AUTH_URL", () => {
|
||||
delete process.env.BETTER_AUTH_URL;
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
return { primary: url, all: urls };
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return { primary: "http://localhost:4321", all: ["http://localhost:4321"] };
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("http://localhost:4321");
|
||||
});
|
||||
|
||||
test("should handle whitespace in comma-separated URLs", () => {
|
||||
process.env.BETTER_AUTH_URL = " http://10.10.20.45:4321 , https://gitea-mirror.mydomain.tld , http://localhost:3000 ";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
return urls;
|
||||
};
|
||||
|
||||
const urls = parseAuthUrls();
|
||||
expect(urls).toEqual([
|
||||
"http://10.10.20.45:4321",
|
||||
"https://gitea-mirror.mydomain.tld",
|
||||
"http://localhost:3000"
|
||||
]);
|
||||
});
|
||||
});
|
||||
176
Divers/gitea-mirror/src/lib/auth.ts
Normal file
176
Divers/gitea-mirror/src/lib/auth.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
usePlural: true, // Our tables use plural names (users, not user)
|
||||
schema, // Pass the schema explicitly
|
||||
}),
|
||||
|
||||
// Secret for signing tokens
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
const defaultUrl = "http://localhost:4321";
|
||||
|
||||
// Check if URL is provided and not empty
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
console.info('BETTER_AUTH_URL not set, using default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate URL format and ensure it's a proper origin
|
||||
const validatedUrl = new URL(url.trim());
|
||||
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
console.error(`Invalid BETTER_AUTH_URL format: "${url}"`);
|
||||
console.error('Error:', e);
|
||||
console.info('Falling back to default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
// This is where users can specify multiple access URLs
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
// Validate each additional origin
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
|
||||
// Authentication methods
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // We'll enable this later
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
// TODO: Implement email sending for password reset
|
||||
console.log("Password reset requested for:", user.email);
|
||||
console.log("Reset URL:", url);
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
cookieName: "better-auth-session",
|
||||
updateSessionCookieAge: true,
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
|
||||
// User configuration
|
||||
user: {
|
||||
additionalFields: {
|
||||
// Keep the username field from our existing schema
|
||||
username: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: false, // Don't show in signup form - we'll derive from email
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins configuration
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/oauth/consent",
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
// For now, we'll use dynamic registration
|
||||
// Customize user info claims based on scopes
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) {
|
||||
claims.username = user.username;
|
||||
}
|
||||
return claims;
|
||||
},
|
||||
}),
|
||||
|
||||
// SSO plugin - allows users to authenticate with external OIDC providers
|
||||
sso({
|
||||
// Provision new users when they sign in with SSO
|
||||
provisionUser: async ({ user }: { user: any, userInfo: any }) => {
|
||||
// Derive username from email if not provided
|
||||
const username = user.name || user.email?.split('@')[0] || 'user';
|
||||
|
||||
// Update user in database if needed
|
||||
await db.update(users)
|
||||
.set({ username })
|
||||
.where(eq(users.id, user.id))
|
||||
.catch(() => {}); // Ignore errors if user doesn't exist yet
|
||||
},
|
||||
// Organization provisioning settings
|
||||
organizationProvisioning: {
|
||||
disabled: false,
|
||||
defaultRole: "member",
|
||||
getRole: async ({ userInfo }: { user: any, userInfo: any }) => {
|
||||
// Check if user has admin attribute from SSO provider
|
||||
const isAdmin = userInfo.attributes?.role === 'admin' ||
|
||||
userInfo.attributes?.groups?.includes('admins');
|
||||
|
||||
return isAdmin ? "admin" : "member";
|
||||
},
|
||||
},
|
||||
// Override user info with provider data by default
|
||||
defaultOverrideUserInfo: true,
|
||||
// Allow implicit sign up for new users
|
||||
disableImplicitSignUp: false,
|
||||
// Trust email_verified claims from the upstream provider so we can link by matching email
|
||||
trustEmailVerified: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Export type for use in other parts of the app
|
||||
export type Auth = typeof auth;
|
||||
258
Divers/gitea-mirror/src/lib/cleanup-service.ts
Normal file
258
Divers/gitea-mirror/src/lib/cleanup-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Background cleanup service for automatic database maintenance
|
||||
* This service runs periodically to clean up old events and mirror jobs
|
||||
* based on user configuration settings
|
||||
*/
|
||||
|
||||
import { db, configs, events, mirrorJobs } from "@/lib/db";
|
||||
import { eq, lt, and } from "drizzle-orm";
|
||||
|
||||
interface CleanupResult {
|
||||
userId: string;
|
||||
eventsDeleted: number;
|
||||
mirrorJobsDeleted: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cleanup interval in hours based on retention period
|
||||
* For shorter retention periods, run more frequently
|
||||
* For longer retention periods, run less frequently
|
||||
* @param retentionSeconds - Retention period in seconds
|
||||
*/
|
||||
export function calculateCleanupInterval(retentionSeconds: number): number {
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60); // Convert seconds to days
|
||||
|
||||
if (retentionDays <= 1) {
|
||||
return 6; // Every 6 hours for 1 day retention
|
||||
} else if (retentionDays <= 3) {
|
||||
return 12; // Every 12 hours for 1-3 days retention
|
||||
} else if (retentionDays <= 7) {
|
||||
return 24; // Daily for 4-7 days retention
|
||||
} else if (retentionDays <= 30) {
|
||||
return 48; // Every 2 days for 8-30 days retention
|
||||
} else {
|
||||
return 168; // Weekly for 30+ days retention
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old events and mirror jobs for a specific user
|
||||
* @param retentionSeconds - Retention period in seconds
|
||||
*/
|
||||
async function cleanupForUser(userId: string, retentionSeconds: number): Promise<CleanupResult> {
|
||||
try {
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60); // Convert to days for logging
|
||||
console.log(`Running cleanup for user ${userId} with ${retentionDays} days retention (${retentionSeconds} seconds)`);
|
||||
|
||||
// Calculate cutoff date using seconds
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setTime(cutoffDate.getTime() - retentionSeconds * 1000);
|
||||
|
||||
let eventsDeleted = 0;
|
||||
let mirrorJobsDeleted = 0;
|
||||
|
||||
// Clean up old events
|
||||
await db
|
||||
.delete(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.userId, userId),
|
||||
lt(events.createdAt, cutoffDate)
|
||||
)
|
||||
);
|
||||
eventsDeleted = 0; // SQLite delete doesn't return count
|
||||
|
||||
// Clean up old mirror jobs (only completed ones)
|
||||
await db
|
||||
.delete(mirrorJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(mirrorJobs.userId, userId),
|
||||
eq(mirrorJobs.inProgress, false),
|
||||
lt(mirrorJobs.timestamp, cutoffDate)
|
||||
)
|
||||
);
|
||||
mirrorJobsDeleted = 0; // SQLite delete doesn't return count
|
||||
|
||||
console.log(`Cleanup completed for user ${userId}: ${eventsDeleted} events, ${mirrorJobsDeleted} jobs deleted`);
|
||||
|
||||
return {
|
||||
userId,
|
||||
eventsDeleted,
|
||||
mirrorJobsDeleted,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error during cleanup for user ${userId}:`, error);
|
||||
return {
|
||||
userId,
|
||||
eventsDeleted: 0,
|
||||
mirrorJobsDeleted: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cleanup configuration with last run time and calculate next run
|
||||
*/
|
||||
async function updateCleanupConfig(userId: string, cleanupConfig: any) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const retentionSeconds = cleanupConfig.retentionDays || 604800; // Default 7 days in seconds
|
||||
const cleanupIntervalHours = calculateCleanupInterval(retentionSeconds);
|
||||
const nextRun = new Date(now.getTime() + cleanupIntervalHours * 60 * 60 * 1000);
|
||||
|
||||
const updatedConfig = {
|
||||
...cleanupConfig,
|
||||
lastRun: now,
|
||||
nextRun: nextRun,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(configs)
|
||||
.set({
|
||||
cleanupConfig: updatedConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(configs.userId, userId));
|
||||
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60);
|
||||
console.log(`Updated cleanup config for user ${userId}, next run: ${nextRun.toISOString()} (${cleanupIntervalHours}h interval for ${retentionDays}d retention)`);
|
||||
} catch (error) {
|
||||
console.error(`Error updating cleanup config for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run automatic cleanup for all users with cleanup enabled
|
||||
*/
|
||||
export async function runAutomaticCleanup(): Promise<CleanupResult[]> {
|
||||
try {
|
||||
console.log('Starting automatic cleanup service...');
|
||||
|
||||
// Get all users with cleanup enabled
|
||||
const userConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.isActive, true));
|
||||
|
||||
const results: CleanupResult[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const config of userConfigs) {
|
||||
try {
|
||||
const cleanupConfig = config.cleanupConfig;
|
||||
|
||||
// Skip if cleanup is not enabled
|
||||
if (!cleanupConfig?.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's time to run cleanup
|
||||
const nextRun = cleanupConfig.nextRun ? new Date(cleanupConfig.nextRun) : null;
|
||||
|
||||
// If nextRun is null or in the past, run cleanup
|
||||
if (!nextRun || now >= nextRun) {
|
||||
const result = await cleanupForUser(config.userId, cleanupConfig.retentionDays || 604800);
|
||||
results.push(result);
|
||||
|
||||
// Update the cleanup config with new run times
|
||||
await updateCleanupConfig(config.userId, cleanupConfig);
|
||||
} else {
|
||||
console.log(`Skipping cleanup for user ${config.userId}, next run: ${nextRun.toISOString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing cleanup for user ${config.userId}:`, error);
|
||||
results.push({
|
||||
userId: config.userId,
|
||||
eventsDeleted: 0,
|
||||
mirrorJobsDeleted: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Automatic cleanup completed. Processed ${results.length} users.`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error in automatic cleanup service:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Service state tracking
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
let initialCleanupTimeoutId: NodeJS.Timeout | null = null;
|
||||
let cleanupServiceRunning = false;
|
||||
|
||||
/**
|
||||
* Start the cleanup service with periodic execution
|
||||
* This should be called when the application starts
|
||||
*/
|
||||
export function startCleanupService() {
|
||||
if (cleanupServiceRunning) {
|
||||
console.log('⚠️ Cleanup service already running, skipping start');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting background cleanup service...');
|
||||
|
||||
// Run cleanup every hour
|
||||
const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
// Run initial cleanup after 5 minutes to allow app to fully start
|
||||
initialCleanupTimeoutId = setTimeout(() => {
|
||||
runAutomaticCleanup().catch(error => {
|
||||
console.error('Error in initial cleanup run:', error);
|
||||
});
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
// Set up periodic cleanup
|
||||
cleanupIntervalId = setInterval(() => {
|
||||
runAutomaticCleanup().catch(error => {
|
||||
console.error('Error in periodic cleanup run:', error);
|
||||
});
|
||||
}, CLEANUP_INTERVAL);
|
||||
|
||||
cleanupServiceRunning = true;
|
||||
console.log(`✅ Cleanup service started. Will run every ${CLEANUP_INTERVAL / 1000 / 60} minutes.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup service (for testing or shutdown)
|
||||
*/
|
||||
export function stopCleanupService() {
|
||||
if (!cleanupServiceRunning) {
|
||||
console.log('Cleanup service is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🛑 Stopping cleanup service...');
|
||||
|
||||
// Clear the periodic interval
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
|
||||
// Clear the initial timeout
|
||||
if (initialCleanupTimeoutId) {
|
||||
clearTimeout(initialCleanupTimeoutId);
|
||||
initialCleanupTimeoutId = null;
|
||||
}
|
||||
|
||||
cleanupServiceRunning = false;
|
||||
console.log('✅ Cleanup service stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cleanup service status
|
||||
*/
|
||||
export function getCleanupServiceStatus() {
|
||||
return {
|
||||
running: cleanupServiceRunning,
|
||||
hasInterval: cleanupIntervalId !== null,
|
||||
hasInitialTimeout: initialCleanupTimeoutId !== null,
|
||||
};
|
||||
}
|
||||
28
Divers/gitea-mirror/src/lib/config.ts
Normal file
28
Divers/gitea-mirror/src/lib/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Application configuration
|
||||
*/
|
||||
|
||||
// Environment variables
|
||||
export const ENV = {
|
||||
// Runtime environment (development, production, test)
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
|
||||
// Database URL - use SQLite by default
|
||||
get DATABASE_URL() {
|
||||
// If explicitly set, use the provided DATABASE_URL
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL;
|
||||
}
|
||||
|
||||
// Otherwise, use the default database
|
||||
return "sqlite://data/gitea-mirror.db";
|
||||
},
|
||||
|
||||
// Better Auth secret for authentication
|
||||
BETTER_AUTH_SECRET:
|
||||
process.env.BETTER_AUTH_SECRET || "your-secret-key-change-this-in-production",
|
||||
|
||||
// Server host and port
|
||||
HOST: process.env.HOST || "localhost",
|
||||
PORT: parseInt(process.env.PORT || "4321", 10),
|
||||
};
|
||||
102
Divers/gitea-mirror/src/lib/db/adapter.ts
Normal file
102
Divers/gitea-mirror/src/lib/db/adapter.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Database adapter for SQLite
|
||||
* For the self-hosted version of Gitea Mirror
|
||||
*/
|
||||
|
||||
import { drizzle as drizzleSqlite } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
|
||||
export type DatabaseClient = ReturnType<typeof createDatabase>;
|
||||
|
||||
/**
|
||||
* Create SQLite database connection
|
||||
*/
|
||||
export function createDatabase() {
|
||||
const dbPath = process.env.DATABASE_PATH || './data/gitea-mirror.db';
|
||||
|
||||
// Ensure directory exists
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create SQLite connection
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
// Enable foreign keys and WAL mode for better performance
|
||||
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||
sqlite.exec('PRAGMA journal_mode = WAL');
|
||||
sqlite.exec('PRAGMA synchronous = NORMAL');
|
||||
sqlite.exec('PRAGMA cache_size = -2000'); // 2MB cache
|
||||
sqlite.exec('PRAGMA temp_store = MEMORY');
|
||||
|
||||
// Create Drizzle instance with SQLite
|
||||
const db = drizzleSqlite(sqlite, {
|
||||
schema,
|
||||
logger: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
client: sqlite,
|
||||
type: 'sqlite' as const,
|
||||
|
||||
// Helper methods
|
||||
async close() {
|
||||
sqlite.close();
|
||||
},
|
||||
|
||||
async healthCheck() {
|
||||
try {
|
||||
sqlite.query('SELECT 1').get();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async transaction<T>(fn: (tx: any) => Promise<T>) {
|
||||
return db.transaction(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
let dbInstance: DatabaseClient | null = null;
|
||||
|
||||
/**
|
||||
* Get database instance (singleton)
|
||||
*/
|
||||
export function getDatabase(): DatabaseClient {
|
||||
if (!dbInstance) {
|
||||
dbInstance = createDatabase();
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
export async function closeDatabase() {
|
||||
if (dbInstance) {
|
||||
await dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export convenience references
|
||||
export const { db, client, type: dbType } = getDatabase();
|
||||
|
||||
// Re-export schema for convenience
|
||||
export * from './schema';
|
||||
|
||||
/**
|
||||
* Database migration utilities
|
||||
*/
|
||||
export async function runMigrations() {
|
||||
const { migrate } = await import('drizzle-orm/bun-sqlite/migrator');
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
}
|
||||
42
Divers/gitea-mirror/src/lib/db/index.test.ts
Normal file
42
Divers/gitea-mirror/src/lib/db/index.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, test, expect, mock, beforeAll, afterAll } from "bun:test";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
// Silence console logs during tests
|
||||
let originalConsoleLog: typeof console.log;
|
||||
|
||||
beforeAll(() => {
|
||||
// Save original console.log
|
||||
originalConsoleLog = console.log;
|
||||
// Replace with no-op function
|
||||
console.log = () => {};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original console.log
|
||||
console.log = originalConsoleLog;
|
||||
});
|
||||
|
||||
// Mock the database module
|
||||
mock.module("bun:sqlite", () => {
|
||||
return {
|
||||
Database: mock(function() {
|
||||
return {
|
||||
query: mock(() => ({
|
||||
all: mock(() => []),
|
||||
run: mock(() => ({}))
|
||||
}))
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the database tables
|
||||
describe("Database Schema", () => {
|
||||
test("database connection can be created", async () => {
|
||||
// Import the db from the module
|
||||
const { db } = await import("./index");
|
||||
|
||||
// Check that db is defined
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
});
|
||||
87
Divers/gitea-mirror/src/lib/db/index.ts
Normal file
87
Divers/gitea-mirror/src/lib/db/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
|
||||
// Skip database initialization in test environment
|
||||
let db: ReturnType<typeof drizzle>;
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
// Define the database URL - for development we'll use a local SQLite file
|
||||
const dataDir = path.join(process.cwd(), "data");
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, "gitea-mirror.db");
|
||||
|
||||
// Create an empty database file if it doesn't exist
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
fs.writeFileSync(dbPath, "");
|
||||
}
|
||||
|
||||
// Create SQLite database instance using Bun's native driver
|
||||
let sqlite: Database;
|
||||
try {
|
||||
sqlite = new Database(dbPath);
|
||||
console.log("Successfully connected to SQLite database using Bun's native driver");
|
||||
} catch (error) {
|
||||
console.error("Error opening database:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create drizzle instance with the SQLite client
|
||||
db = drizzle({ client: sqlite });
|
||||
|
||||
/**
|
||||
* Run Drizzle migrations
|
||||
*/
|
||||
function runDrizzleMigrations() {
|
||||
try {
|
||||
console.log("🔄 Checking for pending migrations...");
|
||||
|
||||
// Check if migrations table exists
|
||||
const migrationsTableExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
|
||||
if (!migrationsTableExists) {
|
||||
console.log("📦 First time setup - running initial migrations...");
|
||||
}
|
||||
|
||||
// Run migrations using Drizzle migrate function
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
|
||||
console.log("✅ Database migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Error running migrations:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run Drizzle migrations after db is initialized
|
||||
runDrizzleMigrations();
|
||||
}
|
||||
|
||||
export { db };
|
||||
|
||||
// Export all table definitions from schema
|
||||
export {
|
||||
users,
|
||||
events,
|
||||
configs,
|
||||
repositories,
|
||||
mirrorJobs,
|
||||
organizations,
|
||||
sessions,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
verifications,
|
||||
oauthApplications,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
699
Divers/gitea-mirror/src/lib/db/schema.ts
Normal file
699
Divers/gitea-mirror/src/lib/db/schema.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import { z } from "zod";
|
||||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ===== Zod Validation Schemas =====
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
email: z.email(),
|
||||
emailVerified: z.boolean().default(false),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const githubConfigSchema = z.object({
|
||||
owner: z.string(),
|
||||
type: z.enum(["personal", "organization"]),
|
||||
token: z.string(),
|
||||
includeStarred: z.boolean().default(false),
|
||||
includeForks: z.boolean().default(true),
|
||||
skipForks: z.boolean().default(false),
|
||||
includeArchived: z.boolean().default(false),
|
||||
includePrivate: z.boolean().default(true),
|
||||
includePublic: z.boolean().default(true),
|
||||
includeOrganizations: z.array(z.string()).default([]),
|
||||
starredReposOrg: z.string().optional(),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
|
||||
defaultOrg: z.string().optional(),
|
||||
starredCodeOnly: z.boolean().default(false),
|
||||
skipStarredIssues: z.boolean().optional(), // Deprecated: kept for backward compatibility, use starredCodeOnly instead
|
||||
starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(),
|
||||
});
|
||||
|
||||
export const giteaConfigSchema = z.object({
|
||||
url: z.url(),
|
||||
token: z.string(),
|
||||
defaultOwner: z.string(),
|
||||
organization: z.string().optional(),
|
||||
mirrorInterval: z.string().default("8h"),
|
||||
lfs: z.boolean().default(false),
|
||||
wiki: z.boolean().default(false),
|
||||
visibility: z
|
||||
.enum(["public", "private", "limited", "default"])
|
||||
.default("default"),
|
||||
createOrg: z.boolean().default(true),
|
||||
templateOwner: z.string().optional(),
|
||||
templateRepo: z.string().optional(),
|
||||
addTopics: z.boolean().default(true),
|
||||
topicPrefix: z.string().optional(),
|
||||
preserveVisibility: z.boolean().default(true),
|
||||
preserveOrgStructure: z.boolean().default(false),
|
||||
forkStrategy: z
|
||||
.enum(["skip", "reference", "full-copy"])
|
||||
.default("reference"),
|
||||
// Mirror options
|
||||
issueConcurrency: z.number().int().min(1).default(3),
|
||||
pullRequestConcurrency: z.number().int().min(1).default(5),
|
||||
mirrorReleases: z.boolean().default(false),
|
||||
releaseLimit: z.number().default(10),
|
||||
mirrorMetadata: z.boolean().default(false),
|
||||
mirrorIssues: z.boolean().default(false),
|
||||
mirrorPullRequests: z.boolean().default(false),
|
||||
mirrorLabels: z.boolean().default(false),
|
||||
mirrorMilestones: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const scheduleConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
interval: z.string().default("0 2 * * *"),
|
||||
concurrent: z.boolean().default(false),
|
||||
batchSize: z.number().default(10),
|
||||
pauseBetweenBatches: z.number().default(5000),
|
||||
retryAttempts: z.number().default(3),
|
||||
retryDelay: z.number().default(60000),
|
||||
timeout: z.number().default(3600000),
|
||||
autoRetry: z.boolean().default(true),
|
||||
cleanupBeforeMirror: z.boolean().default(false),
|
||||
notifyOnFailure: z.boolean().default(true),
|
||||
notifyOnSuccess: z.boolean().default(false),
|
||||
logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
||||
timezone: z.string().default("UTC"),
|
||||
onlyMirrorUpdated: z.boolean().default(false),
|
||||
updateInterval: z.number().default(86400000),
|
||||
skipRecentlyMirrored: z.boolean().default(true),
|
||||
recentThreshold: z.number().default(3600000),
|
||||
autoImport: z.boolean().default(true),
|
||||
autoMirror: z.boolean().default(false),
|
||||
lastRun: z.coerce.date().optional(),
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const cleanupConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
retentionDays: z.number().default(604800), // 7 days in seconds
|
||||
deleteFromGitea: z.boolean().default(false),
|
||||
deleteIfNotInGitHub: z.boolean().default(true),
|
||||
protectedRepos: z.array(z.string()).default([]),
|
||||
dryRun: z.boolean().default(false),
|
||||
orphanedRepoAction: z
|
||||
.enum(["skip", "archive", "delete"])
|
||||
.default("archive"),
|
||||
batchSize: z.number().default(10),
|
||||
pauseBetweenDeletes: z.number().default(2000),
|
||||
lastRun: z.coerce.date().optional(),
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const configSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
name: z.string(),
|
||||
isActive: z.boolean().default(true),
|
||||
githubConfig: githubConfigSchema,
|
||||
giteaConfig: giteaConfigSchema,
|
||||
include: z.array(z.string()).default(["*"]),
|
||||
exclude: z.array(z.string()).default([]),
|
||||
scheduleConfig: scheduleConfigSchema,
|
||||
cleanupConfig: cleanupConfigSchema,
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const repositorySchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
configId: z.string(),
|
||||
name: z.string(),
|
||||
fullName: z.string(),
|
||||
normalizedFullName: z.string(),
|
||||
url: z.url(),
|
||||
cloneUrl: z.url(),
|
||||
owner: z.string(),
|
||||
organization: z.string().optional().nullable(),
|
||||
mirroredLocation: z.string().default(""),
|
||||
isPrivate: z.boolean().default(false),
|
||||
isForked: z.boolean().default(false),
|
||||
forkedFrom: z.string().optional().nullable(),
|
||||
hasIssues: z.boolean().default(false),
|
||||
isStarred: z.boolean().default(false),
|
||||
isArchived: z.boolean().default(false),
|
||||
size: z.number().default(0),
|
||||
hasLFS: z.boolean().default(false),
|
||||
hasSubmodules: z.boolean().default(false),
|
||||
language: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
defaultBranch: z.string(),
|
||||
visibility: z.enum(["public", "private", "internal"]).default("public"),
|
||||
status: z
|
||||
.enum([
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
])
|
||||
.default("imported"),
|
||||
lastMirrored: z.coerce.date().optional().nullable(),
|
||||
errorMessage: z.string().optional().nullable(),
|
||||
destinationOrg: z.string().optional().nullable(),
|
||||
metadata: z.string().optional().nullable(), // JSON string for metadata sync state
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const mirrorJobSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
repositoryId: z.string().optional().nullable(),
|
||||
repositoryName: z.string().optional().nullable(),
|
||||
organizationId: z.string().optional().nullable(),
|
||||
organizationName: z.string().optional().nullable(),
|
||||
details: z.string().optional().nullable(),
|
||||
status: z
|
||||
.enum([
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
])
|
||||
.default("imported"),
|
||||
message: z.string(),
|
||||
timestamp: z.coerce.date(),
|
||||
jobType: z.enum(["mirror", "cleanup", "import"]).default("mirror"),
|
||||
batchId: z.string().optional().nullable(),
|
||||
totalItems: z.number().optional().nullable(),
|
||||
completedItems: z.number().default(0),
|
||||
itemIds: z.array(z.string()).optional().nullable(),
|
||||
completedItemIds: z.array(z.string()).default([]),
|
||||
inProgress: z.boolean().default(false),
|
||||
startedAt: z.coerce.date().optional().nullable(),
|
||||
completedAt: z.coerce.date().optional().nullable(),
|
||||
lastCheckpoint: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
export const organizationSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
configId: z.string(),
|
||||
name: z.string(),
|
||||
normalizedName: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
|
||||
isIncluded: z.boolean().default(true),
|
||||
destinationOrg: z.string().optional().nullable(),
|
||||
status: z
|
||||
.enum([
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
])
|
||||
.default("imported"),
|
||||
lastMirrored: z.coerce.date().optional().nullable(),
|
||||
errorMessage: z.string().optional().nullable(),
|
||||
repositoryCount: z.number().default(0),
|
||||
publicRepositoryCount: z.number().optional(),
|
||||
privateRepositoryCount: z.number().optional(),
|
||||
forkRepositoryCount: z.number().optional(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const eventSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
channel: z.string(),
|
||||
payload: z.any(),
|
||||
read: z.boolean().default(false),
|
||||
createdAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
// ===== Drizzle Table Definitions =====
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
// Custom fields
|
||||
username: text("username"),
|
||||
}, (_table) => []);
|
||||
|
||||
export const events = sqliteTable("events", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
channel: text("channel").notNull(),
|
||||
payload: text("payload", { mode: "json" }).notNull(),
|
||||
read: integer("read", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_events_user_channel").on(table.userId, table.channel),
|
||||
index("idx_events_created_at").on(table.createdAt),
|
||||
index("idx_events_read").on(table.read),
|
||||
]);
|
||||
|
||||
export const configs = sqliteTable("configs", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
githubConfig: text("github_config", { mode: "json" })
|
||||
.$type<z.infer<typeof githubConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
giteaConfig: text("gitea_config", { mode: "json" })
|
||||
.$type<z.infer<typeof giteaConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
include: text("include", { mode: "json" })
|
||||
.$type<string[]>()
|
||||
.notNull()
|
||||
.default(sql`'["*"]'`),
|
||||
|
||||
exclude: text("exclude", { mode: "json" })
|
||||
.$type<string[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'`),
|
||||
|
||||
scheduleConfig: text("schedule_config", { mode: "json" })
|
||||
.$type<z.infer<typeof scheduleConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
cleanupConfig: text("cleanup_config", { mode: "json" })
|
||||
.$type<z.infer<typeof cleanupConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (_table) => []);
|
||||
|
||||
export const repositories = sqliteTable("repositories", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
configId: text("config_id")
|
||||
.notNull()
|
||||
.references(() => configs.id),
|
||||
name: text("name").notNull(),
|
||||
fullName: text("full_name").notNull(),
|
||||
normalizedFullName: text("normalized_full_name").notNull(),
|
||||
url: text("url").notNull(),
|
||||
cloneUrl: text("clone_url").notNull(),
|
||||
owner: text("owner").notNull(),
|
||||
organization: text("organization"),
|
||||
mirroredLocation: text("mirrored_location").default(""),
|
||||
|
||||
isPrivate: integer("is_private", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false),
|
||||
forkedFrom: text("forked_from"),
|
||||
|
||||
hasIssues: integer("has_issues", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isStarred: integer("is_starred", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isArchived: integer("is_archived", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
size: integer("size").notNull().default(0),
|
||||
hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false),
|
||||
hasSubmodules: integer("has_submodules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
language: text("language"),
|
||||
description: text("description"),
|
||||
defaultBranch: text("default_branch").notNull(),
|
||||
visibility: text("visibility").notNull().default("public"),
|
||||
|
||||
status: text("status").notNull().default("imported"),
|
||||
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
destinationOrg: text("destination_org"),
|
||||
|
||||
metadata: text("metadata"), // JSON string storing metadata sync state (issues, PRs, releases, etc.)
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_repositories_user_id").on(table.userId),
|
||||
index("idx_repositories_config_id").on(table.configId),
|
||||
index("idx_repositories_status").on(table.status),
|
||||
index("idx_repositories_owner").on(table.owner),
|
||||
index("idx_repositories_organization").on(table.organization),
|
||||
index("idx_repositories_is_fork").on(table.isForked),
|
||||
index("idx_repositories_is_starred").on(table.isStarred),
|
||||
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
|
||||
uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName),
|
||||
]);
|
||||
|
||||
export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
repositoryId: text("repository_id"),
|
||||
repositoryName: text("repository_name"),
|
||||
organizationId: text("organization_id"),
|
||||
organizationName: text("organization_name"),
|
||||
details: text("details"),
|
||||
status: text("status").notNull().default("imported"),
|
||||
message: text("message").notNull(),
|
||||
timestamp: integer("timestamp", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
|
||||
// Job resilience fields
|
||||
jobType: text("job_type").notNull().default("mirror"),
|
||||
batchId: text("batch_id"),
|
||||
totalItems: integer("total_items"),
|
||||
completedItems: integer("completed_items").default(0),
|
||||
itemIds: text("item_ids", { mode: "json" }).$type<string[]>(),
|
||||
completedItemIds: text("completed_item_ids", { mode: "json" })
|
||||
.$type<string[]>()
|
||||
.default(sql`'[]'`),
|
||||
inProgress: integer("in_progress", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
startedAt: integer("started_at", { mode: "timestamp" }),
|
||||
completedAt: integer("completed_at", { mode: "timestamp" }),
|
||||
lastCheckpoint: integer("last_checkpoint", { mode: "timestamp" }),
|
||||
}, (table) => [
|
||||
index("idx_mirror_jobs_user_id").on(table.userId),
|
||||
index("idx_mirror_jobs_batch_id").on(table.batchId),
|
||||
index("idx_mirror_jobs_in_progress").on(table.inProgress),
|
||||
index("idx_mirror_jobs_job_type").on(table.jobType),
|
||||
index("idx_mirror_jobs_timestamp").on(table.timestamp),
|
||||
]);
|
||||
|
||||
export const organizations = sqliteTable("organizations", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
configId: text("config_id")
|
||||
.notNull()
|
||||
.references(() => configs.id),
|
||||
name: text("name").notNull(),
|
||||
normalizedName: text("normalized_name").notNull(),
|
||||
|
||||
avatarUrl: text("avatar_url").notNull(),
|
||||
|
||||
membershipRole: text("membership_role").notNull().default("member"),
|
||||
|
||||
isIncluded: integer("is_included", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
|
||||
destinationOrg: text("destination_org"),
|
||||
|
||||
status: text("status").notNull().default("imported"),
|
||||
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
repositoryCount: integer("repository_count").notNull().default(0),
|
||||
publicRepositoryCount: integer("public_repository_count"),
|
||||
privateRepositoryCount: integer("private_repository_count"),
|
||||
forkRepositoryCount: integer("fork_repository_count"),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_organizations_user_id").on(table.userId),
|
||||
index("idx_organizations_config_id").on(table.configId),
|
||||
index("idx_organizations_status").on(table.status),
|
||||
index("idx_organizations_is_included").on(table.isIncluded),
|
||||
uniqueIndex("uniq_organizations_user_normalized_name").on(table.userId, table.normalizedName),
|
||||
]);
|
||||
|
||||
// ===== Better Auth Tables =====
|
||||
|
||||
// Sessions table
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
token: text("token").notNull().unique(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_sessions_user_id").on(table.userId),
|
||||
index("idx_sessions_token").on(table.token),
|
||||
index("idx_sessions_expires_at").on(table.expiresAt),
|
||||
]);
|
||||
|
||||
// Accounts table (for OAuth providers and credentials)
|
||||
export const accounts = sqliteTable("accounts", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
providerId: text("provider_id").notNull(),
|
||||
providerUserId: text("provider_user_id"), // Make nullable for email/password auth
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
scope: text("scope"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
password: text("password"), // For credential provider
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_accounts_account_id").on(table.accountId),
|
||||
index("idx_accounts_user_id").on(table.userId),
|
||||
index("idx_accounts_provider").on(table.providerId, table.providerUserId),
|
||||
]);
|
||||
|
||||
// Verification tokens table
|
||||
export const verificationTokens = sqliteTable("verification_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
token: text("token").notNull().unique(),
|
||||
identifier: text("identifier").notNull(),
|
||||
type: text("type").notNull(), // email, password-reset, etc
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_verification_tokens_token").on(table.token),
|
||||
index("idx_verification_tokens_identifier").on(table.identifier),
|
||||
]);
|
||||
|
||||
// Verifications table (for Better Auth)
|
||||
export const verifications = sqliteTable("verifications", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_verifications_identifier").on(table.identifier),
|
||||
]);
|
||||
|
||||
// ===== OIDC Provider Tables =====
|
||||
|
||||
// OAuth Applications table
|
||||
export const oauthApplications = sqliteTable("oauth_applications", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull().unique(),
|
||||
clientSecret: text("client_secret").notNull(),
|
||||
name: text("name").notNull(),
|
||||
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
|
||||
metadata: text("metadata"), // JSON string
|
||||
type: text("type").notNull(), // web, mobile, etc
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
userId: text("user_id"), // Optional - owner of the application
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
index("idx_oauth_applications_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Access Tokens table
|
||||
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
]);
|
||||
|
||||
// OAuth Consent table
|
||||
export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
clientId: text("client_id").notNull(),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_consent_user_id").on(table.userId),
|
||||
index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
]);
|
||||
|
||||
// ===== SSO Provider Tables =====
|
||||
|
||||
// SSO Providers table
|
||||
export const ssoProviders = sqliteTable("sso_providers", {
|
||||
id: text("id").primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
||||
userId: text("user_id").notNull(), // Admin who created this provider
|
||||
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
||||
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_sso_providers_provider_id").on(table.providerId),
|
||||
index("idx_sso_providers_domain").on(table.domain),
|
||||
index("idx_sso_providers_issuer").on(table.issuer),
|
||||
]);
|
||||
|
||||
// ===== Rate Limit Tracking =====
|
||||
|
||||
export const rateLimitSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
provider: z.enum(["github", "gitea"]).default("github"),
|
||||
limit: z.number(),
|
||||
remaining: z.number(),
|
||||
used: z.number(),
|
||||
reset: z.coerce.date(),
|
||||
retryAfter: z.number().optional(), // seconds to wait
|
||||
status: z.enum(["ok", "warning", "limited", "exceeded"]).default("ok"),
|
||||
lastChecked: z.coerce.date(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const rateLimits = sqliteTable("rate_limits", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
provider: text("provider").notNull().default("github"),
|
||||
limit: integer("limit").notNull(),
|
||||
remaining: integer("remaining").notNull(),
|
||||
used: integer("used").notNull(),
|
||||
reset: integer("reset", { mode: "timestamp" }).notNull(),
|
||||
retryAfter: integer("retry_after"), // seconds to wait
|
||||
status: text("status").notNull().default("ok"),
|
||||
lastChecked: integer("last_checked", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_rate_limits_user_provider").on(table.userId, table.provider),
|
||||
index("idx_rate_limits_status").on(table.status),
|
||||
]);
|
||||
|
||||
// Export type definitions
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
export type Repository = z.infer<typeof repositorySchema>;
|
||||
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
|
||||
export type Organization = z.infer<typeof organizationSchema>;
|
||||
export type Event = z.infer<typeof eventSchema>;
|
||||
export type RateLimit = z.infer<typeof rateLimitSchema>;
|
||||
22
Divers/gitea-mirror/src/lib/deployment-mode.ts
Normal file
22
Divers/gitea-mirror/src/lib/deployment-mode.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Deployment mode utilities
|
||||
* Supports both self-hosted and hosted versions
|
||||
*/
|
||||
|
||||
export const DEPLOYMENT_MODE = process.env.DEPLOYMENT_MODE || 'selfhosted';
|
||||
|
||||
export const isSelfHostedMode = () => DEPLOYMENT_MODE === 'selfhosted';
|
||||
export const isHostedMode = () => DEPLOYMENT_MODE === 'hosted';
|
||||
|
||||
/**
|
||||
* Feature flags for self-hosted version
|
||||
*/
|
||||
export const features = {
|
||||
// Core features available
|
||||
githubSync: true,
|
||||
giteaMirroring: true,
|
||||
scheduling: true,
|
||||
multiUser: true,
|
||||
githubSponsors: true,
|
||||
unlimitedRepos: true,
|
||||
};
|
||||
378
Divers/gitea-mirror/src/lib/env-config-loader.ts
Normal file
378
Divers/gitea-mirror/src/lib/env-config-loader.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Environment variable configuration loader
|
||||
* Loads configuration from environment variables and populates the database
|
||||
*/
|
||||
|
||||
import { db, configs, users } from '@/lib/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { encrypt } from '@/lib/utils/encryption';
|
||||
|
||||
interface EnvConfig {
|
||||
github: {
|
||||
username?: string;
|
||||
token?: string;
|
||||
type?: 'personal' | 'organization';
|
||||
privateRepositories?: boolean;
|
||||
publicRepositories?: boolean;
|
||||
mirrorStarred?: boolean;
|
||||
skipForks?: boolean;
|
||||
includeArchived?: boolean;
|
||||
mirrorOrganizations?: boolean;
|
||||
preserveOrgStructure?: boolean;
|
||||
onlyMirrorOrgs?: boolean;
|
||||
starredCodeOnly?: boolean;
|
||||
starredReposOrg?: string;
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
gitea: {
|
||||
url?: string;
|
||||
username?: string;
|
||||
token?: string;
|
||||
organization?: string;
|
||||
visibility?: 'public' | 'private' | 'limited' | 'default';
|
||||
mirrorInterval?: string;
|
||||
lfs?: boolean;
|
||||
createOrg?: boolean;
|
||||
templateOwner?: string;
|
||||
templateRepo?: string;
|
||||
addTopics?: boolean;
|
||||
topicPrefix?: string;
|
||||
preserveVisibility?: boolean;
|
||||
forkStrategy?: 'skip' | 'reference' | 'full-copy';
|
||||
};
|
||||
mirror: {
|
||||
mirrorIssues?: boolean;
|
||||
mirrorWiki?: boolean;
|
||||
mirrorReleases?: boolean;
|
||||
mirrorPullRequests?: boolean;
|
||||
mirrorLabels?: boolean;
|
||||
mirrorMilestones?: boolean;
|
||||
mirrorMetadata?: boolean;
|
||||
releaseLimit?: number;
|
||||
issueConcurrency?: number;
|
||||
pullRequestConcurrency?: number;
|
||||
};
|
||||
schedule: {
|
||||
enabled?: boolean;
|
||||
interval?: string;
|
||||
concurrent?: boolean;
|
||||
batchSize?: number;
|
||||
pauseBetweenBatches?: number;
|
||||
retryAttempts?: number;
|
||||
retryDelay?: number;
|
||||
timeout?: number;
|
||||
autoRetry?: boolean;
|
||||
cleanupBeforeMirror?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
notifyOnSuccess?: boolean;
|
||||
logLevel?: 'error' | 'warn' | 'info' | 'debug';
|
||||
timezone?: string;
|
||||
onlyMirrorUpdated?: boolean;
|
||||
updateInterval?: number;
|
||||
skipRecentlyMirrored?: boolean;
|
||||
recentThreshold?: number;
|
||||
autoImport?: boolean;
|
||||
autoMirror?: boolean;
|
||||
};
|
||||
cleanup: {
|
||||
enabled?: boolean;
|
||||
retentionDays?: number;
|
||||
deleteFromGitea?: boolean;
|
||||
deleteIfNotInGitHub?: boolean;
|
||||
protectedRepos?: string[];
|
||||
dryRun?: boolean;
|
||||
orphanedRepoAction?: 'skip' | 'archive' | 'delete';
|
||||
batchSize?: number;
|
||||
pauseBetweenDeletes?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse environment variables into configuration object
|
||||
*/
|
||||
function parseEnvConfig(): EnvConfig {
|
||||
// Parse protected repos from comma-separated string
|
||||
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
|
||||
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
github: {
|
||||
username: process.env.GITHUB_USERNAME,
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
type: process.env.GITHUB_TYPE as 'personal' | 'organization',
|
||||
privateRepositories: process.env.PRIVATE_REPOSITORIES === 'true',
|
||||
publicRepositories: process.env.PUBLIC_REPOSITORIES === 'true',
|
||||
mirrorStarred: process.env.MIRROR_STARRED === 'true',
|
||||
skipForks: process.env.SKIP_FORKS === 'true',
|
||||
includeArchived: process.env.INCLUDE_ARCHIVED === 'true',
|
||||
mirrorOrganizations: process.env.MIRROR_ORGANIZATIONS === 'true',
|
||||
preserveOrgStructure: process.env.PRESERVE_ORG_STRUCTURE === 'true',
|
||||
onlyMirrorOrgs: process.env.ONLY_MIRROR_ORGS === 'true',
|
||||
starredCodeOnly: process.env.SKIP_STARRED_ISSUES === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL,
|
||||
username: process.env.GITEA_USERNAME,
|
||||
token: process.env.GITEA_TOKEN,
|
||||
organization: process.env.GITEA_ORGANIZATION,
|
||||
visibility: process.env.GITEA_ORG_VISIBILITY as 'public' | 'private' | 'limited' | 'default',
|
||||
mirrorInterval: process.env.GITEA_MIRROR_INTERVAL,
|
||||
lfs: process.env.GITEA_LFS === 'true',
|
||||
createOrg: process.env.GITEA_CREATE_ORG === 'true',
|
||||
templateOwner: process.env.GITEA_TEMPLATE_OWNER,
|
||||
templateRepo: process.env.GITEA_TEMPLATE_REPO,
|
||||
addTopics: process.env.GITEA_ADD_TOPICS === 'true',
|
||||
topicPrefix: process.env.GITEA_TOPIC_PREFIX,
|
||||
preserveVisibility: process.env.GITEA_PRESERVE_VISIBILITY === 'true',
|
||||
forkStrategy: process.env.GITEA_FORK_STRATEGY as 'skip' | 'reference' | 'full-copy',
|
||||
},
|
||||
mirror: {
|
||||
mirrorIssues: process.env.MIRROR_ISSUES === 'true',
|
||||
mirrorWiki: process.env.MIRROR_WIKI === 'true',
|
||||
mirrorReleases: process.env.MIRROR_RELEASES === 'true',
|
||||
mirrorPullRequests: process.env.MIRROR_PULL_REQUESTS === 'true',
|
||||
mirrorLabels: process.env.MIRROR_LABELS === 'true',
|
||||
mirrorMilestones: process.env.MIRROR_MILESTONES === 'true',
|
||||
mirrorMetadata: process.env.MIRROR_METADATA === 'true',
|
||||
releaseLimit: process.env.RELEASE_LIMIT ? parseInt(process.env.RELEASE_LIMIT, 10) : undefined,
|
||||
issueConcurrency: process.env.MIRROR_ISSUE_CONCURRENCY ? parseInt(process.env.MIRROR_ISSUE_CONCURRENCY, 10) : undefined,
|
||||
pullRequestConcurrency: process.env.MIRROR_PULL_REQUEST_CONCURRENCY ? parseInt(process.env.MIRROR_PULL_REQUEST_CONCURRENCY, 10) : undefined,
|
||||
},
|
||||
schedule: {
|
||||
enabled: process.env.SCHEDULE_ENABLED === 'true' ||
|
||||
!!process.env.GITEA_MIRROR_INTERVAL ||
|
||||
!!process.env.SCHEDULE_INTERVAL ||
|
||||
!!process.env.DELAY, // Auto-enable if any interval is specified
|
||||
interval: process.env.SCHEDULE_INTERVAL || process.env.GITEA_MIRROR_INTERVAL || process.env.DELAY, // Support GITEA_MIRROR_INTERVAL, SCHEDULE_INTERVAL, and old DELAY
|
||||
concurrent: process.env.SCHEDULE_CONCURRENT === 'true',
|
||||
batchSize: process.env.SCHEDULE_BATCH_SIZE ? parseInt(process.env.SCHEDULE_BATCH_SIZE, 10) : undefined,
|
||||
pauseBetweenBatches: process.env.SCHEDULE_PAUSE_BETWEEN_BATCHES ? parseInt(process.env.SCHEDULE_PAUSE_BETWEEN_BATCHES, 10) : undefined,
|
||||
retryAttempts: process.env.SCHEDULE_RETRY_ATTEMPTS ? parseInt(process.env.SCHEDULE_RETRY_ATTEMPTS, 10) : undefined,
|
||||
retryDelay: process.env.SCHEDULE_RETRY_DELAY ? parseInt(process.env.SCHEDULE_RETRY_DELAY, 10) : undefined,
|
||||
timeout: process.env.SCHEDULE_TIMEOUT ? parseInt(process.env.SCHEDULE_TIMEOUT, 10) : undefined,
|
||||
autoRetry: process.env.SCHEDULE_AUTO_RETRY === 'true',
|
||||
cleanupBeforeMirror: process.env.SCHEDULE_CLEANUP_BEFORE_MIRROR === 'true',
|
||||
notifyOnFailure: process.env.SCHEDULE_NOTIFY_ON_FAILURE === 'true',
|
||||
notifyOnSuccess: process.env.SCHEDULE_NOTIFY_ON_SUCCESS === 'true',
|
||||
logLevel: process.env.SCHEDULE_LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug',
|
||||
timezone: process.env.SCHEDULE_TIMEZONE,
|
||||
onlyMirrorUpdated: process.env.SCHEDULE_ONLY_MIRROR_UPDATED === 'true',
|
||||
updateInterval: process.env.SCHEDULE_UPDATE_INTERVAL ? parseInt(process.env.SCHEDULE_UPDATE_INTERVAL, 10) : undefined,
|
||||
skipRecentlyMirrored: process.env.SCHEDULE_SKIP_RECENTLY_MIRRORED === 'true',
|
||||
recentThreshold: process.env.SCHEDULE_RECENT_THRESHOLD ? parseInt(process.env.SCHEDULE_RECENT_THRESHOLD, 10) : undefined,
|
||||
autoImport: process.env.AUTO_IMPORT_REPOS !== 'false',
|
||||
autoMirror: process.env.AUTO_MIRROR_REPOS === 'true',
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.CLEANUP_ENABLED === 'true' ||
|
||||
process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true', // Auto-enable if deleteIfNotInGitHub is enabled
|
||||
retentionDays: process.env.CLEANUP_RETENTION_DAYS ? parseInt(process.env.CLEANUP_RETENTION_DAYS, 10) : undefined,
|
||||
deleteFromGitea: process.env.CLEANUP_DELETE_FROM_GITEA === 'true',
|
||||
deleteIfNotInGitHub: process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true',
|
||||
protectedRepos,
|
||||
dryRun: process.env.CLEANUP_DRY_RUN === 'true' ? true : process.env.CLEANUP_DRY_RUN === 'false' ? false : false,
|
||||
orphanedRepoAction: process.env.CLEANUP_ORPHANED_REPO_ACTION as 'skip' | 'archive' | 'delete',
|
||||
batchSize: process.env.CLEANUP_BATCH_SIZE ? parseInt(process.env.CLEANUP_BATCH_SIZE, 10) : undefined,
|
||||
pauseBetweenDeletes: process.env.CLEANUP_PAUSE_BETWEEN_DELETES ? parseInt(process.env.CLEANUP_PAUSE_BETWEEN_DELETES, 10) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if environment configuration is available
|
||||
*/
|
||||
function hasEnvConfig(envConfig: EnvConfig): boolean {
|
||||
// Check if any GitHub or Gitea config is provided
|
||||
return !!(
|
||||
envConfig.github.username ||
|
||||
envConfig.github.token ||
|
||||
envConfig.gitea.url ||
|
||||
envConfig.gitea.username ||
|
||||
envConfig.gitea.token
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration from environment variables
|
||||
* This function runs on application startup and populates the database
|
||||
* with configuration from environment variables if available
|
||||
*/
|
||||
export async function initializeConfigFromEnv(): Promise<void> {
|
||||
try {
|
||||
const envConfig = parseEnvConfig();
|
||||
|
||||
// Skip if no environment config is provided
|
||||
if (!hasEnvConfig(envConfig)) {
|
||||
console.log('[ENV Config Loader] No environment configuration found, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ENV Config Loader] Found environment configuration, initializing...');
|
||||
|
||||
// Get the first user (admin user)
|
||||
const firstUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.limit(1);
|
||||
|
||||
if (firstUser.length === 0) {
|
||||
console.log('[ENV Config Loader] No users found, skipping configuration initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = firstUser[0].id;
|
||||
|
||||
// Check if config already exists for this user
|
||||
const existingConfig = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// Determine mirror strategy based on environment variables or use explicit value
|
||||
let mirrorStrategy: 'preserve' | 'single-org' | 'flat-user' | 'mixed' = 'preserve';
|
||||
if (envConfig.github.mirrorStrategy) {
|
||||
mirrorStrategy = envConfig.github.mirrorStrategy;
|
||||
} else if (envConfig.github.preserveOrgStructure === false && envConfig.gitea.organization) {
|
||||
mirrorStrategy = 'single-org';
|
||||
} else if (envConfig.github.preserveOrgStructure === true) {
|
||||
mirrorStrategy = 'preserve';
|
||||
}
|
||||
|
||||
// Build GitHub config
|
||||
const githubConfig = {
|
||||
owner: envConfig.github.username || existingConfig?.[0]?.githubConfig?.owner || '',
|
||||
type: envConfig.github.type || existingConfig?.[0]?.githubConfig?.type || 'personal',
|
||||
token: envConfig.github.token ? encrypt(envConfig.github.token) : existingConfig?.[0]?.githubConfig?.token || '',
|
||||
includeStarred: envConfig.github.mirrorStarred ?? existingConfig?.[0]?.githubConfig?.includeStarred ?? false,
|
||||
includeForks: !(envConfig.github.skipForks ?? false),
|
||||
skipForks: envConfig.github.skipForks ?? existingConfig?.[0]?.githubConfig?.skipForks ?? false,
|
||||
includeArchived: envConfig.github.includeArchived ?? existingConfig?.[0]?.githubConfig?.includeArchived ?? false,
|
||||
includePrivate: envConfig.github.privateRepositories ?? existingConfig?.[0]?.githubConfig?.includePrivate ?? false,
|
||||
includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true,
|
||||
includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []),
|
||||
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
|
||||
mirrorStrategy,
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
const giteaConfig = {
|
||||
url: envConfig.gitea.url || existingConfig?.[0]?.giteaConfig?.url || '',
|
||||
token: envConfig.gitea.token ? encrypt(envConfig.gitea.token) : existingConfig?.[0]?.giteaConfig?.token || '',
|
||||
defaultOwner: envConfig.gitea.username || existingConfig?.[0]?.giteaConfig?.defaultOwner || '',
|
||||
organization: envConfig.gitea.organization || existingConfig?.[0]?.giteaConfig?.organization || undefined,
|
||||
preserveOrgStructure: mirrorStrategy === 'preserve' || mirrorStrategy === 'mixed',
|
||||
mirrorInterval: envConfig.gitea.mirrorInterval || existingConfig?.[0]?.giteaConfig?.mirrorInterval || '8h',
|
||||
lfs: envConfig.gitea.lfs ?? existingConfig?.[0]?.giteaConfig?.lfs ?? false,
|
||||
wiki: envConfig.mirror.mirrorWiki ?? existingConfig?.[0]?.giteaConfig?.wiki ?? false,
|
||||
visibility: envConfig.gitea.visibility || existingConfig?.[0]?.giteaConfig?.visibility || 'public',
|
||||
createOrg: envConfig.gitea.createOrg ?? existingConfig?.[0]?.giteaConfig?.createOrg ?? true,
|
||||
templateOwner: envConfig.gitea.templateOwner || existingConfig?.[0]?.giteaConfig?.templateOwner || undefined,
|
||||
templateRepo: envConfig.gitea.templateRepo || existingConfig?.[0]?.giteaConfig?.templateRepo || undefined,
|
||||
addTopics: envConfig.gitea.addTopics ?? existingConfig?.[0]?.giteaConfig?.addTopics ?? true,
|
||||
topicPrefix: envConfig.gitea.topicPrefix || existingConfig?.[0]?.giteaConfig?.topicPrefix || undefined,
|
||||
preserveVisibility: envConfig.gitea.preserveVisibility ?? existingConfig?.[0]?.giteaConfig?.preserveVisibility ?? false,
|
||||
forkStrategy: envConfig.gitea.forkStrategy || existingConfig?.[0]?.giteaConfig?.forkStrategy || 'reference',
|
||||
// Mirror metadata options
|
||||
mirrorReleases: envConfig.mirror.mirrorReleases ?? existingConfig?.[0]?.giteaConfig?.mirrorReleases ?? false,
|
||||
releaseLimit: envConfig.mirror.releaseLimit ?? existingConfig?.[0]?.giteaConfig?.releaseLimit ?? 10,
|
||||
issueConcurrency: envConfig.mirror.issueConcurrency && envConfig.mirror.issueConcurrency > 0
|
||||
? envConfig.mirror.issueConcurrency
|
||||
: existingConfig?.[0]?.giteaConfig?.issueConcurrency ?? 3,
|
||||
pullRequestConcurrency: envConfig.mirror.pullRequestConcurrency && envConfig.mirror.pullRequestConcurrency > 0
|
||||
? envConfig.mirror.pullRequestConcurrency
|
||||
: existingConfig?.[0]?.giteaConfig?.pullRequestConcurrency ?? 5,
|
||||
mirrorMetadata: envConfig.mirror.mirrorMetadata ?? (envConfig.mirror.mirrorIssues || envConfig.mirror.mirrorPullRequests || envConfig.mirror.mirrorLabels || envConfig.mirror.mirrorMilestones) ?? existingConfig?.[0]?.giteaConfig?.mirrorMetadata ?? false,
|
||||
mirrorIssues: envConfig.mirror.mirrorIssues ?? existingConfig?.[0]?.giteaConfig?.mirrorIssues ?? false,
|
||||
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
|
||||
mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false,
|
||||
mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false,
|
||||
};
|
||||
|
||||
// Build schedule config with support for interval as string or number
|
||||
const scheduleInterval = envConfig.schedule.interval || (existingConfig?.[0]?.scheduleConfig?.interval ?? '3600');
|
||||
const scheduleConfig = {
|
||||
enabled: envConfig.schedule.enabled ?? existingConfig?.[0]?.scheduleConfig?.enabled ?? false,
|
||||
interval: scheduleInterval,
|
||||
concurrent: envConfig.schedule.concurrent ?? existingConfig?.[0]?.scheduleConfig?.concurrent ?? false,
|
||||
batchSize: envConfig.schedule.batchSize ?? existingConfig?.[0]?.scheduleConfig?.batchSize ?? 10,
|
||||
pauseBetweenBatches: envConfig.schedule.pauseBetweenBatches ?? existingConfig?.[0]?.scheduleConfig?.pauseBetweenBatches ?? 5000,
|
||||
retryAttempts: envConfig.schedule.retryAttempts ?? existingConfig?.[0]?.scheduleConfig?.retryAttempts ?? 3,
|
||||
retryDelay: envConfig.schedule.retryDelay ?? existingConfig?.[0]?.scheduleConfig?.retryDelay ?? 60000,
|
||||
timeout: envConfig.schedule.timeout ?? existingConfig?.[0]?.scheduleConfig?.timeout ?? 3600000,
|
||||
autoRetry: envConfig.schedule.autoRetry ?? existingConfig?.[0]?.scheduleConfig?.autoRetry ?? true,
|
||||
cleanupBeforeMirror: envConfig.schedule.cleanupBeforeMirror ?? existingConfig?.[0]?.scheduleConfig?.cleanupBeforeMirror ?? false,
|
||||
notifyOnFailure: envConfig.schedule.notifyOnFailure ?? existingConfig?.[0]?.scheduleConfig?.notifyOnFailure ?? true,
|
||||
notifyOnSuccess: envConfig.schedule.notifyOnSuccess ?? existingConfig?.[0]?.scheduleConfig?.notifyOnSuccess ?? false,
|
||||
logLevel: envConfig.schedule.logLevel || existingConfig?.[0]?.scheduleConfig?.logLevel || 'info',
|
||||
timezone: envConfig.schedule.timezone || existingConfig?.[0]?.scheduleConfig?.timezone || 'UTC',
|
||||
onlyMirrorUpdated: envConfig.schedule.onlyMirrorUpdated ?? existingConfig?.[0]?.scheduleConfig?.onlyMirrorUpdated ?? false,
|
||||
updateInterval: envConfig.schedule.updateInterval ?? existingConfig?.[0]?.scheduleConfig?.updateInterval ?? 86400000,
|
||||
skipRecentlyMirrored: envConfig.schedule.skipRecentlyMirrored ?? existingConfig?.[0]?.scheduleConfig?.skipRecentlyMirrored ?? true,
|
||||
recentThreshold: envConfig.schedule.recentThreshold ?? existingConfig?.[0]?.scheduleConfig?.recentThreshold ?? 3600000,
|
||||
autoImport: envConfig.schedule.autoImport ?? existingConfig?.[0]?.scheduleConfig?.autoImport ?? true,
|
||||
autoMirror: envConfig.schedule.autoMirror ?? existingConfig?.[0]?.scheduleConfig?.autoMirror ?? false,
|
||||
lastRun: existingConfig?.[0]?.scheduleConfig?.lastRun || undefined,
|
||||
nextRun: existingConfig?.[0]?.scheduleConfig?.nextRun || undefined,
|
||||
};
|
||||
|
||||
// Build cleanup config
|
||||
const cleanupConfig = {
|
||||
enabled: envConfig.cleanup.enabled ?? existingConfig?.[0]?.cleanupConfig?.enabled ?? false,
|
||||
retentionDays: envConfig.cleanup.retentionDays ? envConfig.cleanup.retentionDays * 86400 : existingConfig?.[0]?.cleanupConfig?.retentionDays ?? 604800, // Convert days to seconds
|
||||
deleteFromGitea: envConfig.cleanup.deleteFromGitea ?? existingConfig?.[0]?.cleanupConfig?.deleteFromGitea ?? false,
|
||||
deleteIfNotInGitHub: envConfig.cleanup.deleteIfNotInGitHub ?? existingConfig?.[0]?.cleanupConfig?.deleteIfNotInGitHub ?? true,
|
||||
protectedRepos: envConfig.cleanup.protectedRepos ?? existingConfig?.[0]?.cleanupConfig?.protectedRepos ?? [],
|
||||
dryRun: envConfig.cleanup.dryRun ?? existingConfig?.[0]?.cleanupConfig?.dryRun ?? true,
|
||||
orphanedRepoAction: envConfig.cleanup.orphanedRepoAction || existingConfig?.[0]?.cleanupConfig?.orphanedRepoAction || 'archive',
|
||||
batchSize: envConfig.cleanup.batchSize ?? existingConfig?.[0]?.cleanupConfig?.batchSize ?? 10,
|
||||
pauseBetweenDeletes: envConfig.cleanup.pauseBetweenDeletes ?? existingConfig?.[0]?.cleanupConfig?.pauseBetweenDeletes ?? 2000,
|
||||
lastRun: existingConfig?.[0]?.cleanupConfig?.lastRun || undefined,
|
||||
nextRun: existingConfig?.[0]?.cleanupConfig?.nextRun || undefined,
|
||||
};
|
||||
|
||||
if (existingConfig.length > 0) {
|
||||
// Update existing config
|
||||
console.log('[ENV Config Loader] Updating existing configuration with environment variables');
|
||||
await db
|
||||
.update(configs)
|
||||
.set({
|
||||
githubConfig,
|
||||
giteaConfig,
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(configs.id, existingConfig[0].id));
|
||||
} else {
|
||||
// Create new config
|
||||
console.log('[ENV Config Loader] Creating new configuration from environment variables');
|
||||
const configId = uuidv4();
|
||||
await db.insert(configs).values({
|
||||
id: configId,
|
||||
userId,
|
||||
name: 'Environment Configuration',
|
||||
isActive: true,
|
||||
githubConfig,
|
||||
giteaConfig,
|
||||
include: [],
|
||||
exclude: [],
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[ENV Config Loader] Configuration initialized successfully from environment variables');
|
||||
} catch (error) {
|
||||
console.error('[ENV Config Loader] Failed to initialize configuration from environment:', error);
|
||||
// Don't throw - this is a non-critical initialization
|
||||
}
|
||||
}
|
||||
258
Divers/gitea-mirror/src/lib/events.ts
Normal file
258
Divers/gitea-mirror/src/lib/events.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { db, events } from "./db";
|
||||
import { eq, and, gt, lt, inArray } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Publishes an event to a specific channel for a user
|
||||
* This replaces Redis pub/sub with SQLite storage
|
||||
*/
|
||||
export async function publishEvent({
|
||||
userId,
|
||||
channel,
|
||||
payload,
|
||||
deduplicationKey,
|
||||
}: {
|
||||
userId: string;
|
||||
channel: string;
|
||||
payload: any;
|
||||
deduplicationKey?: string; // Optional key to prevent duplicate events
|
||||
}): Promise<string> {
|
||||
try {
|
||||
const eventId = uuidv4();
|
||||
console.log(`Publishing event to channel ${channel} for user ${userId}`);
|
||||
|
||||
// Check for duplicate events if deduplication key is provided
|
||||
if (deduplicationKey) {
|
||||
const existingEvent = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.userId, userId),
|
||||
eq(events.channel, channel),
|
||||
eq(events.read, false)
|
||||
)
|
||||
)
|
||||
.limit(10); // Check recent unread events
|
||||
|
||||
// Check if any existing event has the same deduplication key in payload
|
||||
const isDuplicate = existingEvent.some(event => {
|
||||
try {
|
||||
const eventPayload = JSON.parse(event.payload as string);
|
||||
return eventPayload.deduplicationKey === deduplicationKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (isDuplicate) {
|
||||
console.log(`Skipping duplicate event with key: ${deduplicationKey}`);
|
||||
return eventId; // Return a valid ID but don't create the event
|
||||
}
|
||||
}
|
||||
|
||||
// Add deduplication key to payload if provided
|
||||
const eventPayload = deduplicationKey
|
||||
? { ...payload, deduplicationKey }
|
||||
: payload;
|
||||
|
||||
// Insert the event into the SQLite database
|
||||
await db.insert(events).values({
|
||||
id: eventId,
|
||||
userId,
|
||||
channel,
|
||||
payload: JSON.stringify(eventPayload),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`Event published successfully with ID ${eventId}`);
|
||||
return eventId;
|
||||
} catch (error) {
|
||||
console.error("Error publishing event:", error);
|
||||
throw new Error("Failed to publish event");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets new events for a specific user and channel
|
||||
* This replaces Redis subscribe with SQLite polling
|
||||
*/
|
||||
export async function getNewEvents({
|
||||
userId,
|
||||
channel,
|
||||
lastEventTime,
|
||||
}: {
|
||||
userId: string;
|
||||
channel: string;
|
||||
lastEventTime?: Date;
|
||||
}): Promise<any[]> {
|
||||
try {
|
||||
// Build the query conditions
|
||||
const conditions = [
|
||||
eq(events.userId, userId),
|
||||
eq(events.channel, channel),
|
||||
eq(events.read, false)
|
||||
];
|
||||
|
||||
// Add time filter if provided
|
||||
if (lastEventTime) {
|
||||
conditions.push(gt(events.createdAt, lastEventTime));
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
const newEvents = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(events.createdAt);
|
||||
|
||||
// Mark events as read
|
||||
if (newEvents.length > 0) {
|
||||
await db
|
||||
.update(events)
|
||||
.set({ read: true })
|
||||
.where(
|
||||
and(
|
||||
eq(events.userId, userId),
|
||||
eq(events.channel, channel),
|
||||
eq(events.read, false)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the payloads
|
||||
return newEvents.map(event => ({
|
||||
...event,
|
||||
payload: JSON.parse(event.payload as string),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error getting new events:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate events based on deduplication keys
|
||||
* This can be called periodically to clean up any duplicates that may have slipped through
|
||||
*/
|
||||
export async function removeDuplicateEvents(userId?: string): Promise<{ duplicatesRemoved: number }> {
|
||||
try {
|
||||
console.log("Removing duplicate events...");
|
||||
|
||||
// Build the base query
|
||||
const allEvents = userId
|
||||
? await db.select().from(events).where(eq(events.userId, userId))
|
||||
: await db.select().from(events);
|
||||
|
||||
const duplicateIds: string[] = [];
|
||||
|
||||
// Group events by user and channel, then check for duplicates
|
||||
const eventsByUserChannel = new Map<string, typeof allEvents>();
|
||||
|
||||
for (const event of allEvents) {
|
||||
const key = `${event.userId}-${event.channel}`;
|
||||
if (!eventsByUserChannel.has(key)) {
|
||||
eventsByUserChannel.set(key, []);
|
||||
}
|
||||
eventsByUserChannel.get(key)!.push(event);
|
||||
}
|
||||
|
||||
// Check each group for duplicates
|
||||
for (const [, events] of eventsByUserChannel) {
|
||||
const channelSeenKeys = new Set<string>();
|
||||
|
||||
// Sort by creation time (keep the earliest)
|
||||
events.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
const payload = JSON.parse(event.payload as string);
|
||||
if (payload.deduplicationKey) {
|
||||
if (channelSeenKeys.has(payload.deduplicationKey)) {
|
||||
duplicateIds.push(event.id);
|
||||
} else {
|
||||
channelSeenKeys.add(payload.deduplicationKey);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip events with invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
if (duplicateIds.length > 0) {
|
||||
console.log(`Removing ${duplicateIds.length} duplicate events`);
|
||||
|
||||
// Delete in batches to avoid query size limits
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < duplicateIds.length; i += batchSize) {
|
||||
const batch = duplicateIds.slice(i, i + batchSize);
|
||||
await db.delete(events).where(inArray(events.id, batch));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Removed ${duplicateIds.length} duplicate events`);
|
||||
return { duplicatesRemoved: duplicateIds.length };
|
||||
} catch (error) {
|
||||
console.error("Error removing duplicate events:", error);
|
||||
return { duplicatesRemoved: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up old events to prevent the database from growing too large
|
||||
* This function is used by the cleanup button in the Activity Log page
|
||||
*
|
||||
* @param maxAgeInDays Number of days to keep events (default: 7)
|
||||
* @param cleanupUnreadAfterDays Number of days after which to clean up unread events (default: 2x maxAgeInDays)
|
||||
* @returns Object containing the number of read and unread events deleted
|
||||
*/
|
||||
export async function cleanupOldEvents(
|
||||
maxAgeInDays: number = 7,
|
||||
cleanupUnreadAfterDays?: number
|
||||
): Promise<{ readEventsDeleted: number; unreadEventsDeleted: number }> {
|
||||
try {
|
||||
console.log(`Cleaning up events older than ${maxAgeInDays} days...`);
|
||||
|
||||
// Calculate the cutoff date for read events
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - maxAgeInDays);
|
||||
|
||||
// Delete read events older than the cutoff date
|
||||
const readResult = await db
|
||||
.delete(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.read, true),
|
||||
lt(events.createdAt, cutoffDate)
|
||||
)
|
||||
);
|
||||
|
||||
const readEventsDeleted = (readResult as any).changes || 0;
|
||||
console.log(`Deleted ${readEventsDeleted} read events`);
|
||||
|
||||
// Calculate the cutoff date for unread events (default to 2x the retention period)
|
||||
const unreadCutoffDate = new Date();
|
||||
const unreadMaxAge = cleanupUnreadAfterDays || (maxAgeInDays * 2);
|
||||
unreadCutoffDate.setDate(unreadCutoffDate.getDate() - unreadMaxAge);
|
||||
|
||||
// Delete unread events that are significantly older
|
||||
const unreadResult = await db
|
||||
.delete(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.read, false),
|
||||
lt(events.createdAt, unreadCutoffDate)
|
||||
)
|
||||
);
|
||||
|
||||
const unreadEventsDeleted = (unreadResult as any).changes || 0;
|
||||
console.log(`Deleted ${unreadEventsDeleted} unread events`);
|
||||
|
||||
return { readEventsDeleted, unreadEventsDeleted };
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up old events:", error);
|
||||
return { readEventsDeleted: 0, unreadEventsDeleted: 0 };
|
||||
}
|
||||
}
|
||||
256
Divers/gitea-mirror/src/lib/events/realtime.ts
Normal file
256
Divers/gitea-mirror/src/lib/events/realtime.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Real-time event system using EventEmitter
|
||||
* For the self-hosted version
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface RealtimeEvent {
|
||||
type: string;
|
||||
userId?: string;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time event bus for local instance
|
||||
*/
|
||||
export class RealtimeEventBus extends EventEmitter {
|
||||
private channels = new Map<string, Set<(event: RealtimeEvent) => void>>();
|
||||
private userChannels = new Map<string, string[]>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming events
|
||||
*/
|
||||
private handleIncomingEvent(channel: string, event: RealtimeEvent) {
|
||||
// Emit to local listeners
|
||||
this.emit(channel, event);
|
||||
|
||||
// Call channel-specific handlers
|
||||
const handlers = this.channels.get(channel);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error('Error in event handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a channel
|
||||
*/
|
||||
async subscribe(channel: string, handler?: (event: RealtimeEvent) => void) {
|
||||
// Add handler if provided
|
||||
if (handler) {
|
||||
if (!this.channels.has(channel)) {
|
||||
this.channels.set(channel, new Set());
|
||||
}
|
||||
this.channels.get(channel)!.add(handler);
|
||||
}
|
||||
|
||||
// Add local listener
|
||||
if (!this.listenerCount(channel)) {
|
||||
this.on(channel, (event) => this.handleIncomingEvent(channel, event));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to user-specific channels
|
||||
*/
|
||||
async subscribeUser(userId: string) {
|
||||
const channels = [
|
||||
`user:${userId}`,
|
||||
`user:${userId}:notifications`,
|
||||
`user:${userId}:updates`,
|
||||
];
|
||||
|
||||
this.userChannels.set(userId, channels);
|
||||
|
||||
for (const channel of channels) {
|
||||
await this.subscribe(channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a channel
|
||||
*/
|
||||
async unsubscribe(channel: string, handler?: (event: RealtimeEvent) => void) {
|
||||
// Remove handler if provided
|
||||
if (handler) {
|
||||
this.channels.get(channel)?.delete(handler);
|
||||
|
||||
// Remove channel if no handlers left
|
||||
if (this.channels.get(channel)?.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove local listener if no handlers
|
||||
if (!this.channels.has(channel)) {
|
||||
this.removeAllListeners(channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from user channels
|
||||
*/
|
||||
async unsubscribeUser(userId: string) {
|
||||
const channels = this.userChannels.get(userId) || [];
|
||||
|
||||
for (const channel of channels) {
|
||||
await this.unsubscribe(channel);
|
||||
}
|
||||
|
||||
this.userChannels.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event
|
||||
*/
|
||||
async publish(channel: string, event: Omit<RealtimeEvent, 'timestamp'>) {
|
||||
const fullEvent: RealtimeEvent = {
|
||||
...event,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Emit locally
|
||||
this.handleIncomingEvent(channel, fullEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast to all users
|
||||
*/
|
||||
async broadcast(event: Omit<RealtimeEvent, 'timestamp'>) {
|
||||
await this.publish('broadcast', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to specific user
|
||||
*/
|
||||
async sendToUser(userId: string, event: Omit<RealtimeEvent, 'timestamp' | 'userId'>) {
|
||||
await this.publish(`user:${userId}`, {
|
||||
...event,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send activity update
|
||||
*/
|
||||
async sendActivity(activity: {
|
||||
userId: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
resourceId: string;
|
||||
details?: any;
|
||||
}) {
|
||||
const event = {
|
||||
type: 'activity',
|
||||
data: activity,
|
||||
};
|
||||
|
||||
// Send to user
|
||||
await this.sendToUser(activity.userId, event);
|
||||
|
||||
// Also publish to activity channel
|
||||
await this.publish('activity', {
|
||||
...event,
|
||||
userId: activity.userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
channels: this.channels.size,
|
||||
listeners: Array.from(this.channels.values()).reduce(
|
||||
(sum, handlers) => sum + handlers.size,
|
||||
0
|
||||
),
|
||||
userChannels: this.userChannels.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Global event bus instance
|
||||
export const eventBus = new RealtimeEventBus();
|
||||
|
||||
/**
|
||||
* React hook for subscribing to events
|
||||
*/
|
||||
export function useRealtimeEvents(
|
||||
channel: string,
|
||||
handler: (event: RealtimeEvent) => void,
|
||||
deps: any[] = []
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const { useEffect } = require('react');
|
||||
|
||||
useEffect(() => {
|
||||
eventBus.subscribe(channel, handler);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(channel, handler);
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-sent events endpoint handler
|
||||
*/
|
||||
export async function createSSEHandler(userId: string) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Create a readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send initial connection event
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
|
||||
);
|
||||
|
||||
// Subscribe to user channels
|
||||
await eventBus.subscribeUser(userId);
|
||||
|
||||
// Create event handler
|
||||
const handleEvent = (event: RealtimeEvent) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
);
|
||||
};
|
||||
|
||||
// Subscribe to channels
|
||||
eventBus.on(`user:${userId}`, handleEvent);
|
||||
|
||||
// Keep connection alive with heartbeat
|
||||
const heartbeat = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
return () => {
|
||||
clearInterval(heartbeat);
|
||||
eventBus.off(`user:${userId}`, handleEvent);
|
||||
eventBus.unsubscribeUser(userId);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
202
Divers/gitea-mirror/src/lib/gitea-auth-validator.ts
Normal file
202
Divers/gitea-mirror/src/lib/gitea-auth-validator.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Gitea authentication and permission validation utilities
|
||||
*/
|
||||
|
||||
import type { Config } from "@/types/config";
|
||||
import { httpGet, HttpError } from "./http-client";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
|
||||
export interface GiteaUser {
|
||||
id: number;
|
||||
login: string;
|
||||
username: string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
is_admin: boolean;
|
||||
created?: string;
|
||||
restricted?: boolean;
|
||||
active?: boolean;
|
||||
prohibit_login?: boolean;
|
||||
location?: string;
|
||||
website?: string;
|
||||
description?: string;
|
||||
visibility?: string;
|
||||
followers_count?: number;
|
||||
following_count?: number;
|
||||
starred_repos_count?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Gitea authentication and returns user information
|
||||
*/
|
||||
export async function validateGiteaAuth(config: Partial<Config>): Promise<GiteaUser> {
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
throw new Error("Gitea URL and token are required for authentication validation");
|
||||
}
|
||||
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
try {
|
||||
const response = await httpGet<GiteaUser>(
|
||||
`${config.giteaConfig.url}/api/v1/user`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
const user = response.data;
|
||||
|
||||
// Validate user data
|
||||
if (!user.id || user.id === 0) {
|
||||
throw new Error("Invalid user data received from Gitea: User ID is 0 or missing");
|
||||
}
|
||||
|
||||
if (!user.username && !user.login) {
|
||||
throw new Error("Invalid user data received from Gitea: Username is missing");
|
||||
}
|
||||
|
||||
console.log(`[Auth Validator] Successfully authenticated as: ${user.username || user.login} (ID: ${user.id}, Admin: ${user.is_admin})`);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.status === 401) {
|
||||
throw new Error(
|
||||
"Authentication failed: The provided Gitea token is invalid or expired. " +
|
||||
"Please check your Gitea configuration and ensure the token has the necessary permissions."
|
||||
);
|
||||
} else if (error.status === 403) {
|
||||
throw new Error(
|
||||
"Permission denied: The Gitea token does not have sufficient permissions. " +
|
||||
"Please ensure your token has 'read:user' scope at minimum."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to validate Gitea authentication: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the authenticated user can create organizations
|
||||
*/
|
||||
export async function canCreateOrganizations(config: Partial<Config>): Promise<boolean> {
|
||||
try {
|
||||
const user = await validateGiteaAuth(config);
|
||||
|
||||
// Admin users can always create organizations
|
||||
if (user.is_admin) {
|
||||
console.log(`[Auth Validator] User is admin, can create organizations`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the instance allows regular users to create organizations
|
||||
// This would require checking instance settings, which may not be publicly available
|
||||
// For now, we'll try to create a test org and see if it fails
|
||||
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
try {
|
||||
// Try to list user's organizations as a proxy for permission check
|
||||
const orgsResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/user/orgs`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
// If we can list orgs, we likely can create them
|
||||
console.log(`[Auth Validator] User can list organizations, likely can create them`);
|
||||
return true;
|
||||
} catch (listError) {
|
||||
if (listError instanceof HttpError && listError.status === 403) {
|
||||
console.log(`[Auth Validator] User cannot list/create organizations`);
|
||||
return false;
|
||||
}
|
||||
// For other errors, assume we can try
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Auth Validator] Error checking organization creation permissions:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or validates the default owner for repositories
|
||||
*/
|
||||
export async function getValidatedDefaultOwner(config: Partial<Config>): Promise<string> {
|
||||
const user = await validateGiteaAuth(config);
|
||||
const username = user.username || user.login;
|
||||
|
||||
if (!username) {
|
||||
throw new Error("Unable to determine Gitea username from authentication");
|
||||
}
|
||||
|
||||
// Check if the configured defaultOwner matches the authenticated user
|
||||
if (config.giteaConfig?.defaultOwner && config.giteaConfig.defaultOwner !== username) {
|
||||
console.warn(
|
||||
`[Auth Validator] Configured defaultOwner (${config.giteaConfig.defaultOwner}) ` +
|
||||
`does not match authenticated user (${username}). Using authenticated user.`
|
||||
);
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the Gitea configuration is properly set up for mirroring
|
||||
*/
|
||||
export async function validateGiteaConfigForMirroring(config: Partial<Config>): Promise<{
|
||||
valid: boolean;
|
||||
user: GiteaUser;
|
||||
canCreateOrgs: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}> {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Validate authentication
|
||||
const user = await validateGiteaAuth(config);
|
||||
|
||||
// Check organization creation permissions
|
||||
const canCreateOrgs = await canCreateOrganizations(config);
|
||||
|
||||
if (!canCreateOrgs && config.giteaConfig?.preserveOrgStructure) {
|
||||
warnings.push(
|
||||
"User cannot create organizations but 'preserveOrgStructure' is enabled. " +
|
||||
"Repositories will be mirrored to the user account instead."
|
||||
);
|
||||
}
|
||||
|
||||
// Validate token scopes (this would require additional API calls to check specific permissions)
|
||||
// For now, we'll just check if basic operations work
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user,
|
||||
canCreateOrgs,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
user: {} as GiteaUser,
|
||||
canCreateOrgs: false,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
755
Divers/gitea-mirror/src/lib/gitea-enhanced.test.ts
Normal file
755
Divers/gitea-mirror/src/lib/gitea-enhanced.test.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { createMockResponse, mockFetch } from "@/tests/mock-fetch";
|
||||
|
||||
// Mock the helpers module before importing gitea-enhanced
|
||||
const mockCreateMirrorJob = mock(() => Promise.resolve("mock-job-id"));
|
||||
mock.module("@/lib/helpers", () => ({
|
||||
createMirrorJob: mockCreateMirrorJob
|
||||
}));
|
||||
|
||||
const mockMirrorGitHubReleasesToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoIssuesToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve());
|
||||
const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred"));
|
||||
|
||||
// Mock the database module
|
||||
const mockDb = {
|
||||
insert: mock((table: any) => ({
|
||||
values: mock((data: any) => Promise.resolve({ insertedId: "mock-id" }))
|
||||
})),
|
||||
update: mock(() => ({
|
||||
set: mock(() => ({
|
||||
where: mock(() => Promise.resolve())
|
||||
}))
|
||||
}))
|
||||
};
|
||||
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: mockDb,
|
||||
mirrorJobs: {},
|
||||
repositories: {}
|
||||
}));
|
||||
|
||||
// Mock config encryption
|
||||
mock.module("@/lib/utils/config-encryption", () => ({
|
||||
decryptConfigTokens: (config: any) => config,
|
||||
encryptConfigTokens: (config: any) => config,
|
||||
getDecryptedGitHubToken: (config: any) => config.githubConfig?.token || "",
|
||||
getDecryptedGiteaToken: (config: any) => config.giteaConfig?.token || ""
|
||||
}));
|
||||
|
||||
// Mock http-client
|
||||
class MockHttpError extends Error {
|
||||
constructor(message: string, public status: number, public statusText: string, public response?: string) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
// Track call counts for org tests
|
||||
let orgCheckCount = 0;
|
||||
let orgTestContext = "";
|
||||
let getOrgCalled = false;
|
||||
let createOrgCalled = false;
|
||||
|
||||
const mockHttpGet = mock(async (url: string, headers?: any) => {
|
||||
// Return different responses based on URL patterns
|
||||
|
||||
// Handle user authentication endpoint
|
||||
if (url.includes("/api/v1/user")) {
|
||||
return {
|
||||
data: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
is_admin: false,
|
||||
full_name: "Test User"
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes("/api/v1/repos/starred/test-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 123,
|
||||
name: "test-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
mirror_interval: "8h",
|
||||
clone_url: "https://github.com/user/test-repo.git",
|
||||
private: false
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/starred/regular-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 124,
|
||||
name: "regular-repo",
|
||||
mirror: false,
|
||||
owner: { login: "starred" }
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/starred/non-mirror-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 456,
|
||||
name: "non-mirror-repo",
|
||||
mirror: false,
|
||||
owner: { login: "starred" },
|
||||
private: false
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/starred/mirror-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 789,
|
||||
name: "mirror-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
mirror_interval: "8h",
|
||||
private: false
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/starred/metadata-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 790,
|
||||
name: "metadata-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
mirror_interval: "8h",
|
||||
private: false,
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/starred/already-synced-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 791,
|
||||
name: "already-synced-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
mirror_interval: "8h",
|
||||
private: false,
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/")) {
|
||||
throw new MockHttpError("Not Found", 404, "Not Found");
|
||||
}
|
||||
|
||||
// Handle org GET requests based on test context
|
||||
if (url.includes("/api/v1/orgs/starred")) {
|
||||
orgCheckCount++;
|
||||
if (orgTestContext === "duplicate-retry" && orgCheckCount > 2) {
|
||||
// After retries, org exists
|
||||
return {
|
||||
data: { id: 999, username: "starred" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
// Otherwise, org doesn't exist
|
||||
throw new MockHttpError("Not Found", 404, "Not Found");
|
||||
}
|
||||
|
||||
if (url.includes("/api/v1/orgs/neworg")) {
|
||||
getOrgCalled = true;
|
||||
// Org doesn't exist
|
||||
throw new MockHttpError("Not Found", 404, "Not Found");
|
||||
}
|
||||
|
||||
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
|
||||
});
|
||||
|
||||
const mockHttpPost = mock(async (url: string, body?: any, headers?: any) => {
|
||||
if (url.includes("/api/v1/orgs") && body?.username === "starred") {
|
||||
// Simulate duplicate org error
|
||||
throw new MockHttpError(
|
||||
'insert organization: pq: duplicate key value violates unique constraint "UQE_user_lower_name"',
|
||||
400,
|
||||
"Bad Request",
|
||||
JSON.stringify({ message: 'insert organization: pq: duplicate key value violates unique constraint "UQE_user_lower_name"', url: "https://gitea.example.com/api/swagger" })
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/v1/orgs") && body?.username === "neworg") {
|
||||
createOrgCalled = true;
|
||||
return {
|
||||
data: { id: 777, username: "neworg" },
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
if (url.includes("/mirror-sync")) {
|
||||
return {
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
|
||||
});
|
||||
|
||||
const mockHttpDelete = mock(async (url: string, headers?: any) => {
|
||||
if (url.includes("/api/v1/repos/starred/test-repo")) {
|
||||
return { data: {}, status: 204, statusText: "No Content", headers: new Headers() };
|
||||
}
|
||||
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
|
||||
});
|
||||
|
||||
mock.module("@/lib/http-client", () => ({
|
||||
httpGet: mockHttpGet,
|
||||
httpPost: mockHttpPost,
|
||||
httpDelete: mockHttpDelete,
|
||||
HttpError: MockHttpError
|
||||
}));
|
||||
|
||||
// Now import the modules we're testing
|
||||
import {
|
||||
getGiteaRepoInfo,
|
||||
getOrCreateGiteaOrgEnhanced,
|
||||
syncGiteaRepoEnhanced,
|
||||
handleExistingNonMirrorRepo
|
||||
} from "./gitea-enhanced";
|
||||
import type { Config, Repository } from "./db/schema";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
|
||||
// Get HttpError from the mocked module
|
||||
const { HttpError } = await import("@/lib/http-client");
|
||||
|
||||
describe("Enhanced Gitea Operations", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
// Clear mocks
|
||||
mockCreateMirrorJob.mockClear();
|
||||
mockDb.insert.mockClear();
|
||||
mockDb.update.mockClear();
|
||||
mockMirrorGitHubReleasesToGitea.mockClear();
|
||||
mockMirrorGitRepoIssuesToGitea.mockClear();
|
||||
mockMirrorGitRepoPullRequestsToGitea.mockClear();
|
||||
mockMirrorGitRepoLabelsToGitea.mockClear();
|
||||
mockMirrorGitRepoMilestonesToGitea.mockClear();
|
||||
mockGetGiteaRepoOwnerAsync.mockClear();
|
||||
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("starred"));
|
||||
// Reset tracking variables
|
||||
orgCheckCount = 0;
|
||||
orgTestContext = "";
|
||||
getOrgCalled = false;
|
||||
createOrgCalled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("getGiteaRepoInfo", () => {
|
||||
test("should return repo info for existing mirror repository", async () => {
|
||||
global.fetch = mockFetch(() =>
|
||||
createMockResponse({
|
||||
id: 123,
|
||||
name: "test-repo",
|
||||
owner: "starred",
|
||||
mirror: true,
|
||||
mirror_interval: "8h",
|
||||
clone_url: "https://github.com/user/test-repo.git",
|
||||
private: false,
|
||||
})
|
||||
);
|
||||
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: "starred",
|
||||
repoName: "test-repo",
|
||||
});
|
||||
|
||||
expect(repoInfo).toBeTruthy();
|
||||
expect(repoInfo?.mirror).toBe(true);
|
||||
expect(repoInfo?.name).toBe("test-repo");
|
||||
});
|
||||
|
||||
test("should return repo info for existing non-mirror repository", async () => {
|
||||
global.fetch = mockFetch(() =>
|
||||
createMockResponse({
|
||||
id: 124,
|
||||
name: "regular-repo",
|
||||
owner: "starred",
|
||||
mirror: false,
|
||||
private: false,
|
||||
})
|
||||
);
|
||||
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: "starred",
|
||||
repoName: "regular-repo",
|
||||
});
|
||||
|
||||
expect(repoInfo).toBeTruthy();
|
||||
expect(repoInfo?.mirror).toBe(false);
|
||||
});
|
||||
|
||||
test("should return null for non-existent repository", async () => {
|
||||
global.fetch = mockFetch(() =>
|
||||
createMockResponse(
|
||||
"Not Found",
|
||||
{ ok: false, status: 404, statusText: "Not Found" }
|
||||
)
|
||||
);
|
||||
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: "starred",
|
||||
repoName: "non-existent",
|
||||
});
|
||||
|
||||
expect(repoInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateGiteaOrgEnhanced", () => {
|
||||
test("should handle duplicate organization constraint error with retry", async () => {
|
||||
orgTestContext = "duplicate-retry";
|
||||
orgCheckCount = 0; // Reset the count
|
||||
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
visibility: "public",
|
||||
},
|
||||
};
|
||||
|
||||
const orgId = await getOrCreateGiteaOrgEnhanced({
|
||||
orgName: "starred",
|
||||
config,
|
||||
maxRetries: 3,
|
||||
retryDelay: 0, // No delay in tests
|
||||
});
|
||||
|
||||
expect(orgId).toBe(999);
|
||||
expect(orgCheckCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("should create organization on first attempt", async () => {
|
||||
// Reset tracking variables
|
||||
getOrgCalled = false;
|
||||
createOrgCalled = false;
|
||||
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const orgId = await getOrCreateGiteaOrgEnhanced({
|
||||
orgName: "neworg",
|
||||
config,
|
||||
retryDelay: 0, // No delay in tests
|
||||
});
|
||||
|
||||
expect(orgId).toBe(777);
|
||||
expect(getOrgCalled).toBe(true);
|
||||
expect(createOrgCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncGiteaRepoEnhanced", () => {
|
||||
test("should fail gracefully when repository is not a mirror", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo123",
|
||||
name: "non-mirror-repo",
|
||||
fullName: "user/non-mirror-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/non-mirror-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("Repository non-mirror-repo is not a mirror. Cannot sync.");
|
||||
|
||||
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should successfully sync a mirror repository", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo456",
|
||||
name: "mirror-repo",
|
||||
fullName: "user/mirror-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/mirror-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockGetGiteaRepoOwnerAsync).toHaveBeenCalled();
|
||||
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
const releaseCall = mockMirrorGitHubReleasesToGitea.mock.calls[0][0];
|
||||
expect(releaseCall.giteaOwner).toBe("starred");
|
||||
expect(releaseCall.giteaRepoName).toBe("mirror-repo");
|
||||
expect(releaseCall.config.githubConfig?.token).toBe("github-token");
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
});
|
||||
|
||||
test("mirrors metadata components when enabled and not previously synced", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: true,
|
||||
mirrorStarred: false,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
mirrorMetadata: true,
|
||||
mirrorIssues: true,
|
||||
mirrorPullRequests: true,
|
||||
mirrorLabels: true,
|
||||
mirrorMilestones: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo789",
|
||||
name: "metadata-repo",
|
||||
fullName: "user/metadata-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/metadata-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: false,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoMilestonesToGitea).toHaveBeenCalledTimes(1);
|
||||
// Labels should be skipped because issues already import them
|
||||
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips metadata mirroring when components already synced", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: true,
|
||||
mirrorStarred: false,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
mirrorMetadata: true,
|
||||
mirrorIssues: true,
|
||||
mirrorPullRequests: true,
|
||||
mirrorLabels: true,
|
||||
mirrorMilestones: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo790",
|
||||
name: "already-synced-repo",
|
||||
fullName: "user/already-synced-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/already-synced-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: false,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: JSON.stringify({
|
||||
components: {
|
||||
releases: true,
|
||||
issues: true,
|
||||
pullRequests: true,
|
||||
labels: true,
|
||||
milestones: true,
|
||||
},
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
|
||||
await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExistingNonMirrorRepo", () => {
|
||||
test("should skip non-mirror repository with skip strategy", async () => {
|
||||
const repoInfo = {
|
||||
id: 123,
|
||||
name: "test-repo",
|
||||
owner: "starred",
|
||||
mirror: false,
|
||||
private: false,
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo123",
|
||||
name: "test-repo",
|
||||
fullName: "user/test-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/test-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("imported"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
},
|
||||
};
|
||||
|
||||
await handleExistingNonMirrorRepo({
|
||||
config,
|
||||
repository,
|
||||
repoInfo,
|
||||
strategy: "skip",
|
||||
});
|
||||
|
||||
// Test passes if no error is thrown
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("should delete non-mirror repository with delete strategy", async () => {
|
||||
// Mock deleteGiteaRepo which uses httpDelete via the http-client mock
|
||||
const repoInfo = {
|
||||
id: 124,
|
||||
name: "test-repo",
|
||||
owner: "starred",
|
||||
mirror: false,
|
||||
private: false,
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo124",
|
||||
name: "test-repo",
|
||||
fullName: "user/test-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/test-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("imported"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
},
|
||||
};
|
||||
|
||||
// deleteGiteaRepo in the actual code uses fetch directly, not httpDelete
|
||||
// We need to mock fetch for this test
|
||||
let deleteCalled = false;
|
||||
global.fetch = mockFetch(async (url: string, options?: RequestInit) => {
|
||||
if (url.includes("/api/v1/repos/starred/test-repo") && options?.method === "DELETE") {
|
||||
deleteCalled = true;
|
||||
return createMockResponse(null, { ok: true, status: 204 });
|
||||
}
|
||||
return createMockResponse(null, { ok: false, status: 404 });
|
||||
});
|
||||
|
||||
await handleExistingNonMirrorRepo({
|
||||
config,
|
||||
repository,
|
||||
repoInfo,
|
||||
strategy: "delete",
|
||||
});
|
||||
|
||||
expect(deleteCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user