mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 20:46:44 +03:00
feat: implement MirrorDestinationEditor component for customizable organization destination
This commit is contained in:
193
src/components/organizations/MirrorDestinationEditor.tsx
Normal file
193
src/components/organizations/MirrorDestinationEditor.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, XCircle, Building2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MirrorDestinationEditorProps {
|
||||||
|
organizationId: string;
|
||||||
|
organizationName: string;
|
||||||
|
currentDestination?: string;
|
||||||
|
onUpdate: (newDestination: string | null) => Promise<void>;
|
||||||
|
isUpdating?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MirrorDestinationEditor({
|
||||||
|
organizationId,
|
||||||
|
organizationName,
|
||||||
|
currentDestination,
|
||||||
|
onUpdate,
|
||||||
|
isUpdating = false,
|
||||||
|
className,
|
||||||
|
}: MirrorDestinationEditorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(currentDestination || "");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const hasOverride = currentDestination && currentDestination !== organizationName;
|
||||||
|
const effectiveDestination = currentDestination || organizationName;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const trimmedValue = editValue.trim();
|
||||||
|
const newDestination = trimmedValue === "" || trimmedValue === organizationName
|
||||||
|
? null
|
||||||
|
: trimmedValue;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await onUpdate(newDestination);
|
||||||
|
setIsOpen(false);
|
||||||
|
toast.success(
|
||||||
|
newDestination
|
||||||
|
? `Destination updated to: ${newDestination}`
|
||||||
|
: "Destination reset to default"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update destination");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setEditValue("");
|
||||||
|
await handleSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditValue(currentDestination || "");
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Building2 className="h-3 w-3" />
|
||||||
|
<span className="font-medium">{organizationName}</span>
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
<span className={cn(
|
||||||
|
"font-medium",
|
||||||
|
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||||
|
)}>
|
||||||
|
{effectiveDestination}
|
||||||
|
</span>
|
||||||
|
{hasOverride && (
|
||||||
|
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
|
||||||
|
custom
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
|
||||||
|
title="Edit mirror destination"
|
||||||
|
disabled={isUpdating || isLoading}
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-1">Mirror Destination</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Customize where this organization's repositories are mirrored to in Gitea.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Visual Preview */}
|
||||||
|
<div className="rounded-md bg-muted/50 p-3 space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Preview</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{organizationName}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Building2 className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
{editValue.trim() || organizationName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Field */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="destination" className="text-xs">
|
||||||
|
Destination Organization
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="destination"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
placeholder={organizationName}
|
||||||
|
className="h-8"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to use the default GitHub organization name
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
{hasOverride && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-2" />
|
||||||
|
Reset to Default ({organizationName})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Settings, ArrowRight, Edit3, X, Save } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { SiGithub } from "react-icons/si";
|
import { SiGithub } from "react-icons/si";
|
||||||
import type { Organization } from "@/lib/db/schema";
|
import type { Organization } from "@/lib/db/schema";
|
||||||
import type { FilterParams } from "@/types/filter";
|
import type { FilterParams } from "@/types/filter";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||||
|
|
||||||
interface OrganizationListProps {
|
interface OrganizationListProps {
|
||||||
organizations: Organization[];
|
organizations: Organization[];
|
||||||
@@ -50,60 +48,29 @@ export function OrganizationList({
|
|||||||
onAddOrganization,
|
onAddOrganization,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: OrganizationListProps) {
|
}: OrganizationListProps) {
|
||||||
const [editingOrg, setEditingOrg] = useState<string | null>(null);
|
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||||
const [editValue, setEditValue] = useState<string>("");
|
// Call API to update organization destination
|
||||||
const [isUpdating, setIsUpdating] = useState<string | null>(null);
|
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
destinationOrg: newDestination,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const handleEditStart = (orgId: string, currentDestination?: string) => {
|
if (!response.ok) {
|
||||||
setEditingOrg(orgId);
|
const errorData = await response.json();
|
||||||
setEditValue(currentDestination || "");
|
throw new Error(errorData.error || "Failed to update organization");
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleEditCancel = () => {
|
// Refresh organizations data
|
||||||
setEditingOrg(null);
|
if (onRefresh) {
|
||||||
setEditValue("");
|
await onRefresh();
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditSave = async (orgId: string) => {
|
|
||||||
setIsUpdating(orgId);
|
|
||||||
try {
|
|
||||||
// Call API to update organization destination
|
|
||||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
destinationOrg: editValue.trim() || null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || "Failed to update organization");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
// Close edit mode
|
|
||||||
setEditingOrg(null);
|
|
||||||
setEditValue("");
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
const destination = result.destinationOrg || "default";
|
|
||||||
toast.success(`Organization destination updated to: ${destination}`);
|
|
||||||
|
|
||||||
// Refresh organizations data
|
|
||||||
if (onRefresh) {
|
|
||||||
await onRefresh();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating organization:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to update organization");
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAnyFilter = Object.values(filter).some(
|
const hasAnyFilter = Object.values(filter).some(
|
||||||
(val) => val?.toString().trim() !== ""
|
(val) => val?.toString().trim() !== ""
|
||||||
);
|
);
|
||||||
@@ -202,64 +169,13 @@ export function OrganizationList({
|
|||||||
|
|
||||||
{/* Destination override section */}
|
{/* Destination override section */}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{editingOrg === org.id ? (
|
<MirrorDestinationEditor
|
||||||
<div className="space-y-2">
|
organizationId={org.id!}
|
||||||
<Label htmlFor={`dest-${org.id}`} className="text-xs text-muted-foreground">
|
organizationName={org.name!}
|
||||||
Mirror destination (leave empty for default: {org.name})
|
currentDestination={org.destinationOrg}
|
||||||
</Label>
|
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||||
<div className="flex items-center gap-2">
|
isUpdating={isLoading}
|
||||||
<Input
|
/>
|
||||||
id={`dest-${org.id}`}
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
placeholder={org.name}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={isUpdating === org.id}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleEditSave(org.id!)}
|
|
||||||
disabled={isUpdating === org.id}
|
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
>
|
|
||||||
<Save className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleEditCancel}
|
|
||||||
disabled={isUpdating === org.id}
|
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
<span>
|
|
||||||
Mirrors to: <span className="font-medium">
|
|
||||||
{org.destinationOrg || org.name}
|
|
||||||
</span>
|
|
||||||
{org.destinationOrg && (
|
|
||||||
<span className="text-orange-600 ml-1">(override)</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleEditStart(org.id!, org.destinationOrg)}
|
|
||||||
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
|
|
||||||
title="Edit destination"
|
|
||||||
>
|
|
||||||
<Edit3 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusBadge.variant} className="ml-2">
|
<Badge variant={statusBadge.variant} className="ml-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user