665 lines
26 KiB
TypeScript
665 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
|
import Link from "next/link";
|
|
import { motion } from "framer-motion";
|
|
import { useTranslations } from "next-intl";
|
|
import { toast } from "sonner";
|
|
import useSWR from "swr";
|
|
|
|
import { BucketItem, CloudStorageCredentials } from "@/lib/r2";
|
|
import { cn, fetcher } from "@/lib/utils";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { Icons } from "@/components/shared/icons";
|
|
|
|
export default function S3Configs({}: {}) {
|
|
const t = useTranslations("Setting");
|
|
const [isPending, startTransition] = useTransition();
|
|
const [s3Configs, setS3Configs] = useState<CloudStorageCredentials[]>([]);
|
|
|
|
const {
|
|
data: configs,
|
|
isLoading,
|
|
mutate,
|
|
} = useSWR<Record<string, any>>("/api/admin/s3", fetcher);
|
|
|
|
const S3_PRVIDERS = [
|
|
{
|
|
label: "Cloudflare R2",
|
|
value: "Cloudflare R2",
|
|
platform: "cloudflare",
|
|
channel: "r2",
|
|
},
|
|
{ label: "AWS S3", value: "AWS S3", platform: "aws", channel: "s3" },
|
|
{
|
|
label: "Tencent COS",
|
|
value: "Tencent COS",
|
|
platform: "tencent",
|
|
channel: "cos",
|
|
},
|
|
{ label: "Ali OSS", value: "Ali OSS", platform: "ali", channel: "oss" },
|
|
{
|
|
label: "Custom Provider",
|
|
value: "Custom Provider",
|
|
platform: "custom provider",
|
|
channel: "cp",
|
|
},
|
|
];
|
|
|
|
useEffect(() => {
|
|
if (configs && configs?.s3_config_list) {
|
|
setS3Configs(configs.s3_config_list);
|
|
}
|
|
}, [configs]);
|
|
|
|
function isProviderNameUnique(array: CloudStorageCredentials[]): boolean {
|
|
const names = array.map((item) => item.provider_name);
|
|
return new Set(names).size === names.length;
|
|
}
|
|
|
|
const handleSaveConfigs = (value: any, key: string, type: string) => {
|
|
if (!isProviderNameUnique(s3Configs)) {
|
|
toast.error("Provider name must be unique");
|
|
return;
|
|
}
|
|
startTransition(async () => {
|
|
const res = await fetch("/api/admin/s3", {
|
|
method: "POST",
|
|
body: JSON.stringify({ key, value, type }),
|
|
});
|
|
if (res.ok) {
|
|
toast.success("Saved");
|
|
mutate();
|
|
} else {
|
|
toast.error("Failed to save", {
|
|
description: await res.text(),
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const canSaveR2Credentials = useMemo(() => {
|
|
if (!configs) return true;
|
|
|
|
return (
|
|
Object.keys(s3Configs).some(
|
|
(key) => s3Configs[key] !== configs.s3_config_list[key],
|
|
) || configs.s3_config_list.length !== s3Configs.length
|
|
);
|
|
}, [s3Configs, configs]);
|
|
|
|
if (isLoading) {
|
|
return <Skeleton className="h-48 w-full rounded-lg" />;
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<Collapsible defaultOpen>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
|
<p className="mr-auto text-lg font-bold">
|
|
{t("Cloud Storage Configs")}
|
|
</p>
|
|
{canSaveR2Credentials && (
|
|
<Button
|
|
className="h-7 px-2 py-1 text-xs"
|
|
size={"sm"}
|
|
disabled={isPending || !canSaveR2Credentials}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleSaveConfigs(s3Configs, "s3_config_list", "OBJECT");
|
|
}}
|
|
>
|
|
{isPending ? (
|
|
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
|
) : null}
|
|
{t("Save Modifications")}
|
|
</Button>
|
|
)}
|
|
<p
|
|
className="flex h-[30px] items-center gap-1 rounded-md border bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-80"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setS3Configs([
|
|
...s3Configs,
|
|
{
|
|
platform: "cloudflare",
|
|
channel: "s3",
|
|
provider_name: `Cloudflare R2 (${s3Configs.length + 1})`,
|
|
account_id: "",
|
|
access_key_id: "",
|
|
secret_access_key: "",
|
|
endpoint: "",
|
|
enabled: true,
|
|
buckets: [
|
|
{
|
|
bucket: "",
|
|
custom_domain: "",
|
|
prefix: "",
|
|
file_types: "",
|
|
region: "auto",
|
|
public: true,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
}}
|
|
>
|
|
<Icons.add className="size-3" />
|
|
{t("Add Provider")}
|
|
</p>
|
|
<Icons.chevronDown className="size-4" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
|
|
{s3Configs.map((config, index) => {
|
|
const updateBucket = (
|
|
bucketIndex: number,
|
|
updates: Partial<BucketItem>,
|
|
) => {
|
|
const newBuckets = [...config.buckets];
|
|
newBuckets[bucketIndex] = {
|
|
...newBuckets[bucketIndex],
|
|
...updates,
|
|
};
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
buckets: newBuckets,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
};
|
|
return (
|
|
<Collapsible
|
|
className={cn(
|
|
index !== s3Configs.length - 1 && "border-b pb-3",
|
|
"group",
|
|
)}
|
|
key={index}
|
|
>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3">
|
|
<p className="mr-auto font-semibold group-hover:font-bold">
|
|
{config.provider_name}
|
|
</p>
|
|
<Badge className="text-xs" variant="outline">
|
|
{t("{length} Buckets", {
|
|
length: config.buckets.length,
|
|
})}
|
|
</Badge>
|
|
<Icons.trash
|
|
className="size-6 rounded border p-1 text-muted-foreground hover:border-red-500 hover:bg-red-50 hover:text-red-500"
|
|
onClick={() => {
|
|
setS3Configs(s3Configs.filter((_, i) => i !== index));
|
|
}}
|
|
/>
|
|
<Icons.chevronDown className="size-4" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="mt-3 space-y-4 rounded-lg border p-6 shadow-md transition-colors duration-75 group-hover:bg-primary-foreground">
|
|
{/* Base */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<div className="space-y-1">
|
|
<Label>{t("Provider")}*</Label>
|
|
<Select
|
|
value={`${config.platform} (${config.channel})`}
|
|
onValueChange={(v) => {
|
|
const provider = S3_PRVIDERS.find(
|
|
(p) => `${p.platform} (${p.channel})` === v,
|
|
);
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
provider_name: `${provider?.value} (${index + 1})`,
|
|
channel: provider?.channel || "",
|
|
platform: provider?.platform || "",
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger className="bg-neutral-100 dark:bg-neutral-800">
|
|
<SelectValue placeholder="Select a provider" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{S3_PRVIDERS.map((provider) => (
|
|
<SelectItem
|
|
key={`${provider.platform} (${provider.channel})`}
|
|
value={`${provider.platform} (${provider.channel})`}
|
|
>
|
|
{provider.platform} ({provider.channel})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>
|
|
{t("Provider Unique Name")}* ({t("Unique")})
|
|
</Label>
|
|
<Input
|
|
value={config.provider_name}
|
|
placeholder="provider display name"
|
|
onChange={(e) =>
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
provider_name: e.target.value,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>{t("Endpoint")}*</Label>
|
|
<Input
|
|
value={config.endpoint}
|
|
placeholder="https://<account_id>.r2.cloudflarestorage.com"
|
|
onChange={(e) =>
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
endpoint: e.target.value,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>{t("Access Key ID")}*</Label>
|
|
<Input
|
|
value={config.access_key_id}
|
|
onChange={(e) =>
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
access_key_id: e.target.value,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>{t("Secret Access Key")}*</Label>
|
|
<Input
|
|
value={config.secret_access_key}
|
|
onChange={(e) =>
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
secret_access_key: e.target.value,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col justify-center space-y-3">
|
|
<Label>{t("Enabled")}*</Label>
|
|
<Switch
|
|
checked={config.enabled}
|
|
onCheckedChange={(e) =>
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
enabled: e,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* buckets */}
|
|
{config.buckets.map((bucket, index2) => (
|
|
<motion.div
|
|
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-3"
|
|
key={`bucket-${index2}`}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{
|
|
layout: { duration: 0.3, ease: "easeInOut" },
|
|
opacity: { duration: 0.2 },
|
|
scale: { duration: 0.2 },
|
|
}}
|
|
>
|
|
<p className="absolute left-2 top-3 text-xs text-muted-foreground">
|
|
{t("Bucket")} {index2 + 1}
|
|
</p>
|
|
|
|
{/* 按钮部分 */}
|
|
<div className="absolute right-2 top-2 flex items-center justify-between space-x-2">
|
|
{index2 > 0 && (
|
|
<Button
|
|
className="h-[30px] px-1.5"
|
|
size={"sm"}
|
|
variant={"ghost"}
|
|
onClick={() => {
|
|
const newBuckets = [...config.buckets];
|
|
newBuckets.splice(index2, 1);
|
|
newBuckets.splice(index2 - 1, 0, bucket);
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
buckets: newBuckets,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
<Icons.arrowUp className="size-4" />
|
|
</Button>
|
|
)}
|
|
{index2 < config.buckets.length - 1 && (
|
|
<Button
|
|
className="h-[30px] px-1.5"
|
|
size={"sm"}
|
|
variant={"ghost"}
|
|
onClick={() => {
|
|
const newBuckets = [...config.buckets];
|
|
newBuckets.splice(index2, 1);
|
|
newBuckets.splice(index2 + 1, 0, bucket);
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
buckets: newBuckets,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
<Icons.arrowDown className="size-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
className="ml-auto h-[30px] px-1.5"
|
|
size={"sm"}
|
|
variant={"outline"}
|
|
onClick={() => {
|
|
const newBuckets = [...config.buckets];
|
|
newBuckets.splice(index2 + 1, 0, {
|
|
bucket: "",
|
|
prefix: "",
|
|
file_types: "",
|
|
region: "auto",
|
|
custom_domain: "",
|
|
file_size: "26214400",
|
|
max_storage: "",
|
|
public: true,
|
|
});
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
buckets: newBuckets,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
<Icons.add className="size-4" />
|
|
</Button>
|
|
{index2 !== 0 && (
|
|
<Button
|
|
className="h-[30px] px-1.5"
|
|
size={"sm"}
|
|
variant={"outline"}
|
|
onClick={() => {
|
|
const newBuckets = [...config.buckets];
|
|
newBuckets.splice(index2, 1);
|
|
setS3Configs(
|
|
s3Configs.map((c, i) => {
|
|
if (i === index) {
|
|
return {
|
|
...c,
|
|
buckets: newBuckets,
|
|
};
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
<Icons.trash className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 使用 updateBucket 函数的输入字段 */}
|
|
<div className="space-y-1">
|
|
<Label>{t("Bucket Name")}*</Label>
|
|
<Input
|
|
value={bucket.bucket}
|
|
placeholder="bucket name"
|
|
onChange={(e) =>
|
|
updateBucket(index2, { bucket: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>{t("Public Domain")}*</Label>
|
|
<Input
|
|
value={bucket.custom_domain}
|
|
placeholder="https://endpoint or custom domain"
|
|
onChange={(e) =>
|
|
updateBucket(index2, {
|
|
custom_domain: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>{t("Region")}</Label>
|
|
<Input
|
|
value={bucket.region}
|
|
placeholder="auto"
|
|
onChange={(e) =>
|
|
updateBucket(index2, { region: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>
|
|
{t("Prefix")} ({t("Optional")})
|
|
</Label>
|
|
<Input
|
|
value={bucket.prefix}
|
|
placeholder="2025/08/08"
|
|
onChange={(e) =>
|
|
updateBucket(index2, { prefix: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<Label>
|
|
{t("Max Storage")} ({t("Optional")})
|
|
</Label>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger>
|
|
<Icons.help className="size-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-64 text-wrap">
|
|
{t("maxStorageTooltip")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
value={bucket.max_storage || ""}
|
|
placeholder="10737418240"
|
|
onChange={(e) =>
|
|
updateBucket(index2, { max_storage: e.target.value })
|
|
}
|
|
/>
|
|
{bucket.max_storage && (
|
|
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
|
≈{(Number(bucket.max_storage) / (1024 * 1024 * 1024)).toFixed(1)}GB
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col justify-center space-y-3">
|
|
<div className="flex items-center gap-1">
|
|
<Label>{t("Public")}</Label>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger>
|
|
<Icons.help className="size-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-56 text-wrap">
|
|
{t(
|
|
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<Switch
|
|
checked={bucket.public}
|
|
onCheckedChange={(e) =>
|
|
updateBucket(index2, { public: e })
|
|
}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
{/* actions */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Link
|
|
className="text-sm text-blue-500 hover:underline"
|
|
href="/docs/developer/cloud-storage#cloudflare-r2"
|
|
target="_blank"
|
|
>
|
|
{t("How to get the S3 credentials?")}
|
|
</Link>
|
|
{/* <Button
|
|
className="ml-auto"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setS3Configs([
|
|
{
|
|
platform: "cloudflare",
|
|
channel: "r2",
|
|
provider_name: "Cloudflare R2",
|
|
endpoint: "",
|
|
access_key_id: "",
|
|
secret_access_key: "",
|
|
buckets: [
|
|
{
|
|
bucket: "",
|
|
prefix: "",
|
|
file_types: "",
|
|
region: "auto",
|
|
custom_domain: "",
|
|
file_size: "26214400",
|
|
max_storage: "",
|
|
public: true,
|
|
},
|
|
],
|
|
account_id: "",
|
|
enabled: false,
|
|
},
|
|
]);
|
|
}}
|
|
>
|
|
{t("Clear")}
|
|
</Button> */}
|
|
<Button
|
|
disabled={isPending || !canSaveR2Credentials}
|
|
onClick={() => {
|
|
handleSaveConfigs(
|
|
s3Configs,
|
|
"s3_config_list",
|
|
"OBJECT",
|
|
);
|
|
}}
|
|
>
|
|
{isPending ? (
|
|
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
|
) : null}
|
|
{t("Save")}
|
|
</Button>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</Card>
|
|
);
|
|
}
|