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
This commit is contained in:
ARUNAVO RAY
2026-08-03 11:27:45 +05:30
committed by GitHub
parent da75f10f26
commit 882e147504
29 changed files with 10562 additions and 2802 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+7974 -500
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 |
|---|---|---|---|
+292 -398
View File
@@ -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 optionit 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 syncsuse 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>
);
}
+66 -37
View File
@@ -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">
+301 -292
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+217 -197
View File
@@ -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 &gt;
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>
</>
);
}
+244 -261
View File
@@ -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>
+75 -82
View File
@@ -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>
);
};
+86 -81
View File
@@ -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>
);
+341
View File
@@ -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>;
}
+47 -21
View File
@@ -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>
+6 -3
View File
@@ -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>
+17 -13
View File
@@ -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>
);
}
-3
View File
@@ -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>
+11
View File
@@ -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 />
+98 -22
View File
@@ -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>
);
-52
View File
@@ -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>
);
}
+4 -10
View File
@@ -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>
+93
View File
@@ -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>
);
}
+2 -2
View File
@@ -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">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 905 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 45 KiB