diff --git a/bun.lock b/bun.lock index dcfb329..b10d434 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.5", - "@radix-ui/react-tabs": "^1.1.11", + "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.6", "@tailwindcss/vite": "^4.1.7", "@tanstack/react-virtual": "^3.13.8", diff --git a/package.json b/package.json index 24d4f74..c7c9da4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "check-db": "bun scripts/manage-db.ts check", "fix-db": "bun scripts/manage-db.ts fix", "reset-users": "bun scripts/manage-db.ts reset-users", - "startup-recovery": "bun scripts/startup-recovery.ts", "startup-recovery-force": "bun scripts/startup-recovery.ts --force", "test-recovery": "bun scripts/test-recovery.ts", @@ -46,7 +45,7 @@ "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.5", - "@radix-ui/react-tabs": "^1.1.11", + "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.6", "@tailwindcss/vite": "^4.1.7", "@tanstack/react-virtual": "^3.13.8", diff --git a/src/components/config/AdvancedOptionsForm.tsx b/src/components/config/AdvancedOptionsForm.tsx new file mode 100644 index 0000000..76abfc8 --- /dev/null +++ b/src/components/config/AdvancedOptionsForm.tsx @@ -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>; + onAutoSave?: (config: AdvancedOptions) => Promise; + 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 ( + + + + Advanced Options + {isAutoSaving && ( +
+ + Auto-saving... +
+ )} +
+
+ +
+
+ + handleChange("skipForks", Boolean(checked)) + } + /> + +
+

+ Don't mirror repositories that are forks of other repositories +

+ +
+ + handleChange("skipStarredIssues", Boolean(checked)) + } + /> + +
+

+ Skip mirroring issues and pull requests for starred repositories +

