Redesign dashboard and configuration screens (#353)
* feat(ui): redesign dashboard and configuration screens New settings design language built from the design/giteamirror.pen file: cards with icon headers and status footers, header-level enable switches, toggle switches instead of checkboxes, uppercase section titles, selection tiles with icon chips and a check on the active option, segmented controls, and an indigo accent. Implemented via shared primitives in src/components/config/settings-ui.tsx and applied across: - Automation: header switches, schedule card, one-line auto-mirror copy with info tooltip, full-width Repository Cleanup card with Skip/Archive/ Delete tiles and dry run row - Notifications: segmented provider picker (ntfy/Apprise/Gotify/Webhook), events card with per-event switches - Connections: GitHub/Gitea connection cards with token creation guide and field helpers, Repository Selection and Mirror Content cards covering every mirror option, Organization Structure card with strategy tiles, destructive update protection tiles (BETA label removed) - Authentication: sign-in methods status card, identity providers restyle - Dashboard: flatter stat cards, icon panel headers, indigo view-all links All existing state handling, autosave and API behavior is unchanged. Light mode keeps working via theme tokens. README and website screenshots regenerated, docs references to renamed cards updated. * fix(ui): design polish pass from local review - Recent Activity rows get status icon circles (check, sync, sparkles, alert) - Connections tab restructured: connection cards share a stretched grid row so GitHub and Gitea stay equal height; Mirror Content moved to the right column; forms split into placeable cards via a part prop - Token guide panel: link moved to header as icon, larger text; redundant card footers removed (scopes line, test-connection hint) - Repository Selection gains a footer note; retention explanation moved below the selector - Authentication tab matches the design: side-by-side cards, row dividers, disabled state-reflecting switches with info hints, footers; SSO dialog restyled (segmented protocol tabs, field labels, indigo primary) - Import GitHub Data button is the indigo primary; disabled state is muted - Time format menu redesigned (locale pill, live examples, live clock in the trigger); theme switcher moved to sidebar as icon segmented control, system preference now persists correctly; legacy ModeToggle removed - Automation timezone pill no longer shows stored legacy UTC as a choice - Config tab bar wraps 2x2 on narrow screens instead of overflowing - README, website and PR screenshots regenerated
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 45 KiB |
@@ -38,7 +38,7 @@ If detection itself fails (GitHub rate limits, network errors, API outages), syn
|
||||
|
||||
## Backup Strategies
|
||||
|
||||
Configure via **Settings → GitHub Configuration → Destructive Update Protection**.
|
||||
Configure via **Configuration → Connections → Destructive Update Protection**.
|
||||
|
||||
| Strategy | What It Does | Storage Cost | Best For |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -10,24 +7,21 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Clock,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Activity,
|
||||
Zap,
|
||||
Info,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
CircleOff,
|
||||
Globe,
|
||||
History,
|
||||
Info,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
@@ -35,6 +29,15 @@ import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
} from "@/lib/utils/schedule-utils";
|
||||
import {
|
||||
SettingsCard,
|
||||
SectionTitle,
|
||||
SwitchRow,
|
||||
OptionTile,
|
||||
StatusFooterItem,
|
||||
CardDivider,
|
||||
CardSection,
|
||||
} from "./settings-ui";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
@@ -82,6 +85,30 @@ function getCleanupFrequencyText(retentionSeconds: number): string {
|
||||
return "weekly";
|
||||
}
|
||||
|
||||
const orphanActions = [
|
||||
{
|
||||
value: "skip" as const,
|
||||
label: "Skip",
|
||||
description: "Leave the mirror untouched",
|
||||
icon: CircleOff,
|
||||
info: "Orphaned mirrors stay exactly as they are and keep their sync settings.",
|
||||
},
|
||||
{
|
||||
value: "archive" as const,
|
||||
label: "Archive",
|
||||
description: "Kept read-only with an archived prefix",
|
||||
icon: Archive,
|
||||
info: "Renames the mirror with an archived- prefix and disables automatic syncs. Nothing is lost; use Manual Sync to refresh.",
|
||||
},
|
||||
{
|
||||
value: "delete" as const,
|
||||
label: "Delete",
|
||||
description: "Remove the mirror from Gitea",
|
||||
icon: Trash2,
|
||||
info: "Permanently deletes the mirror repository from Gitea.",
|
||||
},
|
||||
];
|
||||
|
||||
export function AutomationSettings({
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
@@ -98,8 +125,13 @@ export function AutomationSettings({
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
: "UTC";
|
||||
|
||||
// Use saved timezone, but treat "UTC" as unset for users who never chose it
|
||||
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
|
||||
// Use saved timezone, but treat "UTC" as unset for users who never chose
|
||||
// it: older versions stored UTC as a default without asking. Anyone truly
|
||||
// in UTC gets the same result via their browser timezone.
|
||||
const effectiveTimezone =
|
||||
scheduleConfig.timezone && scheduleConfig.timezone !== "UTC"
|
||||
? scheduleConfig.timezone
|
||||
: browserTimezone;
|
||||
|
||||
const nextScheduledRun = useMemo(() => {
|
||||
if (!scheduleConfig.enabled) return null;
|
||||
@@ -123,429 +155,291 @@ export function AutomationSettings({
|
||||
}
|
||||
}, [cleanupConfig.enabled, cleanupConfig.retentionDays]);
|
||||
|
||||
const savingSpinner = (saving?: boolean) =>
|
||||
saving ? (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : undefined;
|
||||
|
||||
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="flex flex-col gap-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="flex-1 flex flex-col gap-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,
|
||||
timezone: checked ? browserTimezone : scheduleConfig.timezone,
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
|
||||
scheduleMode: "clock",
|
||||
})
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 items-stretch gap-6 md:grid-cols-2">
|
||||
{/* Automatic Syncing */}
|
||||
<SettingsCard
|
||||
icon={RefreshCw}
|
||||
title="Automatic Syncing"
|
||||
enabled={scheduleConfig.enabled}
|
||||
onEnabledChange={(checked) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
enabled: checked,
|
||||
timezone: checked ? browserTimezone : scheduleConfig.timezone,
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
|
||||
scheduleMode: "clock",
|
||||
})
|
||||
}
|
||||
headerAction={savingSpinner(isAutoSavingSchedule)}
|
||||
footer={
|
||||
<>
|
||||
<StatusFooterItem
|
||||
icon={History}
|
||||
label="Last sync"
|
||||
value={
|
||||
scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"
|
||||
}
|
||||
/>
|
||||
{scheduleConfig.enabled ? (
|
||||
<StatusFooterItem
|
||||
icon={Calendar}
|
||||
label="Next sync"
|
||||
value={
|
||||
scheduleConfig.nextRun
|
||||
? formatDate(scheduleConfig.nextRun)
|
||||
: nextScheduledRun
|
||||
? formatDate(nextScheduledRun)
|
||||
: "Calculating..."
|
||||
}
|
||||
valueClassName="text-indigo-500"
|
||||
/>
|
||||
<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 sync GitHub changes to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Schedule
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
Enable syncing to schedule updates
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{scheduleConfig.enabled && (
|
||||
<>
|
||||
<CardSection>
|
||||
<SectionTitle
|
||||
action={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{effectiveTimezone}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Schedule
|
||||
</SectionTitle>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-frequency"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={String(scheduleConfig.clockFrequencyHours || 24)}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: parseInt(value, 10),
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="clock-frequency" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clockFrequencies.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-frequency"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={String(scheduleConfig.clockFrequencyHours || 24)}
|
||||
onValueChange={(value) =>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-start-time"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Start time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
|
||||
<Clock className="size-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="clock-start-time"
|
||||
type="time"
|
||||
value={scheduleConfig.startTime || "22:00"}
|
||||
onChange={(event) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: parseInt(value, 10),
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
startTime: event.target.value,
|
||||
clockFrequencyHours:
|
||||
scheduleConfig.clockFrequencyHours || 24,
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="clock-frequency" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clockFrequencies.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-start-time"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Start time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
|
||||
<Clock className="size-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="clock-start-time"
|
||||
type="time"
|
||||
value={scheduleConfig.startTime || "22:00"}
|
||||
onChange={(event) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
startTime: event.target.value,
|
||||
clockFrequencyHours:
|
||||
scheduleConfig.clockFrequencyHours || 24,
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-1">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror-new"
|
||||
checked={scheduleConfig.autoMirror ?? false}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
autoMirror: !!checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="enable-auto-mirror-new"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Auto-mirror new repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically mirror newly imported repositories on each scheduled sync. When off, new repos are imported for browsing but require a manual mirror click. (Starred repos have their own toggle in GitHub settings.)
|
||||
</p>
|
||||
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync{" "}
|
||||
<span className="font-medium">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</span>
|
||||
{scheduleConfig.enabled ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{scheduleConfig.nextRun
|
||||
? formatDate(scheduleConfig.nextRun)
|
||||
: nextScheduledRun
|
||||
? formatDate(nextScheduledRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Enable syncing to schedule updates</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="flex flex-col gap-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="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
checked={cleanupConfig.enabled}
|
||||
className="mt-1.25"
|
||||
</CardSection>
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="Auto-mirror new repositories"
|
||||
description="Mirror repos discovered during sync automatically"
|
||||
info="When off, newly discovered repos are imported for browsing and wait for a manual mirror click. Starred repos have their own toggle in GitHub settings."
|
||||
checked={scheduleConfig.autoMirror ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
|
||||
onScheduleChange({ ...scheduleConfig, autoMirror: 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 to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="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="mt-auto space-y-2 pt-3 border-t 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" />
|
||||
</CardSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<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>
|
||||
{/* Database Maintenance */}
|
||||
<SettingsCard
|
||||
icon={Database}
|
||||
title="Database Maintenance"
|
||||
enabled={cleanupConfig.enabled}
|
||||
onEnabledChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, enabled: checked })
|
||||
}
|
||||
headerAction={savingSpinner(isAutoSavingCleanup)}
|
||||
className="md:h-full"
|
||||
footer={
|
||||
<>
|
||||
<StatusFooterItem
|
||||
icon={History}
|
||||
label="Last cleanup"
|
||||
value={
|
||||
cleanupConfig.lastRun
|
||||
? formatDate(cleanupConfig.lastRun)
|
||||
: "Never"
|
||||
}
|
||||
/>
|
||||
{cleanupConfig.enabled && cleanupConfig.nextRun ? (
|
||||
<StatusFooterItem
|
||||
icon={Calendar}
|
||||
label="Next cleanup"
|
||||
value={formatDate(cleanupConfig.nextRun)}
|
||||
valueClassName="text-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
Old activity logs are removed automatically
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{cleanupConfig.enabled && (
|
||||
<CardSection>
|
||||
<SectionTitle>Data retention</SectionTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={cleanupConfig.orphanedRepoAction ?? "archive"}
|
||||
value={cleanupConfig.retentionDays.toString()}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
orphanedRepoAction: value as DatabaseCleanupConfig["orphanedRepoAction"],
|
||||
retentionDays: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="cleanup-orphaned-action">
|
||||
<SelectTrigger id="retention-period" className="w-40">
|
||||
<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>
|
||||
{retentionPeriods.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</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.
|
||||
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
|
||||
</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>
|
||||
<p className="max-w-md text-[13px] leading-relaxed text-muted-foreground">
|
||||
Activity logs and events older than the retention period are
|
||||
removed. Cleanup frequency adapts to the period you pick.
|
||||
</p>
|
||||
</CardSection>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
{/* Repository Cleanup */}
|
||||
<SettingsCard
|
||||
icon={ArchiveRestore}
|
||||
title="Repository Cleanup"
|
||||
enabled={Boolean(cleanupConfig.deleteIfNotInGitHub)}
|
||||
onEnabledChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, deleteIfNotInGitHub: checked })
|
||||
}
|
||||
headerAction={savingSpinner(isAutoSavingCleanup)}
|
||||
footer={
|
||||
<StatusFooterItem
|
||||
icon={Info}
|
||||
label="Runs as part of each scheduled sync"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{cleanupConfig.deleteIfNotInGitHub && (
|
||||
<>
|
||||
<CardSection>
|
||||
<SectionTitle>When a GitHub repo is deleted</SectionTitle>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{orphanActions.map((action) => (
|
||||
<OptionTile
|
||||
key={action.value}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
description={action.description}
|
||||
info={action.info}
|
||||
selected={
|
||||
(cleanupConfig.orphanedRepoAction ?? "archive") ===
|
||||
action.value
|
||||
}
|
||||
onSelect={() =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
orphanedRepoAction: action.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardSection>
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="Dry run"
|
||||
description="Log planned actions without changing anything"
|
||||
checked={Boolean(cleanupConfig.dryRun)}
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, dryRun: checked })
|
||||
}
|
||||
/>
|
||||
</CardSection>
|
||||
</>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { GitHubMirrorSettings } from './GitHubMirrorSettings';
|
||||
import { AutomationSettings } from './AutomationSettings';
|
||||
import { SSOSettings } from './SSOSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
@@ -686,7 +687,7 @@ export function ConfigTabs() {
|
||||
? 'Import in progress'
|
||||
: 'Import GitHub Data'
|
||||
}
|
||||
className="w-full md:w-auto"
|
||||
className="w-full bg-indigo-500 text-white hover:bg-indigo-600 disabled:bg-muted disabled:text-muted-foreground md:w-auto"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
@@ -705,78 +706,106 @@ export function ConfigTabs() {
|
||||
|
||||
{/* Content section - Tabs layout */}
|
||||
<Tabs defaultValue="connections" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
|
||||
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||
<TabsTrigger value="automation">Automation</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notifications</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 =>
|
||||
<TabsContent value="connections" className="space-y-6">
|
||||
{(() => {
|
||||
const githubFormProps = {
|
||||
config: config.githubConfig,
|
||||
setConfig: (update: React.SetStateAction<typeof config.githubConfig>) =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
githubConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.githubConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
mirrorOptions={config.mirrorOptions}
|
||||
setMirrorOptions={update =>
|
||||
})),
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
setMirrorOptions: (update: React.SetStateAction<typeof config.mirrorOptions>) =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
mirrorOptions:
|
||||
typeof update === 'function'
|
||||
? update(prev.mirrorOptions)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
advancedOptions={config.advancedOptions}
|
||||
setAdvancedOptions={update =>
|
||||
})),
|
||||
advancedOptions: config.advancedOptions,
|
||||
setAdvancedOptions: (update: React.SetStateAction<typeof config.advancedOptions>) =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
advancedOptions:
|
||||
typeof update === 'function'
|
||||
? update(prev.advancedOptions)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
giteaConfig={config.giteaConfig}
|
||||
setGiteaConfig={update =>
|
||||
})),
|
||||
giteaConfig: config.giteaConfig,
|
||||
setGiteaConfig: (update: React.SetStateAction<typeof config.giteaConfig>) =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
giteaConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.giteaConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGitHubConfig}
|
||||
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
|
||||
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
|
||||
onGiteaAutoSave={autoSaveGiteaConfig}
|
||||
isAutoSaving={isAutoSavingGitHub}
|
||||
/>
|
||||
<GiteaConfigForm
|
||||
config={config.giteaConfig}
|
||||
setConfig={update =>
|
||||
})),
|
||||
onAutoSave: autoSaveGitHubConfig,
|
||||
onMirrorOptionsAutoSave: autoSaveMirrorOptions,
|
||||
onAdvancedOptionsAutoSave: autoSaveAdvancedOptions,
|
||||
onGiteaAutoSave: autoSaveGiteaConfig,
|
||||
isAutoSaving: isAutoSavingGitHub,
|
||||
};
|
||||
const giteaFormProps = {
|
||||
config: config.giteaConfig,
|
||||
setConfig: (update: React.SetStateAction<typeof config.giteaConfig>) =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
giteaConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.giteaConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGiteaConfig}
|
||||
isAutoSaving={isAutoSavingGitea}
|
||||
githubUsername={config.githubConfig.username}
|
||||
/>
|
||||
</div>
|
||||
})),
|
||||
onAutoSave: autoSaveGiteaConfig,
|
||||
isAutoSaving: isAutoSavingGitea,
|
||||
githubUsername: config.githubConfig.username,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* Connection cards share a stretched row so they stay equal height */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<GitHubConfigForm {...githubFormProps} part="connection" />
|
||||
<GiteaConfigForm {...giteaFormProps} part="connection" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:items-start">
|
||||
<GitHubConfigForm {...githubFormProps} part="settings" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<GiteaConfigForm {...giteaFormProps} part="organization" />
|
||||
<GitHubMirrorSettings
|
||||
part="content"
|
||||
githubConfig={config.githubConfig}
|
||||
mirrorOptions={config.mirrorOptions}
|
||||
advancedOptions={config.advancedOptions}
|
||||
onGitHubConfigChange={newConfig => {
|
||||
setConfig(prev => ({ ...prev, githubConfig: newConfig }));
|
||||
autoSaveGitHubConfig(newConfig);
|
||||
}}
|
||||
onMirrorOptionsChange={newOptions => {
|
||||
setConfig(prev => ({ ...prev, mirrorOptions: newOptions }));
|
||||
autoSaveMirrorOptions(newOptions);
|
||||
}}
|
||||
onAdvancedOptionsChange={newOptions => {
|
||||
setConfig(prev => ({ ...prev, advancedOptions: newOptions }));
|
||||
autoSaveAdvancedOptions(newOptions);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="automation" className="space-y-4">
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
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, GiteaConfig, BackupStrategy } from "@/types/config";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Info, ShieldAlert } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
Activity,
|
||||
CircleOff,
|
||||
DatabaseBackup,
|
||||
ExternalLink,
|
||||
Hand,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { SiGithub } from "react-icons/si";
|
||||
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
|
||||
import {
|
||||
SettingsCard,
|
||||
SectionTitle,
|
||||
SwitchRow,
|
||||
OptionTile,
|
||||
StatusFooterItem,
|
||||
CardDivider,
|
||||
CardSection,
|
||||
} from "./settings-ui";
|
||||
|
||||
interface GitHubConfigFormProps {
|
||||
config: GitHubConfig;
|
||||
@@ -34,9 +43,43 @@ interface GitHubConfigFormProps {
|
||||
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
|
||||
onGiteaAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
/** Which card group to render: the connection card, or the settings stack
|
||||
* (repository selection + destructive update protection). */
|
||||
part?: "connection" | "settings";
|
||||
}
|
||||
|
||||
export function GitHubConfigForm({
|
||||
const backupStrategies = [
|
||||
{
|
||||
value: "disabled" as const,
|
||||
label: "Disabled",
|
||||
description: "Force-pushes sync straight through",
|
||||
icon: CircleOff,
|
||||
info: "No detection or backups. Rewritten upstream history overwrites your mirror.",
|
||||
},
|
||||
{
|
||||
value: "always" as const,
|
||||
label: "Always Backup",
|
||||
description: "Snapshot before every sync",
|
||||
icon: DatabaseBackup,
|
||||
info: "Maximum safety at the cost of disk usage.",
|
||||
},
|
||||
{
|
||||
value: "on-force-push" as const,
|
||||
label: "Smart",
|
||||
description: "Snapshot only on history rewrites",
|
||||
icon: Sparkles,
|
||||
info: "Backs up only when a force-push is detected.",
|
||||
},
|
||||
{
|
||||
value: "block-on-force-push" as const,
|
||||
label: "Block & Approve",
|
||||
description: "Hold sync until you approve",
|
||||
icon: Hand,
|
||||
info: "Force-pushed repos pause syncing until you approve the update.",
|
||||
},
|
||||
];
|
||||
|
||||
export function GitHubConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
mirrorOptions,
|
||||
@@ -49,7 +92,8 @@ export function GitHubConfigForm({
|
||||
onMirrorOptionsAutoSave,
|
||||
onAdvancedOptionsAutoSave,
|
||||
onGiteaAutoSave,
|
||||
isAutoSaving
|
||||
isAutoSaving,
|
||||
part = "connection"
|
||||
}: GitHubConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -93,289 +137,254 @@ export function GitHubConfigForm({
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
const updateGitea = (newConfig: GiteaConfig) => {
|
||||
if (!setGiteaConfig) return;
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
};
|
||||
|
||||
<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>
|
||||
const backupStrategy = giteaConfig?.backupStrategy ?? "on-force-push";
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="block text-sm font-medium"
|
||||
if (part === "connection") {
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={SiGithub}
|
||||
title="GitHub Connection"
|
||||
headerAction={
|
||||
<div className="flex items-center gap-3">
|
||||
{isAutoSaving && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={isLoading || !config.token}
|
||||
>
|
||||
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" />
|
||||
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isLoading ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CardSection>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="github-username"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="github-username"
|
||||
name="username"
|
||||
type="text"
|
||||
value={config.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Your GitHub username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="github-token"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Personal access token
|
||||
</Label>
|
||||
<Input
|
||||
id="github-token"
|
||||
name="token"
|
||||
type="password"
|
||||
value={config.token}
|
||||
onChange={handleChange}
|
||||
placeholder="Your GitHub token (classic)"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
Needed for private repos, organizations, and starred repositories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-semibold text-muted-foreground">
|
||||
Creating your token
|
||||
</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>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open github.com/settings/tokens"
|
||||
aria-label="Open GitHub token settings"
|
||||
className="text-indigo-500 hover:text-indigo-400"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<ol className="list-decimal space-y-1.5 pl-4 text-xs leading-relaxed text-muted-foreground">
|
||||
<li>GitHub → Settings → Developer settings</li>
|
||||
<li>Personal access tokens → Generate new token (classic)</li>
|
||||
<li>Select the scopes below and paste the token here</li>
|
||||
</ol>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
repo
|
||||
</code>
|
||||
<code className="rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
admin:org
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<GitHubMirrorSettings
|
||||
part="selection"
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Destructive Update Protection */}
|
||||
{giteaConfig && setGiteaConfig && (
|
||||
<SettingsCard
|
||||
icon={ShieldAlert}
|
||||
title="Destructive Update Protection"
|
||||
footer={
|
||||
<StatusFooterItem
|
||||
icon={ShieldCheck}
|
||||
label="Applies to Always Backup and Smart modes"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CardSection>
|
||||
<SectionTitle>How to handle force-pushes on GitHub</SectionTitle>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{backupStrategies.map((opt) => (
|
||||
<OptionTile
|
||||
key={opt.value}
|
||||
icon={opt.icon}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
info={opt.info}
|
||||
selected={backupStrategy === opt.value}
|
||||
onSelect={() =>
|
||||
updateGitea({
|
||||
...giteaConfig,
|
||||
backupStrategy: opt.value as BackupStrategy,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
{backupStrategy !== "disabled" && (
|
||||
<>
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="backup-retention"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Snapshot retention count
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-retention"
|
||||
name="backupRetentionCount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={giteaConfig.backupRetentionCount ?? 5}
|
||||
onChange={(e) =>
|
||||
updateGitea({
|
||||
...giteaConfig,
|
||||
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="backup-retention-days"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Snapshot retention days
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-retention-days"
|
||||
name="backupRetentionDays"
|
||||
type="number"
|
||||
min={0}
|
||||
value={giteaConfig.backupRetentionDays ?? 30}
|
||||
onChange={(e) =>
|
||||
updateGitea({
|
||||
...giteaConfig,
|
||||
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
0 = no time-based limit
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="backup-directory"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Snapshot directory
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-directory"
|
||||
name="backupDirectory"
|
||||
type="text"
|
||||
value={giteaConfig.backupDirectory || "data/repo-backups"}
|
||||
onChange={(e) =>
|
||||
updateGitea({ ...giteaConfig, backupDirectory: e.target.value })
|
||||
}
|
||||
placeholder="data/repo-backups"
|
||||
/>
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
|
||||
{giteaConfig && setGiteaConfig && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-primary" />
|
||||
Destructive Update Protection
|
||||
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how to handle force-pushes or rewritten upstream history on GitHub.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "disabled",
|
||||
label: "Disabled",
|
||||
desc: "No detection or backups",
|
||||
},
|
||||
{
|
||||
value: "always",
|
||||
label: "Always Backup",
|
||||
desc: "Snapshot before every sync (high disk usage)",
|
||||
},
|
||||
{
|
||||
value: "on-force-push",
|
||||
label: "Smart",
|
||||
desc: "Backup only on force-push",
|
||||
},
|
||||
{
|
||||
value: "block-on-force-push",
|
||||
label: "Block & Approve",
|
||||
desc: "Require approval on force-push",
|
||||
},
|
||||
] as const).map((opt) => {
|
||||
const isSelected = (giteaConfig.backupStrategy ?? "on-force-push") === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newConfig = { ...giteaConfig, backupStrategy: opt.value as BackupStrategy };
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className={`flex flex-col items-start gap-1 rounded-lg border p-3 text-left text-sm transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-input hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot retention count
|
||||
</label>
|
||||
<input
|
||||
id="backup-retention"
|
||||
name="backupRetentionCount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={giteaConfig.backupRetentionCount ?? 5}
|
||||
onChange={(e) => {
|
||||
const newConfig = {
|
||||
...giteaConfig,
|
||||
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
|
||||
};
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backup-retention-days" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot retention days
|
||||
</label>
|
||||
<input
|
||||
id="backup-retention-days"
|
||||
name="backupRetentionDays"
|
||||
type="number"
|
||||
min={0}
|
||||
value={giteaConfig.backupRetentionDays ?? 30}
|
||||
onChange={(e) => {
|
||||
const newConfig = {
|
||||
...giteaConfig,
|
||||
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
|
||||
};
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = no time-based limit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot directory
|
||||
</label>
|
||||
<input
|
||||
id="backup-directory"
|
||||
name="backupDirectory"
|
||||
type="text"
|
||||
value={giteaConfig.backupDirectory || "data/repo-backups"}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...giteaConfig, backupDirectory: e.target.value };
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
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="data/repo-backups"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{((giteaConfig.backupStrategy ?? "on-force-push") === "always" ||
|
||||
(giteaConfig.backupStrategy ?? "on-force-push") === "on-force-push") && (
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
name="blockSyncOnBackupFailure"
|
||||
type="checkbox"
|
||||
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...giteaConfig, blockSyncOnBackupFailure: e.target.checked };
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className="mt-0.5 rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Block sync when snapshot fails
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommended for backup-first behavior. If disabled, sync continues even when snapshot creation fails.
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{(backupStrategy === "always" || backupStrategy === "on-force-push") && (
|
||||
<SwitchRow
|
||||
label="Block sync when snapshot fails"
|
||||
description="Recommended so a failed backup never lets a destructive sync through"
|
||||
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
|
||||
onCheckedChange={(checked) =>
|
||||
updateGitea({ ...giteaConfig, blockSyncOnBackupFailure: checked })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardSection>
|
||||
</>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Activity, AlertTriangle, Building2, Info, PlugZap, Server } from "lucide-react";
|
||||
import { giteaApi, type GiteaServerInfo } 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";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
SettingsCard,
|
||||
StatusFooterItem,
|
||||
CardDivider,
|
||||
CardSection,
|
||||
} from "./settings-ui";
|
||||
|
||||
interface GiteaConfigFormProps {
|
||||
config: GiteaConfig;
|
||||
@@ -21,12 +22,14 @@ interface GiteaConfigFormProps {
|
||||
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
githubUsername?: string;
|
||||
/** Which card to render: the connection card or the organization card. */
|
||||
part?: "connection" | "organization";
|
||||
}
|
||||
|
||||
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
|
||||
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername, part = "connection" }: GiteaConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState<GiteaServerInfo | null>(null);
|
||||
|
||||
|
||||
// Derive the mirror strategy from existing config for backward compatibility
|
||||
const getMirrorStrategy = (): MirrorStrategy => {
|
||||
if (config.mirrorStrategy) return config.mirrorStrategy;
|
||||
@@ -36,13 +39,15 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
if (config.organization && config.organization !== config.username) return "single-org";
|
||||
return "flat-user";
|
||||
};
|
||||
|
||||
|
||||
const [mirrorStrategy, setMirrorStrategy] = useState<MirrorStrategy>(getMirrorStrategy());
|
||||
|
||||
// Update config when strategy changes
|
||||
|
||||
// Update config when strategy changes. Only the organization instance runs
|
||||
// this effect so a second (connection) instance can't double-fire autosave.
|
||||
useEffect(() => {
|
||||
if (part !== "organization") return;
|
||||
const newConfig = { ...config };
|
||||
|
||||
|
||||
switch (mirrorStrategy) {
|
||||
case "preserve":
|
||||
newConfig.preserveOrgStructure = true;
|
||||
@@ -75,7 +80,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
@@ -149,191 +154,206 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
}
|
||||
};
|
||||
|
||||
if (part === "connection") {
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Server}
|
||||
title="Gitea Connection"
|
||||
headerAction={
|
||||
<div className="flex items-center gap-3">
|
||||
{isAutoSaving && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={isLoading || !config.url || !config.token}
|
||||
>
|
||||
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isLoading ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
serverInfo ? (
|
||||
<StatusFooterItem
|
||||
icon={Info}
|
||||
label={`${serverInfo.type === "forgejo" ? "Forgejo" : "Gitea"} ${serverInfo.version} detected`}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CardSection>
|
||||
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
Forgejo {serverInfo.version} has a known mirror-credential bug
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
|
||||
<a
|
||||
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
PR #11909
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<p>
|
||||
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="gitea-username"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="gitea-username"
|
||||
name="username"
|
||||
type="text"
|
||||
value={config.username}
|
||||
onChange={handleChange}
|
||||
placeholder="Your Gitea username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="gitea-url"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="gitea-url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={config.url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://your-gitea-instance.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="gitea-external-url"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
External URL (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="gitea-external-url"
|
||||
name="externalUrl"
|
||||
type="url"
|
||||
value={config.externalUrl || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
Dashboard links only, syncing always uses the server URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="gitea-token"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Access token
|
||||
</Label>
|
||||
<Input
|
||||
id="gitea-token"
|
||||
name="token"
|
||||
type="password"
|
||||
value={config.token}
|
||||
onChange={handleChange}
|
||||
placeholder="Your Gitea access token"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
Create one in Gitea under Settings → Applications
|
||||
</p>
|
||||
</div>
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
Forgejo {serverInfo.version} has a known mirror-credential bug
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
|
||||
<a
|
||||
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
PR #11909
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<p>
|
||||
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<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
|
||||
<>
|
||||
{/* Organization Structure */}
|
||||
<SettingsCard
|
||||
icon={Building2}
|
||||
title="Organization Structure"
|
||||
footer={
|
||||
<StatusFooterItem
|
||||
icon={Info}
|
||||
label="Strategy applies to newly mirrored repositories"
|
||||
/>
|
||||
</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
|
||||
}
|
||||
>
|
||||
<CardSection>
|
||||
<OrganizationStrategy
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
starredReposMode={config.starredReposMode}
|
||||
onStrategyChange={setMirrorStrategy}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-external-url"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Gitea External URL (optional)
|
||||
</label>
|
||||
<input
|
||||
id="gitea-external-url"
|
||||
name="externalUrl"
|
||||
type="url"
|
||||
value={config.externalUrl || ""}
|
||||
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://gitea.example.com"
|
||||
</CardSection>
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<OrganizationConfiguration
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
starredReposMode={config.starredReposMode}
|
||||
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);
|
||||
}}
|
||||
onStarredReposModeChange={(mode) => {
|
||||
const newConfig = { ...config, starredReposMode: mode };
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Used only for dashboard links. API sync still uses Gitea URL.
|
||||
</p>
|
||||
</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}
|
||||
starredReposMode={config.starredReposMode}
|
||||
onStrategyChange={setMirrorStrategy}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<OrganizationConfiguration
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
starredReposMode={config.starredReposMode}
|
||||
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);
|
||||
}}
|
||||
onStarredReposModeChange={(mode) => {
|
||||
const newConfig = { ...config, starredReposMode: mode };
|
||||
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>
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -9,12 +8,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bell, Activity, Send } from "lucide-react";
|
||||
import { Bell, Activity, Send, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
SettingsCard,
|
||||
SectionTitle,
|
||||
SwitchRow,
|
||||
SegmentedControl,
|
||||
StatusFooterItem,
|
||||
CardDivider,
|
||||
CardSection,
|
||||
} from "./settings-ui";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
notificationConfig: NotificationConfig;
|
||||
@@ -22,6 +29,42 @@ interface NotificationSettingsProps {
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
type Provider = "ntfy" | "apprise" | "gotify" | "webhook";
|
||||
|
||||
const providerOptions: { value: Provider; label: string }[] = [
|
||||
{ value: "ntfy", label: "Ntfy.sh" },
|
||||
{ value: "apprise", label: "Apprise" },
|
||||
{ value: "gotify", label: "Gotify" },
|
||||
{ value: "webhook", label: "Webhook" },
|
||||
];
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
helper,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
helper?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
{helper && (
|
||||
<p className="text-[11px] text-muted-foreground/80">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettings({
|
||||
notificationConfig,
|
||||
onNotificationChange,
|
||||
@@ -53,71 +96,71 @@ export function NotificationSettings({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
{isAutoSaving && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Enable/disable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifications-enabled" className="text-sm font-medium cursor-pointer">
|
||||
Enable notifications
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Receive alerts when mirror jobs complete or fail
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications-enabled"
|
||||
checked={notificationConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notificationConfig.enabled && (
|
||||
<>
|
||||
{/* Provider selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notification-provider" className="text-sm font-medium">
|
||||
Notification provider
|
||||
</Label>
|
||||
<Select
|
||||
value={notificationConfig.provider}
|
||||
onValueChange={(value: "ntfy" | "apprise" | "gotify" | "webhook") =>
|
||||
onNotificationChange({ ...notificationConfig, provider: value })
|
||||
}
|
||||
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
|
||||
<SettingsCard
|
||||
icon={Bell}
|
||||
title="Notifications"
|
||||
enabled={notificationConfig.enabled}
|
||||
onEnabledChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, enabled: checked })
|
||||
}
|
||||
headerAction={
|
||||
isAutoSaving ? (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
notificationConfig.enabled ? (
|
||||
<>
|
||||
<StatusFooterItem
|
||||
icon={Info}
|
||||
label="Sends a test message with your current settings"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleTestNotification}
|
||||
disabled={isTesting}
|
||||
className="bg-indigo-500 text-white hover:bg-indigo-600"
|
||||
>
|
||||
<SelectTrigger id="notification-provider">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
|
||||
<SelectItem value="apprise">Apprise API</SelectItem>
|
||||
<SelectItem value="gotify">Gotify</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Activity className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Send test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<StatusFooterItem
|
||||
icon={Info}
|
||||
label="Get alerted when mirror jobs complete or fail"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{notificationConfig.enabled && (
|
||||
<CardSection>
|
||||
<SectionTitle>Provider</SectionTitle>
|
||||
<SegmentedControl
|
||||
options={providerOptions}
|
||||
value={notificationConfig.provider}
|
||||
onChange={(value) =>
|
||||
onNotificationChange({ ...notificationConfig, provider: value })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Ntfy configuration */}
|
||||
{notificationConfig.provider === "ntfy" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Ntfy.sh Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-url" className="text-sm">
|
||||
Server URL
|
||||
</Label>
|
||||
<div className="space-y-4 pt-2">
|
||||
<Field
|
||||
id="ntfy-url"
|
||||
label="Server URL"
|
||||
helper="Use https://ntfy.sh for the public server or your self-hosted instance URL"
|
||||
>
|
||||
<Input
|
||||
id="ntfy-url"
|
||||
type="url"
|
||||
@@ -135,15 +178,13 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use https://ntfy.sh for the public server or your self-hosted instance URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-topic" className="text-sm">
|
||||
Topic <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="ntfy-topic"
|
||||
label="Topic"
|
||||
required
|
||||
helper="Choose a unique topic name. Anyone with the topic name can subscribe."
|
||||
>
|
||||
<Input
|
||||
id="ntfy-topic"
|
||||
placeholder="gitea-mirror"
|
||||
@@ -160,15 +201,12 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a unique topic name. Anyone with the topic name can subscribe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-token" className="text-sm">
|
||||
Access token (optional)
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="ntfy-token"
|
||||
label="Access token (optional)"
|
||||
helper="Required if your ntfy server uses authentication"
|
||||
>
|
||||
<Input
|
||||
id="ntfy-token"
|
||||
type="password"
|
||||
@@ -187,15 +225,12 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required if your ntfy server uses authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-priority" className="text-sm">
|
||||
Default priority
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="ntfy-priority"
|
||||
label="Default priority"
|
||||
helper='Error notifications always use "high" priority regardless of this setting'
|
||||
>
|
||||
<Select
|
||||
value={notificationConfig.ntfy?.priority || "default"}
|
||||
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
|
||||
@@ -221,22 +256,18 @@ export function NotificationSettings({
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error notifications always use "high" priority regardless of this setting
|
||||
</p>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apprise configuration */}
|
||||
{notificationConfig.provider === "apprise" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Apprise API Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-url" className="text-sm">
|
||||
Server URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="space-y-4 pt-2">
|
||||
<Field
|
||||
id="apprise-url"
|
||||
label="Server URL"
|
||||
required
|
||||
helper="URL of your Apprise API server (e.g., http://apprise:8000)"
|
||||
>
|
||||
<Input
|
||||
id="apprise-url"
|
||||
type="url"
|
||||
@@ -253,15 +284,13 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL of your Apprise API server (e.g., http://apprise:8000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-token" className="text-sm">
|
||||
Token / path <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="apprise-token"
|
||||
label="Token / path"
|
||||
required
|
||||
helper="The Apprise API configuration token or key"
|
||||
>
|
||||
<Input
|
||||
id="apprise-token"
|
||||
placeholder="gitea-mirror"
|
||||
@@ -277,15 +306,12 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Apprise API configuration token or key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-tag" className="text-sm">
|
||||
Tag filter (optional)
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="apprise-tag"
|
||||
label="Tag filter (optional)"
|
||||
helper="Optional tag to filter which Apprise services receive notifications"
|
||||
>
|
||||
<Input
|
||||
id="apprise-tag"
|
||||
placeholder="all"
|
||||
@@ -302,22 +328,18 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional tag to filter which Apprise services receive notifications
|
||||
</p>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gotify configuration */}
|
||||
{notificationConfig.provider === "gotify" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Gotify Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gotify-url" className="text-sm">
|
||||
Server URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="space-y-4 pt-2">
|
||||
<Field
|
||||
id="gotify-url"
|
||||
label="Server URL"
|
||||
required
|
||||
helper="URL of your Gotify server"
|
||||
>
|
||||
<Input
|
||||
id="gotify-url"
|
||||
type="url"
|
||||
@@ -335,15 +357,13 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL of your Gotify server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gotify-token" className="text-sm">
|
||||
Application token <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="gotify-token"
|
||||
label="Application token"
|
||||
required
|
||||
helper="Create an application in Gotify and paste its token here"
|
||||
>
|
||||
<Input
|
||||
id="gotify-token"
|
||||
type="password"
|
||||
@@ -361,15 +381,12 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an application in Gotify and paste its token here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gotify-priority" className="text-sm">
|
||||
Default priority (0-10)
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="gotify-priority"
|
||||
label="Default priority (0-10)"
|
||||
helper="Error notifications always use priority 8 regardless of this setting"
|
||||
>
|
||||
<Input
|
||||
id="gotify-priority"
|
||||
type="number"
|
||||
@@ -388,22 +405,18 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error notifications always use priority 8 regardless of this setting
|
||||
</p>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook configuration */}
|
||||
{notificationConfig.provider === "webhook" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Webhook Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url" className="text-sm">
|
||||
Webhook URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="space-y-4 pt-2">
|
||||
<Field
|
||||
id="webhook-url"
|
||||
label="Webhook URL"
|
||||
required
|
||||
helper="Notifications are sent as a JSON POST with title, message, type, and timestamp fields"
|
||||
>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
type="url"
|
||||
@@ -419,15 +432,12 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notifications are sent as a JSON POST with title, message, type, and timestamp fields
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-secret" className="text-sm">
|
||||
Signing secret (optional)
|
||||
</Label>
|
||||
</Field>
|
||||
<Field
|
||||
id="webhook-secret"
|
||||
label="Signing secret (optional)"
|
||||
helper="If set, requests include an X-Webhook-Signature header with an HMAC-SHA256 hex digest of the body (sha256=...)"
|
||||
>
|
||||
<Input
|
||||
id="webhook-secret"
|
||||
type="password"
|
||||
@@ -444,96 +454,69 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If set, requests include an X-Webhook-Signature header with an HMAC-SHA256 hex digest of the body (sha256=...)
|
||||
</p>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event toggles */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Notification Events</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-sync-error" className="text-sm font-normal cursor-pointer">
|
||||
Sync errors
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a mirror job fails
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-sync-error"
|
||||
checked={notificationConfig.notifyOnSyncError}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-sync-success" className="text-sm font-normal cursor-pointer">
|
||||
Sync success
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a mirror job completes successfully
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-sync-success"
|
||||
checked={notificationConfig.notifyOnSyncSuccess}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-new-repo" className="text-sm font-normal cursor-pointer text-muted-foreground">
|
||||
New repository discovered (coming soon)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a new GitHub repository is auto-imported
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-new-repo"
|
||||
checked={notificationConfig.notifyOnNewRepo}
|
||||
disabled
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestNotification}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4 animate-spin mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Test Notification
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</CardSection>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsCard>
|
||||
|
||||
{notificationConfig.enabled && (
|
||||
<SettingsCard
|
||||
icon={Activity}
|
||||
title="Notification Events"
|
||||
footer={
|
||||
<StatusFooterItem
|
||||
icon={Info}
|
||||
label="Error notifications are always sent at high priority"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="Sync errors"
|
||||
description="Notify when a mirror job fails"
|
||||
checked={notificationConfig.notifyOnSyncError}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnSyncError: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CardSection>
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="Sync success"
|
||||
description="Notify when a mirror job completes successfully"
|
||||
checked={notificationConfig.notifyOnSyncSuccess}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnSyncSuccess: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CardSection>
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="New repository discovered"
|
||||
description="Notify when a new GitHub repository is auto-imported"
|
||||
badge="SOON"
|
||||
checked={notificationConfig.notifyOnNewRepo}
|
||||
disabled
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,16 +50,13 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
|
||||
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>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Organization configuration
|
||||
</span>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal flex items-center gap-2">
|
||||
Starred Repository Destination
|
||||
<Label className="text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||
Starred repository destination
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -71,43 +68,43 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="rounded-lg border bg-muted/20 p-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStarredReposModeChange("dedicated-org")}
|
||||
aria-pressed={activeStarredMode === "dedicated-org"}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 rounded-md border text-sm transition-all",
|
||||
activeStarredMode === "dedicated-org"
|
||||
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
Dedicated Organization
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStarredReposModeChange("preserve-owner")}
|
||||
aria-pressed={activeStarredMode === "preserve-owner"}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 rounded-md border text-sm transition-all",
|
||||
activeStarredMode === "preserve-owner"
|
||||
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
Preserve Source Owner/Org
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 px-1 text-xs text-muted-foreground">
|
||||
{
|
||||
<div className="flex w-full gap-1 rounded-lg bg-muted p-1" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeStarredMode === "dedicated-org"}
|
||||
onClick={() => onStarredReposModeChange("dedicated-org")}
|
||||
className={cn(
|
||||
"flex h-8 flex-1 items-center justify-center rounded-md text-xs transition-colors",
|
||||
activeStarredMode === "dedicated-org"
|
||||
? "All starred repositories go to a single destination organization."
|
||||
: "Starred repositories keep their original GitHub Owner/Org destination."
|
||||
}
|
||||
</p>
|
||||
? "bg-background font-medium text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Dedicated organization
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeStarredMode === "preserve-owner"}
|
||||
onClick={() => onStarredReposModeChange("preserve-owner")}
|
||||
className={cn(
|
||||
"flex h-8 flex-1 items-center justify-center rounded-md text-xs transition-colors",
|
||||
activeStarredMode === "preserve-owner"
|
||||
? "bg-background font-medium text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Preserve source owner
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{
|
||||
activeStarredMode === "dedicated-org"
|
||||
? "All starred repositories go to a single destination organization"
|
||||
: "Starred repositories keep their original GitHub Owner/Org destination"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* First row - Organization inputs */}
|
||||
@@ -199,7 +196,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex w-full gap-1 rounded-lg bg-muted p-1" role="tablist">
|
||||
{visibilityOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = visibility === option.value;
|
||||
@@ -209,20 +206,18 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => onVisibilityChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
|
||||
"border group",
|
||||
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md text-xs transition-colors",
|
||||
isSelected
|
||||
? "bg-accent border-accent-foreground/20"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
? "bg-background font-medium text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
<Icon className="h-3 w-3" />
|
||||
{option.label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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 { CircleCheck, Info, GitBranch, FolderTree, Star, Building2, User } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
@@ -291,13 +289,9 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
<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>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Organization strategy
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
@@ -363,84 +357,83 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
</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;
|
||||
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-3" role="radiogroup">
|
||||
{(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
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => onStrategyChange(key)}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border p-3.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-indigo-500 bg-indigo-500/10"
|
||||
: "border-border hover:border-muted-foreground/40"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
|
||||
isSelected
|
||||
? "bg-indigo-500/20 text-indigo-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<span className="flex-1 min-w-0 space-y-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"relative",
|
||||
isSelected && `${config.borderColor} border-2`,
|
||||
!isSelected && "border-muted"
|
||||
"text-[13px] font-medium leading-none",
|
||||
isSelected ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<RadioGroupItem
|
||||
value={key}
|
||||
id={key}
|
||||
className="mt-1"
|
||||
{config.title}
|
||||
</span>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span
|
||||
className="inline-flex cursor-help text-muted-foreground/50 hover:text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</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}
|
||||
starredReposMode={starredReposMode}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
|
||||
<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}
|
||||
starredReposMode={starredReposMode}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</span>
|
||||
<span className="block text-[11px] leading-relaxed text-muted-foreground">
|
||||
{config.description}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{isSelected && (
|
||||
<CircleCheck className="h-4 w-4 shrink-0 text-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,13 +8,14 @@ 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 { Plus, Trash2, Loader2, AlertCircle, Info, KeyRound, Shield, ShieldCheck, 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';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
import { SwitchRow, accentSwitch } from './settings-ui';
|
||||
|
||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||
try {
|
||||
@@ -289,91 +290,75 @@ export function SSOSettings() {
|
||||
}
|
||||
|
||||
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 className="grid grid-cols-1 items-stretch gap-6 lg:grid-cols-2">
|
||||
{/* Authentication Methods Overview */}
|
||||
<div className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center gap-3 px-6 py-4">
|
||||
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-base font-semibold">Sign-in Methods</h3>
|
||||
</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 className="border-t border-border" />
|
||||
<div className="flex-1 divide-y divide-border p-6">
|
||||
{/* These methods are not toggleable from the UI, so the switches
|
||||
reflect real state and explain where each is controlled. */}
|
||||
<SwitchRow
|
||||
className="py-4 first:pt-0 last:pb-0"
|
||||
label="Email & Password"
|
||||
badge="DEFAULT"
|
||||
description="Built-in accounts, always available"
|
||||
info="Always enabled so you can never lock yourself out."
|
||||
checked
|
||||
disabled
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
<SwitchRow
|
||||
className="py-4 first:pt-0 last:pb-0"
|
||||
label="Single Sign-On (OIDC)"
|
||||
description="Sign in through an external identity provider"
|
||||
info={
|
||||
providers.length > 0
|
||||
? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured`
|
||||
: "Turns on automatically when you add an identity provider."
|
||||
}
|
||||
checked={providers.length > 0}
|
||||
disabled
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
<SwitchRow
|
||||
className="py-4 first:pt-0 last:pb-0"
|
||||
label="Header Authentication"
|
||||
description="Trust identity headers from your reverse proxy"
|
||||
info="Controlled by the HEADER_AUTH environment variables. See the environment variable docs."
|
||||
checked={headerAuthEnabled}
|
||||
disabled
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Only enable header auth behind a trusted reverse proxy
|
||||
</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>
|
||||
<Card className="flex h-full flex-col">
|
||||
<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 className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">Identity Providers</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Users sign in with their existing accounts (Google, Azure AD, Authentik...)
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button size="sm" className="bg-indigo-500 text-white hover:bg-indigo-600">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Provider
|
||||
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">
|
||||
@@ -385,11 +370,21 @@ export function SSOSettings() {
|
||||
: 'Configure an external identity provider for user authentication'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1">
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1 [&_label]:text-xs [&_label]:font-medium [&_label]:text-muted-foreground">
|
||||
<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 className="sticky top-0 z-10 grid w-full grid-cols-2 gap-1 rounded-lg bg-muted p-1">
|
||||
<TabsTrigger
|
||||
value="oidc"
|
||||
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
OIDC / OAuth2
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="saml"
|
||||
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
SAML 2.0
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
@@ -513,6 +508,7 @@ export function SSOSettings() {
|
||||
id="pkce"
|
||||
checked={providerForm.pkce}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
|
||||
className={accentSwitch}
|
||||
/>
|
||||
<Label htmlFor="pkce">Enable PKCE</Label>
|
||||
</div>
|
||||
@@ -609,7 +605,11 @@ export function SSOSettings() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createProvider} disabled={addingProvider}>
|
||||
<Button
|
||||
onClick={createProvider}
|
||||
disabled={addingProvider}
|
||||
className="bg-indigo-500 text-white hover:bg-indigo-600"
|
||||
>
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -624,7 +624,7 @@ export function SSOSettings() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
|
||||
@@ -725,6 +725,11 @@ export function SSOSettings() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<div className="mt-auto border-t border-border" />
|
||||
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Users are matched to existing accounts by email address
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
type IconComponent = ComponentType<{ className?: string }>;
|
||||
import { CircleCheck, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
/** Indigo accent used across the redesigned settings surfaces. */
|
||||
export const accentSwitch =
|
||||
"data-[state=checked]:bg-indigo-500 dark:data-[state=checked]:bg-indigo-500";
|
||||
|
||||
interface SettingsCardProps {
|
||||
icon: IconComponent;
|
||||
title: string;
|
||||
/** Renders a switch in the header; the card is toggled as a whole. */
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
/** Custom element on the right side of the header (e.g. a Test button). */
|
||||
headerAction?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
headerAction,
|
||||
footer,
|
||||
className,
|
||||
children,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl border border-border bg-card overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{headerAction}
|
||||
{onEnabledChange && (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
className={accentSwitch}
|
||||
aria-label={`Enable ${title.toLowerCase()}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex-1 flex flex-col">{children}</div>
|
||||
{footer && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex items-center justify-between gap-4 px-6 py-3.5">
|
||||
{footer}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionTitle({
|
||||
children,
|
||||
badge,
|
||||
action,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
badge?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="rounded-full bg-indigo-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-indigo-500">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoHint({ content }: { content: ReactNode }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help text-muted-foreground/50 hover:text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionRowProps {
|
||||
icon?: IconComponent;
|
||||
label: string;
|
||||
description?: string;
|
||||
info?: ReactNode;
|
||||
badge?: string;
|
||||
/** Control rendered on the right (switch, pill, input...). */
|
||||
right?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OptionRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
info,
|
||||
badge,
|
||||
right,
|
||||
className,
|
||||
}: OptionRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-4", className)}>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
<span className="text-sm font-medium leading-none">{label}</span>
|
||||
{badge && (
|
||||
<span className="rounded-full bg-indigo-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-indigo-500">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{info && <InfoHint content={info} />}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-[13px] text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Switch row shorthand used by most option lists. */
|
||||
export function SwitchRow(
|
||||
props: Omit<OptionRowProps, "right"> & {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
/** Extra element rendered between text and switch (e.g. "Latest 10"). */
|
||||
extra?: ReactNode;
|
||||
}
|
||||
) {
|
||||
const { checked, onCheckedChange, disabled, extra, ...rest } = props;
|
||||
return (
|
||||
<OptionRow
|
||||
{...rest}
|
||||
right={
|
||||
<div className="flex items-center gap-3">
|
||||
{extra}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
className={accentSwitch}
|
||||
aria-label={rest.label}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionTileProps {
|
||||
icon: IconComponent;
|
||||
label: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
info?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Selection tile ("variant C"): icon chip, label with info, description,
|
||||
* and a check mark only on the selected tile. */
|
||||
export function OptionTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
onSelect,
|
||||
info,
|
||||
disabled,
|
||||
}: OptionTileProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex flex-1 items-start gap-3 rounded-lg border p-3.5 text-left transition-colors",
|
||||
selected
|
||||
? "border-indigo-500 bg-indigo-500/10"
|
||||
: "border-border hover:border-muted-foreground/40",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
|
||||
selected
|
||||
? "bg-indigo-500/20 text-indigo-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 space-y-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[13px] font-medium leading-none",
|
||||
selected ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{info && <InfoHint content={info} />}
|
||||
</span>
|
||||
<span className="block text-[11px] leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<CircleCheck className="h-4 w-4 shrink-0 text-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: { value: T; label: string; icon?: IconComponent }[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-1 rounded-lg bg-muted p-1",
|
||||
className
|
||||
)}
|
||||
role="tablist"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = opt.value === value;
|
||||
const OptIcon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md text-xs transition-colors",
|
||||
active
|
||||
? "bg-background font-medium text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{OptIcon && <OptIcon className="h-3 w-3" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusFooterItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
}: {
|
||||
icon: IconComponent;
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
{value !== undefined && (
|
||||
<span className={cn("font-medium text-muted-foreground", valueClassName)}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Divider between sections inside a SettingsCard. */
|
||||
export function CardDivider() {
|
||||
return <div className="border-t border-border" />;
|
||||
}
|
||||
|
||||
/** Standard padded section body inside a SettingsCard. */
|
||||
export function CardSection({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-4 p-6", className)}>{children}</div>;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
|
||||
@@ -10,6 +17,23 @@ interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
}
|
||||
|
||||
function activityIcon(status: MirrorJob["status"]) {
|
||||
switch (status) {
|
||||
case "mirrored":
|
||||
case "synced":
|
||||
return { Icon: CircleCheck, color: "text-green-500" };
|
||||
case "mirroring":
|
||||
case "syncing":
|
||||
return { Icon: RefreshCw, color: "text-indigo-400" };
|
||||
case "imported":
|
||||
return { Icon: Sparkles, color: "text-muted-foreground" };
|
||||
case "failed":
|
||||
return { Icon: TriangleAlert, color: "text-red-500" };
|
||||
default:
|
||||
return { Icon: Activity, color: "text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
@@ -17,9 +41,12 @@ 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={withBase("/activity")}>View All</a>
|
||||
<CardTitle className="flex items-center gap-3 text-base font-semibold">
|
||||
<Activity className="h-5 w-5 text-muted-foreground" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild className="text-indigo-500 hover:text-indigo-600">
|
||||
<a href={withBase("/activity")}>View all</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -41,25 +68,24 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
</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}
|
||||
{activities.map((activity, index) => {
|
||||
const { Icon, color } = activityIcon(activity.status);
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-3 py-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(activity.timestamp)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{activity.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(activity.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -41,9 +41,12 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href={withBase("/repositories")}>View All</a>
|
||||
<CardTitle className="flex items-center gap-3 text-base font-semibold">
|
||||
<GitFork className="h-5 w-5 text-muted-foreground" />
|
||||
Repositories
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild className="text-indigo-500 hover:text-indigo-600">
|
||||
<a href={withBase("/repositories")}>View all</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusCardProps {
|
||||
@@ -17,17 +16,22 @@ export function StatusCard({
|
||||
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>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2.5 rounded-xl border border-border bg-card p-5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">{icon}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold leading-none">{value}</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground/80">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||
import { TimeFormatToggle } from "@/components/layout/TimeFormatToggle";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
@@ -128,8 +127,6 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
|
||||
<TimeFormatToggle />
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { links } from "@/data/Sidebar";
|
||||
import { VersionInfo } from "./VersionInfo";
|
||||
import { ThemeSwitcher } from "@/components/theme/ThemeSwitcher";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -196,6 +197,16 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"mt-3 flex items-center justify-between",
|
||||
isCollapsed ? "md:hidden xl:flex" : "flex"
|
||||
)}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
Theme
|
||||
</span>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"mt-1",
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<VersionInfo />
|
||||
|
||||
@@ -1,23 +1,56 @@
|
||||
import { Clock, Check } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Clock, CircleCheck, Globe } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
import type { TimeFormatPreference } from "@/lib/utils/time-format";
|
||||
|
||||
const OPTIONS: { value: TimeFormatPreference; label: string }[] = [
|
||||
{ value: "auto", label: "Auto (browser locale)" },
|
||||
{ value: "12h", label: "12-hour" },
|
||||
{ value: "24h", label: "24-hour" },
|
||||
];
|
||||
function formatNow(now: Date, preference: TimeFormatPreference): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
};
|
||||
if (preference === "12h") options.hour12 = true;
|
||||
if (preference === "24h") options.hour12 = false;
|
||||
return new Intl.DateTimeFormat(undefined, options).format(now);
|
||||
}
|
||||
|
||||
export function TimeFormatToggle() {
|
||||
const { preference, setPreference } = useTimeFormat();
|
||||
const [now, setNow] = React.useState(() => new Date());
|
||||
const locale =
|
||||
typeof navigator !== "undefined" ? navigator.language : undefined;
|
||||
|
||||
// Keep the trigger clock fresh.
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const options: {
|
||||
value: TimeFormatPreference;
|
||||
label: string;
|
||||
example: string;
|
||||
mono: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
example: formatNow(now, "auto"),
|
||||
mono: true,
|
||||
},
|
||||
{ value: "12h", label: "12-hour", example: formatNow(now, "12h"), mono: true },
|
||||
{ value: "24h", label: "24-hour", example: formatNow(now, "24h"), mono: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -25,27 +58,70 @@ export function TimeFormatToggle() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="has-[>svg]:px-3"
|
||||
className="gap-2 px-3"
|
||||
title="Time format"
|
||||
>
|
||||
<Clock className="h-[1.2rem] w-[1.2rem]" />
|
||||
<Clock className="h-[1.1rem] w-[1.1rem]" />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{formatNow(now, preference)}
|
||||
</span>
|
||||
<span className="sr-only">Toggle time format</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setPreference(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={`h-4 w-4 ${
|
||||
preference === option.value ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuContent align="end" className="w-70 p-1.5">
|
||||
<DropdownMenuLabel className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Time format
|
||||
</span>
|
||||
{locale && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] font-normal text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{locale}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{options.map((option) => {
|
||||
const selected = preference === option.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setPreference(option.value)}
|
||||
className={cn(
|
||||
"gap-2.5 rounded-lg px-2.5 py-2.5",
|
||||
selected && "bg-muted/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{option.value === "auto" && (
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[13px] leading-none",
|
||||
selected && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground",
|
||||
option.mono && "font-mono"
|
||||
)}
|
||||
>
|
||||
{option.example}
|
||||
</span>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
"h-4 w-4 text-indigo-500",
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -22,19 +22,13 @@ const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
|
||||
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
|
||||
|
||||
const getThemePreference = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
}
|
||||
// "system" or nothing stored: follow the OS.
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from "react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
const OPTIONS: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
];
|
||||
|
||||
function resolveIsDark(preference: ThemePreference): boolean {
|
||||
if (preference === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return preference === "dark";
|
||||
}
|
||||
|
||||
/** Icon-only segmented theme control for the sidebar. Persists the raw
|
||||
* preference (including "system") so it survives reloads. */
|
||||
export function ThemeSwitcher({ className }: { className?: string }) {
|
||||
const [preference, setPreference] = React.useState<ThemePreference>("system");
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
setPreference(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList[
|
||||
resolveIsDark(preference) ? "add" : "remove"
|
||||
]("dark");
|
||||
|
||||
if (preference !== "system") return;
|
||||
// Follow OS changes live while "system" is selected.
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () =>
|
||||
document.documentElement.classList[media.matches ? "add" : "remove"]("dark");
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, [preference]);
|
||||
|
||||
const select = (value: ThemePreference) => {
|
||||
setPreference(value);
|
||||
localStorage.setItem("theme", value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-0.5 rounded-lg bg-muted p-0.5", className)}
|
||||
role="radiogroup"
|
||||
aria-label="Theme"
|
||||
>
|
||||
<TooltipProvider>
|
||||
{OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = preference === option.value;
|
||||
return (
|
||||
<Tooltip key={option.value} delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={option.label}
|
||||
onClick={() => select(option.value)}
|
||||
className={cn(
|
||||
"flex h-6.5 w-7.5 items-center justify-center rounded-md transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{option.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ const giteaOptions = [
|
||||
|
||||
<!-- GitHub Configuration -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">GitHub Configuration</h3>
|
||||
<h3 class="text-xl font-semibold mb-4">GitHub Connection</h3>
|
||||
<p class="text-muted-foreground mb-4">The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.</p>
|
||||
|
||||
<div class="overflow-x-auto mb-6">
|
||||
@@ -226,7 +226,7 @@ const giteaOptions = [
|
||||
|
||||
<!-- Gitea Configuration -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">Gitea Configuration</h3>
|
||||
<h3 class="text-xl font-semibold mb-4">Gitea Connection</h3>
|
||||
<p class="text-muted-foreground mb-4">The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.</p>
|
||||
|
||||
<div class="overflow-x-auto mb-6">
|
||||
|
||||
|
Before Width: | Height: | Size: 986 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 905 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 45 KiB |