Added gitea-mirror
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user