+
+
+
+ ); +} diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index 07069e5..5929c60 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -3,6 +3,9 @@ import { GitHubConfigForm } from './GitHubConfigForm'; import { GiteaConfigForm } from './GiteaConfigForm'; import { ScheduleConfigForm } from './ScheduleConfigForm'; import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm'; +import { MirrorOptionsForm } from './MirrorOptionsForm'; +import { AdvancedOptionsForm } from './AdvancedOptionsForm'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import type { ConfigApiResponse, GiteaConfig, @@ -11,6 +14,8 @@ import type { SaveConfigApiResponse, ScheduleConfig, DatabaseCleanupConfig, + MirrorOptions, + AdvancedOptions, } from '@/types/config'; import { Button } from '../ui/button'; import { useAuth } from '@/hooks/useAuth'; @@ -25,6 +30,8 @@ type ConfigState = { giteaConfig: GiteaConfig; scheduleConfig: ScheduleConfig; cleanupConfig: DatabaseCleanupConfig; + mirrorOptions: MirrorOptions; + advancedOptions: AdvancedOptions; }; export function ConfigTabs() { @@ -32,13 +39,8 @@ export function ConfigTabs() { githubConfig: { username: '', token: '', - skipForks: false, privateRepositories: false, - mirrorIssues: false, - mirrorWiki: false, mirrorStarred: false, - preserveOrgStructure: false, - skipStarredIssues: false, }, giteaConfig: { url: '', @@ -47,6 +49,7 @@ export function ConfigTabs() { organization: 'github-mirrors', visibility: 'public', starredReposOrg: 'github', + preserveOrgStructure: false, }, scheduleConfig: { enabled: false, @@ -56,6 +59,21 @@ export function ConfigTabs() { enabled: false, retentionDays: 604800, // 7 days in seconds }, + mirrorOptions: { + mirrorReleases: false, + mirrorMetadata: false, + metadataComponents: { + issues: false, + pullRequests: false, + labels: false, + milestones: false, + wiki: false, + }, + }, + advancedOptions: { + skipForks: false, + skipStarredIssues: false, + }, }); const { user } = useAuth(); const [isLoading, setIsLoading] = useState(true); @@ -65,10 +83,14 @@ export function ConfigTabs() { const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState(false); const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState(false); const [isAutoSavingGitea, setIsAutoSavingGitea] = useState(false); + const [isAutoSavingMirrorOptions, setIsAutoSavingMirrorOptions] = useState(false); + const [isAutoSavingAdvancedOptions, setIsAutoSavingAdvancedOptions] = useState(false); const autoSaveScheduleTimeoutRef = useRef(null); const autoSaveCleanupTimeoutRef = useRef(null); const autoSaveGitHubTimeoutRef = useRef(null); const autoSaveGiteaTimeoutRef = useRef(null); + const autoSaveMirrorOptionsTimeoutRef = useRef(null); + const autoSaveAdvancedOptionsTimeoutRef = useRef(null); const isConfigFormValid = (): boolean => { const { githubConfig, giteaConfig } = config; @@ -133,6 +155,8 @@ export function ConfigTabs() { giteaConfig: config.giteaConfig, scheduleConfig: scheduleConfig, cleanupConfig: config.cleanupConfig, + mirrorOptions: config.mirrorOptions, + advancedOptions: config.advancedOptions, }; try { @@ -197,6 +221,8 @@ export function ConfigTabs() { giteaConfig: config.giteaConfig, scheduleConfig: config.scheduleConfig, cleanupConfig: cleanupConfig, + mirrorOptions: config.mirrorOptions, + advancedOptions: config.advancedOptions, }; try { @@ -260,6 +286,8 @@ export function ConfigTabs() { giteaConfig: config.giteaConfig, scheduleConfig: config.scheduleConfig, cleanupConfig: config.cleanupConfig, + mirrorOptions: config.mirrorOptions, + advancedOptions: config.advancedOptions, }; try { @@ -307,6 +335,8 @@ export function ConfigTabs() { giteaConfig: giteaConfig, scheduleConfig: config.scheduleConfig, cleanupConfig: config.cleanupConfig, + mirrorOptions: config.mirrorOptions, + advancedOptions: config.advancedOptions, }; try { @@ -335,6 +365,104 @@ export function ConfigTabs() { }, 500); // 500ms debounce }, [user?.id, config.githubConfig, config.scheduleConfig, config.cleanupConfig]); + // Auto-save function specifically for mirror options changes + const autoSaveMirrorOptions = useCallback(async (mirrorOptions: MirrorOptions) => { + if (!user?.id) return; + + // Clear any existing timeout + if (autoSaveMirrorOptionsTimeoutRef.current) { + clearTimeout(autoSaveMirrorOptionsTimeoutRef.current); + } + + // Debounce the auto-save to prevent excessive API calls + autoSaveMirrorOptionsTimeoutRef.current = setTimeout(async () => { + setIsAutoSavingMirrorOptions(true); + + 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) { + // 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 { + setIsAutoSavingMirrorOptions(false); + } + }, 500); // 500ms debounce + }, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.advancedOptions]); + + // Auto-save function specifically for advanced options changes + const autoSaveAdvancedOptions = useCallback(async (advancedOptions: AdvancedOptions) => { + if (!user?.id) return; + + // Clear any existing timeout + if (autoSaveAdvancedOptionsTimeoutRef.current) { + clearTimeout(autoSaveAdvancedOptionsTimeoutRef.current); + } + + // Debounce the auto-save to prevent excessive API calls + autoSaveAdvancedOptionsTimeoutRef.current = setTimeout(async () => { + setIsAutoSavingAdvancedOptions(true); + + 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) { + // 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 { + setIsAutoSavingAdvancedOptions(false); + } + }, 500); // 500ms debounce + }, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]); + // Cleanup timeouts on unmount useEffect(() => { return () => { @@ -350,6 +478,12 @@ export function ConfigTabs() { if (autoSaveGiteaTimeoutRef.current) { clearTimeout(autoSaveGiteaTimeoutRef.current); } + if (autoSaveMirrorOptionsTimeoutRef.current) { + clearTimeout(autoSaveMirrorOptionsTimeoutRef.current); + } + if (autoSaveAdvancedOptionsTimeoutRef.current) { + clearTimeout(autoSaveAdvancedOptionsTimeoutRef.current); + } }; }, []); @@ -373,6 +507,10 @@ export function ConfigTabs() { response.scheduleConfig || config.scheduleConfig, cleanupConfig: response.cleanupConfig || config.cleanupConfig, + mirrorOptions: + response.mirrorOptions || config.mirrorOptions, + advancedOptions: + response.advancedOptions || config.advancedOptions, }); } @@ -496,72 +634,118 @@ export function ConfigTabs() { {/* Content section */} -
-
- - setConfig(prev => ({ - ...prev, - githubConfig: - typeof update === 'function' - ? update(prev.githubConfig) - : update, - })) - } - onAutoSave={autoSaveGitHubConfig} - isAutoSaving={isAutoSavingGitHub} - /> - - setConfig(prev => ({ - ...prev, - giteaConfig: - typeof update === 'function' - ? update(prev.giteaConfig) - : update, - })) - } - onAutoSave={autoSaveGiteaConfig} - isAutoSaving={isAutoSavingGitea} - /> -
-
-
- + + Connections + Mirror Options + Schedule & Cleanup + Advanced + + + +
+ setConfig(prev => ({ ...prev, - scheduleConfig: + githubConfig: typeof update === 'function' - ? update(prev.scheduleConfig) + ? update(prev.githubConfig) : update, })) } - onAutoSave={autoSaveScheduleConfig} - isAutoSaving={isAutoSavingSchedule} + onAutoSave={autoSaveGitHubConfig} + isAutoSaving={isAutoSavingGitHub} /> -
-
- setConfig(prev => ({ ...prev, - cleanupConfig: + giteaConfig: typeof update === 'function' - ? update(prev.cleanupConfig) + ? update(prev.giteaConfig) : update, })) } - onAutoSave={autoSaveCleanupConfig} - isAutoSaving={isAutoSavingCleanup} + onAutoSave={autoSaveGiteaConfig} + isAutoSaving={isAutoSavingGitea} />
-
-
+ + + + + setConfig(prev => ({ + ...prev, + mirrorOptions: + typeof update === 'function' + ? update(prev.mirrorOptions) + : update, + })) + } + onAutoSave={autoSaveMirrorOptions} + isAutoSaving={isAutoSavingMirrorOptions} + /> + + + +
+
+ + setConfig(prev => ({ + ...prev, + scheduleConfig: + typeof update === 'function' + ? update(prev.scheduleConfig) + : update, + })) + } + onAutoSave={autoSaveScheduleConfig} + isAutoSaving={isAutoSavingSchedule} + /> +
+
+ + setConfig(prev => ({ + ...prev, + cleanupConfig: + typeof update === 'function' + ? update(prev.cleanupConfig) + : update, + })) + } + onAutoSave={autoSaveCleanupConfig} + isAutoSaving={isAutoSavingCleanup} + /> +
+
+
+ + + + setConfig(prev => ({ + ...prev, + advancedOptions: + typeof update === 'function' + ? update(prev.advancedOptions) + : update, + })) + } + onAutoSave={autoSaveAdvancedOptions} + isAutoSaving={isAutoSavingAdvancedOptions} + /> + +
); } diff --git a/src/components/config/GitHubConfigForm.tsx b/src/components/config/GitHubConfigForm.tsx index 6356205..7c820a4 100644 --- a/src/components/config/GitHubConfigForm.tsx +++ b/src/components/config/GitHubConfigForm.tsx @@ -30,21 +30,6 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving } const handleChange = (e: React.ChangeEvent) => { 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", - } - ); - } - const newConfig = { ...config, [name]: type === "checkbox" ? checked : value, @@ -140,32 +125,10 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }

-
-
-
- - handleChange({ - target: { - name: "skipForks", - type: "checkbox", - checked: Boolean(checked), - value: "", - }, - } as React.ChangeEvent) - } - /> - -
+
+

