🎉 Gitea Mirror: Added

This commit is contained in:
Arunavo Ray
2025-05-18 09:31:23 +05:30
commit 5d40023de0
139 changed files with 22033 additions and 0 deletions

View File

@@ -0,0 +1,398 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { GitHubConfigForm } from "./GitHubConfigForm";
import { GiteaConfigForm } from "./GiteaConfigForm";
import { ScheduleConfigForm } from "./ScheduleConfigForm";
import type {
ConfigApiResponse,
GiteaConfig,
GitHubConfig,
SaveConfigApiRequest,
SaveConfigApiResponse,
ScheduleConfig,
} from "@/types/config";
import { Button } from "../ui/button";
import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils";
import { Copy, CopyCheck, RefreshCw } from "lucide-react";
import { toast } from "sonner";
type ConfigState = {
githubConfig: GitHubConfig;
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
};
export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({
githubConfig: {
username: "",
token: "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: false,
preserveOrgStructure: false,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
username: "",
token: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
},
scheduleConfig: {
enabled: false,
interval: 3600,
},
});
const { user, refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [dockerCode, setDockerCode] = useState<string>("");
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
// Check if all required fields are filled to enable the Save Configuration button
const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config;
// Check GitHub required fields
const isGitHubValid = !!(
githubConfig.username?.trim() && githubConfig.token?.trim()
);
// Check Gitea required fields
const isGiteaValid = !!(
giteaConfig.url?.trim() &&
giteaConfig.username?.trim() &&
giteaConfig.token?.trim()
);
return isGitHubValid && isGiteaValid;
};
useEffect(() => {
const updateLastAndNextRun = () => {
const lastRun = config.scheduleConfig.lastRun
? new Date(config.scheduleConfig.lastRun)
: new Date(); // fallback to now if lastRun is null
const intervalInSeconds = config.scheduleConfig.interval;
const nextRun = new Date(lastRun.getTime() + intervalInSeconds * 1000);
setConfig((prev) => ({
...prev,
scheduleConfig: {
...prev.scheduleConfig,
lastRun,
nextRun,
},
}));
};
updateLastAndNextRun();
}, [config.scheduleConfig.interval]);
const handleImportGitHubData = async () => {
try {
if (!user?.id) return;
setIsSyncing(true);
const result = await apiRequest<{ success: boolean; message?: string }>(
`/sync?userId=${user.id}`,
{
method: "POST",
}
);
if (result.success) {
toast.success(
"GitHub data imported successfully! Head to the Dashboard to start mirroring repositories."
);
} else {
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);
}
};
const handleSaveConfig = async () => {
try {
if (!user || !user.id) {
return;
}
const reqPyload: SaveConfigApiRequest = {
userId: user.id,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
};
const response = await fetch("/api/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqPyload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
await refreshUser();
setIsConfigSaved(true);
toast.success(
"Configuration saved successfully! Now import your GitHub data to begin."
);
} else {
toast.error(
`Failed to save configuration: ${result.message || "Unknown error"}`
);
}
} catch (error) {
toast.error(
`An error occurred while saving the configuration: ${
error instanceof Error ? error.message : String(error)
}`
);
}
};
useEffect(() => {
const fetchConfig = async () => {
try {
if (!user) {
return;
}
setIsLoading(true);
const response = await apiRequest<ConfigApiResponse>(
`/config?userId=${user.id}`,
{
method: "GET",
}
);
// Check if we have a valid config response
if (response && !response.error) {
setConfig({
githubConfig: response.githubConfig || config.githubConfig,
giteaConfig: response.giteaConfig || config.giteaConfig,
scheduleConfig: response.scheduleConfig || config.scheduleConfig,
});
// If we got a valid config from the server, it means it was previously saved
if (response.id) {
setIsConfigSaved(true);
}
}
// If there's an error, we'll just use the default config defined in state
setIsLoading(false);
} catch (error) {
// Don't show error for first-time users, just use the default config
console.warn("Could not fetch configuration, using defaults:", error);
} finally {
setIsLoading(false);
}
};
fetchConfig();
}, [user]);
useEffect(() => {
const generateDockerCode = () => {
return `version: "3.3"
services:
gitea-mirror:
image: arunavo4/gitea-mirror:latest
restart: unless-stopped
container_name: gitea-mirror
environment:
- GITHUB_USERNAME=${config.githubConfig.username}
- GITEA_URL=${config.giteaConfig.url}
- GITEA_TOKEN=${config.giteaConfig.token}
- GITHUB_TOKEN=${config.githubConfig.token}
- SKIP_FORKS=${config.githubConfig.skipForks}
- PRIVATE_REPOSITORIES=${config.githubConfig.privateRepositories}
- MIRROR_ISSUES=${config.githubConfig.mirrorIssues}
- MIRROR_STARRED=${config.githubConfig.mirrorStarred}
- PRESERVE_ORG_STRUCTURE=${config.githubConfig.preserveOrgStructure}
- SKIP_STARRED_ISSUES=${config.githubConfig.skipStarredIssues}
- GITEA_ORGANIZATION=${config.giteaConfig.organization}
- GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility}
- DELAY=${config.scheduleConfig.interval}`;
};
const code = generateDockerCode();
setDockerCode(code);
}, [config]);
const handleCopyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(
() => {
setIsCopied(true);
toast.success("Docker configuration copied to clipboard!");
setTimeout(() => setIsCopied(false), 2000);
},
(err) => {
toast.error("Could not copy text to clipboard.");
}
);
};
return isLoading ? (
<div>loading...</div>
) : (
<div className="flex flex-col gap-y-6">
<Card>
<CardHeader className="flex-row justify-between">
<div className="flex flex-col gap-y-1.5 m-0">
<CardTitle>Configuration Settings</CardTitle>
<CardDescription>
Configure your GitHub and Gitea connections, and set up automatic
mirroring.
</CardDescription>
</div>
<div className="flex gap-x-4">
<Button
onClick={handleImportGitHubData}
disabled={isSyncing || !isConfigSaved}
title={
!isConfigSaved
? "Save configuration first"
: isSyncing
? "Import in progress"
: "Import GitHub Data"
}
>
{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>
<Button
onClick={handleSaveConfig}
disabled={!isConfigFormValid()}
title={
!isConfigFormValid()
? "Please fill all required fields"
: "Save Configuration"
}
>
Save Configuration
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<GitHubConfigForm
config={config.githubConfig}
setConfig={(update) =>
setConfig((prev) => ({
...prev,
githubConfig:
typeof update === "function"
? update(prev.githubConfig)
: update,
}))
}
/>
<GiteaConfigForm
config={config?.giteaConfig ?? ({} as GiteaConfig)}
setConfig={(update) =>
setConfig((prev) => ({
...prev,
giteaConfig:
typeof update === "function"
? update(prev.giteaConfig)
: update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
scheduleConfig:
prev?.scheduleConfig ?? ({} as ScheduleConfig),
}))
}
/>
</div>
<ScheduleConfigForm
config={config?.scheduleConfig ?? ({} as ScheduleConfig)}
setConfig={(update) =>
setConfig((prev) => ({
...prev,
scheduleConfig:
typeof update === "function"
? update(prev.scheduleConfig)
: update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
giteaConfig: prev?.giteaConfig ?? ({} as GiteaConfig),
}))
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
<CardDescription>
Equivalent Docker configuration for your current settings.
</CardDescription>
</CardHeader>
<CardContent className="relative">
<Button
variant="outline"
size="icon"
className="absolute top-4 right-10"
onClick={() => handleCopyToClipboard(dockerCode)}
>
{isCopied ? (
<CopyCheck className="text-green-500" />
) : (
<Copy className="text-muted-foreground" />
)}
</Button>
<pre className="bg-muted p-4 rounded-md overflow-auto text-sm">
{dockerCode}
</pre>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,340 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { githubApi } from "@/lib/api";
import type { GitHubConfig } from "@/types/config";
import { Input } from "../ui/input";
import { Checkbox } from "../ui/checkbox";
import { toast } from "sonner";
import { AlertTriangle } from "lucide-react";
import { Alert, AlertDescription } from "../ui/alert";
import { Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
interface GitHubConfigFormProps {
config: GitHubConfig;
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
}
export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
// 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",
}
);
}
setConfig({
...config,
[name]: type === "checkbox" ? checked : value,
});
};
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">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
GitHub Configuration
</CardTitle>
<Button
type="button"
variant="outline"
onClick={testConnection}
disabled={isLoading || !config.token}
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6">
<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>
<label
htmlFor="github-token"
className="block text-sm font-medium mb-1.5"
>
GitHub Token
</label>
<Input
id="github-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
className="bg-background"
placeholder="Your GitHub personal access token"
/>
<p className="text-xs text-muted-foreground mt-1">
Required for private repositories, organizations, and starred
repositories.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="skip-forks"
name="skipForks"
checked={config.skipForks}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "skipForks",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="skip-forks"
className="ml-2 block text-sm select-none"
>
Skip Forks
</label>
</div>
<div className="flex items-center">
<Checkbox
id="private-repositories"
name="privateRepositories"
checked={config.privateRepositories}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "privateRepositories",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="private-repositories"
className="ml-2 block text-sm select-none"
>
Mirror Private Repos
</label>
</div>
<div className="flex items-center">
<Checkbox
id="mirror-starred"
name="mirrorStarred"
checked={config.mirrorStarred}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "mirrorStarred",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="mirror-starred"
className="ml-2 block text-sm select-none"
>
Mirror Starred Repos
</label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="mirror-issues"
name="mirrorIssues"
checked={config.mirrorIssues}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "mirrorIssues",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="mirror-issues"
className="ml-2 block text-sm select-none"
>
Mirror Issues
</label>
</div>
<div className="flex items-center">
<Checkbox
id="preserve-org-structure"
name="preserveOrgStructure"
checked={config.preserveOrgStructure}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "preserveOrgStructure",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="preserve-org-structure"
className="ml-2 text-sm select-none flex items-center"
>
Preserve Org Structure
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-1 cursor-pointer align-middle text-muted-foreground"
role="button"
tabIndex={0}
>
<Info size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
When enabled, organization repositories will be mirrored to
the same organization structure in Gitea. When disabled, all
repositories will be mirrored under your Gitea username.
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex items-center">
<Checkbox
id="skip-starred-issues"
name="skipStarredIssues"
checked={config.skipStarredIssues}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "skipStarredIssues",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="skip-starred-issues"
className="ml-2 block text-sm select-none"
>
Skip Issues for Starred Repos
</label>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex-col items-start">
<Alert variant="note" className="w-full">
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" />
<AlertDescription className="text-sm">
<div className="font-semibold mb-1">Note:</div>
<div className="mb-1">
You need to create a{" "}
<span className="font-semibold">Classic GitHub PAT Token</span>{" "}
with following scopes:
</div>
<ul className="ml-4 mb-1 list-disc">
<li>
<code>repo</code>
</li>
<li>
<code>admin:org</code>
</li>
</ul>
<div className="mb-1">
The organization access is required for mirroring organization
repositories.
</div>
<div>
You can generate tokens at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-900 dark:hover:text-blue-200"
>
github.com/settings/tokens
</a>
.
</div>
</AlertDescription>
</Alert>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,228 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { giteaApi } from "@/lib/api";
import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config";
import { toast } from "sonner";
interface GiteaConfigFormProps {
config: GiteaConfig;
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
}
export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setConfig({
...config,
[name]: value,
});
};
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">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
Gitea Configuration
</CardTitle>
<Button
type="button"
variant="outline"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6">
<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 &gt;
Applications.
</p>
</div>
<div>
<label
htmlFor="organization"
className="block text-sm font-medium mb-1.5"
>
Default Organization (Optional)
</label>
<input
id="organization"
name="organization"
type="text"
value={config.organization}
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="Organization name"
/>
<p className="text-xs text-muted-foreground mt-1">
If specified, repositories will be mirrored to this organization.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="visibility"
className="block text-sm font-medium mb-1.5"
>
Organization Visibility
</label>
<Select
name="visibility"
value={config.visibility}
onValueChange={(value) =>
handleChange({
target: { name: "visibility", value },
} as React.ChangeEvent<HTMLInputElement>)
}
>
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
{(["public", "private", "limited"] as GiteaOrgVisibility[]).map(
(option) => (
<SelectItem
key={option}
value={option}
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
>
{option.charAt(0).toUpperCase() + option.slice(1)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="starred-repos-org"
className="block text-sm font-medium mb-1.5"
>
Starred Repositories Organization
</label>
<input
id="starred-repos-org"
name="starredReposOrg"
type="text"
value={config.starredReposOrg}
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="github"
/>
<p className="text-xs text-muted-foreground mt-1">
Organization for starred repositories (default: github)
</p>
</div>
</div>
</CardContent>
<CardFooter className="">
{/* Footer content can be added here if needed */}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,139 @@
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";
interface ScheduleConfigFormProps {
config: ScheduleConfig;
setConfig: React.Dispatch<React.SetStateAction<ScheduleConfig>>;
}
export function ScheduleConfigForm({
config,
setConfig,
}: ScheduleConfigFormProps) {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
setConfig({
...config,
[name]:
type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
});
};
// Convert seconds to human-readable format
const formatInterval = (seconds: number): string => {
if (seconds < 60) return `${seconds} seconds`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
return `${Math.floor(seconds / 86400)} days`;
};
// Predefined intervals
const intervals: { value: number; label: string }[] = [
// { value: 120, label: "2 minutes" }, //for testing
{ value: 900, label: "15 minutes" },
{ value: 1800, label: "30 minutes" },
{ 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>
<CardContent className="pt-6">
<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 Mirroring
</label>
</div>
<div>
<label
htmlFor="interval"
className="block text-sm font-medium mb-1.5"
>
Mirroring 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 mirroring process should run.
</p>
</div>
{config.lastRun && (
<div>
<label className="block text-sm font-medium mb-1">Last Run</label>
<div className="text-sm">{formatDate(config.lastRun)}</div>
</div>
)}
{config.nextRun && config.enabled && (
<div>
<label className="block text-sm font-medium mb-1">Next Run</label>
<div className="text-sm">{formatDate(config.nextRun)}</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}