Files
gitea-mirror/src/components/config/NotificationSettings.tsx
T
ARUNAVO RAYandGitHub 882e147504 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
2026-08-03 11:27:45 +05:30

523 lines
18 KiB
TypeScript

import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
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;
onNotificationChange: (config: NotificationConfig) => void;
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,
isAutoSaving,
}: NotificationSettingsProps) {
const [isTesting, setIsTesting] = useState(false);
const handleTestNotification = async () => {
setIsTesting(true);
try {
const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }),
});
const result = await resp.json();
if (result.success) {
toast.success("Test notification sent successfully!");
} else {
toast.error(`Test failed: ${result.error || "Unknown error"}`);
}
} catch (error) {
toast.error(
`Test failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsTesting(false);
}
};
return (
<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"
>
{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 })
}
/>
{notificationConfig.provider === "ntfy" && (
<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"
placeholder="https://ntfy.sh"
value={notificationConfig.ntfy?.url || "https://ntfy.sh"}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: e.target.value,
topic: notificationConfig.ntfy?.topic || "",
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
</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"
value={notificationConfig.ntfy?.topic || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
</Field>
<Field
id="ntfy-token"
label="Access token (optional)"
helper="Required if your ntfy server uses authentication"
>
<Input
id="ntfy-token"
type="password"
placeholder="tk_..."
value={notificationConfig.ntfy?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
token: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
</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") =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
priority: value,
},
})
}
>
<SelectTrigger id="ntfy-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="min">Min</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
{notificationConfig.provider === "apprise" && (
<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"
placeholder="http://apprise:8000"
value={notificationConfig.apprise?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: e.target.value,
token: notificationConfig.apprise?.token || "",
},
})
}
/>
</Field>
<Field
id="apprise-token"
label="Token / path"
required
helper="The Apprise API configuration token or key"
>
<Input
id="apprise-token"
placeholder="gitea-mirror"
value={notificationConfig.apprise?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: e.target.value,
},
})
}
/>
</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"
value={notificationConfig.apprise?.tag || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: notificationConfig.apprise?.token || "",
tag: e.target.value,
},
})
}
/>
</Field>
</div>
)}
{notificationConfig.provider === "gotify" && (
<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"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
</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"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
</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"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
</Field>
</div>
)}
{notificationConfig.provider === "webhook" && (
<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"
placeholder="https://example.com/hooks/gitea-mirror"
value={notificationConfig.webhook?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: e.target.value,
},
})
}
/>
</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"
placeholder="whsec_..."
value={notificationConfig.webhook?.secret || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: notificationConfig.webhook?.url || "",
secret: e.target.value,
},
})
}
/>
</Field>
</div>
)}
</CardSection>
)}
</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>
);
}