Repository Access

+
- Mirror Private Repos + Include private repos
@@ -210,121 +173,7 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving } htmlFor="mirror-starred" className="ml-2 block text-sm select-none" > - Mirror Starred Repos - -
-
- -
-
- - handleChange({ - target: { - name: "mirrorIssues", - type: "checkbox", - checked: Boolean(checked), - value: "", - }, - } as React.ChangeEvent) - } - /> - -
- -
- - handleChange({ - target: { - name: "mirrorWiki", - type: "checkbox", - checked: Boolean(checked), - value: "", - }, - } as React.ChangeEvent) - } - /> - -
- -
- - handleChange({ - target: { - name: "preserveOrgStructure", - type: "checkbox", - checked: Boolean(checked), - value: "", - }, - } as React.ChangeEvent) - } - /> - -
- -
- - handleChange({ - target: { - name: "skipStarredIssues", - type: "checkbox", - checked: Boolean(checked), - value: "", - }, - } as React.ChangeEvent) - } - /> -
diff --git a/src/components/config/GiteaConfigForm.tsx b/src/components/config/GiteaConfigForm.tsx index f86bead..0f85fa7 100644 --- a/src/components/config/GiteaConfigForm.tsx +++ b/src/components/config/GiteaConfigForm.tsx @@ -14,9 +14,12 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; +import { Checkbox } from "../ui/checkbox"; import { giteaApi } from "@/lib/api"; import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config"; import { toast } from "sonner"; +import { Info } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; interface GiteaConfigFormProps { config: GiteaConfig; @@ -31,10 +34,27 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: const handleChange = ( e: React.ChangeEvent ) => { - const { name, value } = e.target; + 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]: value, + [name]: type === "checkbox" ? checked : value, }; setConfig(newConfig); @@ -153,7 +173,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: htmlFor="organization" className="block text-sm font-medium mb-1.5" > - Default Organization (Optional) + Destination organisation (optional)

- If specified, repositories will be mirrored to this organization. + Repos are created here if no per-repo org is set.

+
+ + handleChange({ + target: { + name: "preserveOrgStructure", + type: "checkbox", + checked: Boolean(checked), + value: "", + }, + } as React.ChangeEvent) + } + /> + +
+
diff --git a/src/components/config/MirrorOptionsForm.tsx b/src/components/config/MirrorOptionsForm.tsx new file mode 100644 index 0000000..ed0cd8c --- /dev/null +++ b/src/components/config/MirrorOptionsForm.tsx @@ -0,0 +1,226 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "../ui/checkbox"; +import type { MirrorOptions } from "@/types/config"; +import { RefreshCw, Info } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; + +interface MirrorOptionsFormProps { + config: MirrorOptions; + setConfig: React.Dispatch>; + onAutoSave?: (config: MirrorOptions) => Promise; + isAutoSaving?: boolean; +} + +export function MirrorOptionsForm({ + config, + setConfig, + onAutoSave, + isAutoSaving = false, +}: MirrorOptionsFormProps) { + const handleChange = (name: string, checked: boolean) => { + let newConfig = { ...config }; + + if (name === "mirrorMetadata") { + newConfig.mirrorMetadata = checked; + // If disabling metadata, also disable all components + if (!checked) { + newConfig.metadataComponents = { + issues: false, + pullRequests: false, + labels: false, + milestones: false, + wiki: false, + }; + } + } else if (name.startsWith("metadataComponents.")) { + const componentName = name.split(".")[1] as keyof typeof config.metadataComponents; + newConfig.metadataComponents = { + ...config.metadataComponents, + [componentName]: checked, + }; + } else { + newConfig = { + ...config, + [name]: checked, + }; + } + + setConfig(newConfig); + + // Auto-save + if (onAutoSave) { + onAutoSave(newConfig); + } + }; + + return ( + + + + Mirror Options + {isAutoSaving && ( +
+ + Auto-saving... +
+ )} +
+
+ + {/* Repository Content */} +
+

Repository Content

+ +
+ + handleChange("mirrorReleases", Boolean(checked)) + } + /> + +
+ +
+ + handleChange("mirrorMetadata", Boolean(checked)) + } + /> + +
+ + {/* Metadata Components */} + {config.mirrorMetadata && ( +
+
+ Metadata Components +
+ +
+
+ + handleChange("metadataComponents.issues", Boolean(checked)) + } + disabled={!config.mirrorMetadata} + /> + +
+ +
+ + handleChange("metadataComponents.pullRequests", Boolean(checked)) + } + disabled={!config.mirrorMetadata} + /> + +
+ +
+ + handleChange("metadataComponents.labels", Boolean(checked)) + } + disabled={!config.mirrorMetadata} + /> + +
+ +
+ + handleChange("metadataComponents.milestones", Boolean(checked)) + } + disabled={!config.mirrorMetadata} + /> + +
+ +
+ + handleChange("metadataComponents.wiki", Boolean(checked)) + } + disabled={!config.mirrorMetadata} + /> + +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/types/config.ts b/src/types/config.ts index fecbe0d..dbdd1b1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -9,6 +9,7 @@ export interface GiteaConfig { organization: string; visibility: GiteaOrgVisibility; starredReposOrg: string; + preserveOrgStructure: boolean; } export interface ScheduleConfig { @@ -28,12 +29,24 @@ export interface DatabaseCleanupConfig { export interface GitHubConfig { username: string; token: string; - skipForks: boolean; privateRepositories: boolean; - mirrorIssues: boolean; - mirrorWiki: boolean; mirrorStarred: boolean; - preserveOrgStructure: boolean; +} + +export interface MirrorOptions { + mirrorReleases: boolean; + mirrorMetadata: boolean; + metadataComponents: { + issues: boolean; + pullRequests: boolean; + labels: boolean; + milestones: boolean; + wiki: boolean; + }; +} + +export interface AdvancedOptions { + skipForks: boolean; skipStarredIssues: boolean; } @@ -43,6 +56,8 @@ export interface SaveConfigApiRequest { giteaConfig: GiteaConfig; scheduleConfig: ScheduleConfig; cleanupConfig: DatabaseCleanupConfig; + mirrorOptions?: MirrorOptions; + advancedOptions?: AdvancedOptions; } export interface SaveConfigApiResponse { @@ -65,6 +80,8 @@ export interface ConfigApiResponse { giteaConfig: GiteaConfig; scheduleConfig: ScheduleConfig; cleanupConfig: DatabaseCleanupConfig; + mirrorOptions?: MirrorOptions; + advancedOptions?: AdvancedOptions; include: string[]; exclude: string[]; createdAt: Date;