@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- s3/cloudflare-r2
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
|
||||
+11
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
## 简介
|
||||
|
||||
WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱、子域名管理和开放API接口。支持自定义链接、密码保护、访问统计;提供无限制临时邮箱收发;管理多域名DNS记录;内置网站截图、元数据提取等实用API。完整的管理后台,支持用户权限控制和服务配置。
|
||||
WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱、子域名管理、文件存储和开放API接口。支持自定义链接、密码保护、访问统计;提供无限制临时邮箱收发;管理多域名DNS记录;支持云存储,对接 S3 API;内置网站截图、元数据提取等实用API。完整的管理后台,支持用户权限控制和服务配置。
|
||||
|
||||
- 官网: [https://wr.do](https://wr.do)
|
||||
- Demo: [https://699399.xyz](https://699399.xyz) (账号: `admin@admin.com`, 密码: `123456`)
|
||||
@@ -45,6 +45,16 @@ WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱
|
||||
- 支持开启申请模式(用户提交、管理员审批)
|
||||
- 支持邮件通知管理员、用户域名申请状态
|
||||
|
||||
- 💳 **云存储服务**
|
||||
- 接入多渠道(S3 API)云存储平台(Cloudflare R2、AWS S3)
|
||||
- 支持单渠道多存储桶配置
|
||||
- 动态配置(用户配额设置)文件上传大小限制
|
||||
- 支持拖拽、批量、分块上传文件
|
||||
- 支持批量删除文件
|
||||
- 快捷生成文件短链、二维码
|
||||
- 支持部分文件在线预览内容
|
||||
- 支持调用 API 上传文件
|
||||
|
||||
- 📡 **开放接口模块**:
|
||||
- 获取网站元数据 API
|
||||
- 获取网站截图 API
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, open APIs for screenshots and metadata extraction, plus comprehensive admin dashboard.
|
||||
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, file storage, open APIs for screenshots and metadata extraction, and comprehensive admin dashboard.
|
||||
|
||||
- Official website: [https://wr.do](https://wr.do)
|
||||
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
|
||||
@@ -43,6 +43,16 @@ WR.DO is a all-in-one web utility platform featuring short links with analytics,
|
||||
- Support enabling application mode (user submission, admin approval)
|
||||
- Support email notification of administrator and user domain application status
|
||||
|
||||
- 💳 **Cloud Storage Service**
|
||||
- Connects to multiple channels (S3 API) cloud storage platforms (Cloudflare R2, AWS S3)
|
||||
- Supports single-channel multi-bucket configuration
|
||||
- Dynamic configuration (user quota settings) for file upload size limits
|
||||
- Supports drag-and-drop, batch, and chunked file uploads
|
||||
- Supports batch file deletion
|
||||
- Quickly generates short links and QR codes for files
|
||||
- Supports online preview of certain file types
|
||||
- Supports file uploads via API calls
|
||||
|
||||
- 📡 **Open API Module**:
|
||||
- Website metadata extraction API
|
||||
- Website screenshot capture API
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
/>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserFileList from "@/components/file";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Cloud Storage",
|
||||
description: "List and manage cloud storage.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
link="/docs/cloud-storage"
|
||||
linkText="Cloud Storage"
|
||||
/>
|
||||
<UserFileList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/storage/admin"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
@@ -172,9 +171,9 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import AppConfigs from "./app-configs";
|
||||
import DomainList from "./domain-list";
|
||||
import PlanList from "./plan-list";
|
||||
import S3Configs from "./s3-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "System Settings",
|
||||
@@ -22,6 +23,7 @@ export default async function DashboardPage() {
|
||||
<>
|
||||
<DashboardHeader heading="System Settings" text="" />
|
||||
<AppConfigs />
|
||||
<S3Configs />
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
@@ -111,9 +111,9 @@ export default function PlanList({ user, action }: PlanListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { CloudCog } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { 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 { 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 [isCheckingR2Config, startCheckR2Transition] = useTransition();
|
||||
const [isCheckingCOSConfig, startCheckCOSTransition] = useTransition();
|
||||
const [isCheckedR2Config, setIsCheckedR2Config] = useState(false);
|
||||
const [isCheckedCOSConfig, setIsCheckedCOSConfig] = useState(false);
|
||||
const [r2Credentials, setR2Credentials] = useState<CloudStorageCredentials>({
|
||||
platform: "cloudflare",
|
||||
channel: "r2",
|
||||
provider_name: "Cloudflare R2",
|
||||
account_id: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
endpoint: "",
|
||||
enabled: true,
|
||||
buckets: [
|
||||
{
|
||||
bucket: "",
|
||||
custom_domain: "",
|
||||
prefix: "",
|
||||
file_types: "",
|
||||
region: "auto",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {
|
||||
data: configs,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Record<string, any>>("/api/admin/s3", fetcher);
|
||||
|
||||
useEffect(() => {
|
||||
if (configs) {
|
||||
setR2Credentials(configs.s3_config_01);
|
||||
}
|
||||
}, [configs]);
|
||||
|
||||
const handleSaveConfigs = (value: any, key: string, type: string) => {
|
||||
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 handleR2CheckAccess = async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toast.success("Checking");
|
||||
};
|
||||
|
||||
const handleCOSCheckAccess = async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toast.success("Checking");
|
||||
};
|
||||
|
||||
const canSaveR2Credentials = useMemo(() => {
|
||||
if (!configs) return true;
|
||||
|
||||
return Object.keys(r2Credentials).some(
|
||||
(key) => r2Credentials[key] !== configs.s3_config_01[key],
|
||||
);
|
||||
}, [r2Credentials, configs]);
|
||||
|
||||
const ReadyBadge = (
|
||||
isChecked: boolean,
|
||||
isChecking: boolean,
|
||||
type: string,
|
||||
) => (
|
||||
<Badge
|
||||
className={cn("ml-auto text-xs font-semibold")}
|
||||
variant={isChecked ? "green" : "default"}
|
||||
onClick={(event) =>
|
||||
type === "r2" ? handleR2CheckAccess(event) : handleCOSCheckAccess(event)
|
||||
}
|
||||
>
|
||||
{isChecking && <Icons.spinner className="mr-1 size-3 animate-spin" />}
|
||||
{isChecked && !isChecking && <Icons.check className="mr-1 size-3" />}
|
||||
{isChecked ? t("Verified") : t("Verify Configuration")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-48 w-full rounded-lg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible className="group" defaultOpen>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
||||
<div className="text-lg font-bold">{t("Cloud Storage Configs")}</div>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
<CloudCog className="ml-3 size-4 transition-all group-hover:scale-110" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<div className="font-semibold">Cloudflare R2</div>
|
||||
{ReadyBadge(isCheckedR2Config, isCheckingR2Config, "r2")}
|
||||
<Icons.chevronDown className="ml-3 size-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-4 rounded-lg border p-6 shadow-md">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Endpoint")}</Label>
|
||||
<Input
|
||||
value={r2Credentials.endpoint}
|
||||
placeholder="https://<account_id>.r2.cloudflarestorage.com"
|
||||
onChange={(e) =>
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
endpoint: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Access Key ID")}</Label>
|
||||
<Input
|
||||
value={r2Credentials.access_key_id}
|
||||
onChange={(e) =>
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
access_key_id: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Secret Access Key")}</Label>
|
||||
<Input
|
||||
value={r2Credentials.secret_access_key}
|
||||
onChange={(e) =>
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
secret_access_key: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
<Label>{t("Enabled")}</Label>
|
||||
<Switch
|
||||
checked={r2Credentials.enabled}
|
||||
onCheckedChange={(e) =>
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
enabled: e,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{r2Credentials.buckets.map((bucket, index) => (
|
||||
<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-${index}`}
|
||||
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")} {index + 1}
|
||||
</p>
|
||||
<div className="absolute right-2 top-2 flex items-center justify-between space-x-2">
|
||||
{index > 0 && (
|
||||
<Button
|
||||
className="h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets.splice(index, 1);
|
||||
newBuckets.splice(index - 1, 0, bucket);
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.arrowUp className="size-4" />{" "}
|
||||
</Button>
|
||||
)}
|
||||
{index < r2Credentials.buckets.length - 1 && (
|
||||
<Button
|
||||
className="h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets.splice(index, 1);
|
||||
newBuckets.splice(index + 1, 0, bucket);
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.arrowDown className="size-4" />{" "}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets.splice(index + 1, 0, {
|
||||
bucket: "",
|
||||
prefix: "",
|
||||
file_types: "",
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
public: true,
|
||||
});
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />{" "}
|
||||
</Button>
|
||||
{index !== 0 && (
|
||||
<Button
|
||||
className="h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
<Icons.trash
|
||||
className="size-4"
|
||||
onClick={() => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets.splice(index, 1);
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Bucket Name")}*</Label>
|
||||
<Input
|
||||
value={bucket.bucket}
|
||||
placeholder="bucket name"
|
||||
onChange={(e) => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets[index] = {
|
||||
...bucket,
|
||||
bucket: e.target.value,
|
||||
};
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Public Domain")}*</Label>
|
||||
<Input
|
||||
value={bucket.custom_domain}
|
||||
placeholder="https://endpoint or custom domain"
|
||||
onChange={(e) => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets[index] = {
|
||||
...bucket,
|
||||
custom_domain: e.target.value,
|
||||
};
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Region")}</Label>
|
||||
<Input
|
||||
value={bucket.region}
|
||||
placeholder="auto"
|
||||
onChange={(e) => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets[index] = {
|
||||
...bucket,
|
||||
region: e.target.value,
|
||||
};
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{t("Prefix")} ({t("Optional")})
|
||||
</Label>
|
||||
<Input
|
||||
value={bucket.prefix}
|
||||
placeholder="2025/08/08"
|
||||
onChange={(e) => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets[index] = {
|
||||
...bucket,
|
||||
prefix: e.target.value,
|
||||
};
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</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) =>
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: r2Credentials.buckets.map((b, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...b,
|
||||
public: e,
|
||||
};
|
||||
}
|
||||
return b;
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="space-y-1">
|
||||
<Label>
|
||||
{t("Allowed File Types")} ({t("Optional")})
|
||||
</Label>
|
||||
<Input
|
||||
value={bucket.file_types}
|
||||
placeholder=""
|
||||
disabled
|
||||
onChange={(e) => {
|
||||
const newBuckets = [...r2Credentials.buckets];
|
||||
newBuckets[index] = {
|
||||
...bucket,
|
||||
file_types: e.target.value,
|
||||
};
|
||||
setR2Credentials({
|
||||
...r2Credentials,
|
||||
buckets: newBuckets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div> */}
|
||||
</motion.div>
|
||||
))}
|
||||
<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 R2 credentials?")}
|
||||
</Link>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setR2Credentials({
|
||||
platform: "cloudflare",
|
||||
channel: "r2",
|
||||
provider_name: "cloudflare",
|
||||
endpoint: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
buckets: [],
|
||||
account_id: "",
|
||||
enabled: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending || !canSaveR2Credentials}
|
||||
onClick={() => {
|
||||
handleSaveConfigs(r2Credentials, "s3_config_01", "OBJECT");
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
@@ -117,9 +117,9 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
@@ -181,9 +181,9 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
export interface LogsTableData {
|
||||
@@ -132,9 +132,9 @@ const LogsTable = ({ userId, target }) => {
|
||||
className="ml-2 h-8 px-2 py-0"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className={`size-4 animate-spin`} />
|
||||
<Icons.refreshCw className={`size-4 animate-spin`} />
|
||||
) : (
|
||||
<RefreshCwIcon className={`size-4`} />
|
||||
<Icons.refreshCw className={`size-4`} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
/>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserFileList from "@/components/file";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Cloud Storage",
|
||||
description: "List and manage cloud storage.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
link="/docs/cloud-storage"
|
||||
linkText="Cloud Storage"
|
||||
/>
|
||||
<UserFileList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/storage"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -180,9 +180,9 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
|
||||
disabled={!isLive}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
@@ -702,9 +702,9 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{action.indexOf("admin") === -1 && (
|
||||
|
||||
@@ -52,6 +52,9 @@ export async function POST(req: NextRequest) {
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
@@ -95,6 +98,9 @@ export async function PUT(req: NextRequest) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
getMultipleConfigs,
|
||||
updateSystemConfig,
|
||||
} from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const s3ConfigKeys = [
|
||||
"s3_config_01",
|
||||
"s3_config_02",
|
||||
"s3_config_03",
|
||||
"s3_config_04",
|
||||
];
|
||||
|
||||
const configs = await getMultipleConfigs(s3ConfigKeys);
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { key, value, type } = await req.json();
|
||||
if (!key || !type) {
|
||||
return Response.json("key and value is required", { status: 400 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs([key]);
|
||||
|
||||
if (key in configs) {
|
||||
await updateSystemConfig(key, { value, type });
|
||||
return Response.json("Success", { status: 200 });
|
||||
}
|
||||
return Response.json("Invalid key", { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
@@ -26,6 +28,7 @@ export async function GET(req: Request) {
|
||||
|
||||
return Response.json(data);
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
|
||||
if (!configs.s3_config_01 || !configs.s3_config_01.enabled) {
|
||||
return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
buckets: configs.s3_config_01.buckets,
|
||||
enabled: configs.s3_config_01.enabled,
|
||||
provider_name: configs.s3_config_01.provider_name,
|
||||
platform: configs.s3_config_01.platform,
|
||||
channel: configs.s3_config_01.channel,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = url.searchParams.get("page");
|
||||
const size = url.searchParams.get("size");
|
||||
const bucket = url.searchParams.get("bucket") || "";
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const res = await getUserFiles({
|
||||
page: Number(page) || 1,
|
||||
limit: Number(size) || 20,
|
||||
bucket,
|
||||
channel: configs.s3_config_01.channel,
|
||||
platform: configs.s3_config_01.platform,
|
||||
});
|
||||
|
||||
return NextResponse.json(res);
|
||||
} catch (error) {
|
||||
console.error("Error listing files:", error);
|
||||
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const { key, bucket } = await request.json();
|
||||
if (!key || !bucket) {
|
||||
return NextResponse.json("key and bucket is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const signedUrl = await getSignedUrlForDownload(
|
||||
key,
|
||||
createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
),
|
||||
bucket,
|
||||
);
|
||||
return NextResponse.json({ signedUrl });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Error generating download URL" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const { keys, ids, bucket } = await request.json();
|
||||
|
||||
if (!keys || !ids || !bucket) {
|
||||
return NextResponse.json("key and bucket is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const R2 = createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
);
|
||||
|
||||
for (const key of keys) {
|
||||
await deleteFile(key, R2, bucket);
|
||||
}
|
||||
await softDeleteUserFiles(ids);
|
||||
return NextResponse.json({ message: "File deleted successfully" });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
|
||||
if (!configs.s3_config_01 || !configs.s3_config_01.enabled) {
|
||||
return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
buckets: configs.s3_config_01.buckets.filter((b) => b.public), // public
|
||||
enabled: configs.s3_config_01.enabled,
|
||||
provider_name: configs.s3_config_01.provider_name,
|
||||
platform: configs.s3_config_01.platform,
|
||||
channel: configs.s3_config_01.channel,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = url.searchParams.get("page");
|
||||
const size = url.searchParams.get("size");
|
||||
const bucket = url.searchParams.get("bucket") || "";
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const res = await getUserFiles({
|
||||
page: Number(page) || 1,
|
||||
limit: Number(size) || 20,
|
||||
bucket,
|
||||
userId: user.id,
|
||||
status: 1,
|
||||
channel: configs.s3_config_01.channel,
|
||||
platform: configs.s3_config_01.platform,
|
||||
});
|
||||
|
||||
return NextResponse.json(res);
|
||||
} catch (error) {
|
||||
console.error("Error listing files:", error);
|
||||
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { key, bucket } = await request.json();
|
||||
if (!key || !bucket) {
|
||||
return NextResponse.json("key and bucket is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const signedUrl = await getSignedUrlForDownload(
|
||||
key,
|
||||
createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
),
|
||||
bucket,
|
||||
);
|
||||
return NextResponse.json({ signedUrl });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Error generating download URL" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { keys, ids, bucket } = await request.json();
|
||||
|
||||
if (!keys || !ids || !bucket) {
|
||||
return NextResponse.json("key and bucket is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const R2 = createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
);
|
||||
|
||||
for (const key of keys) {
|
||||
await deleteFile(key, R2, bucket);
|
||||
}
|
||||
await softDeleteUserFiles(ids);
|
||||
return NextResponse.json({ message: "File deleted successfully" });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { updateUserFile } from "@/lib/dto/files";
|
||||
import { getUserShortLinksByIds } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { ids } = await request.json();
|
||||
if (!ids) {
|
||||
return NextResponse.json({ error: "Ids are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await getUserShortLinksByIds(ids, user.id);
|
||||
|
||||
// ids:["cmcrqtql10001zbhynvwfuqza", "", "", "", ""],则返回的短链位置也要一一对应
|
||||
const dataMap = new Map(data.map((item) => [item.id, item]));
|
||||
|
||||
const orderedResults = ids.map((id) => {
|
||||
const item = dataMap.get(id);
|
||||
return item ? `${item.prefix}/s/${item.url}` : "";
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
urls: orderedResults,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Error generating download URL" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { urlId, fileId } = await request.json();
|
||||
|
||||
if (!urlId || !fileId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Slug and fileId are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const res = await updateUserFile(fileId, {
|
||||
shortUrlId: urlId,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
return NextResponse.json({ error: res.error }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Error generating download URL" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createUserFile } from "@/lib/dto/files";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { CloudStorageCredentials, createS3Client } from "@/lib/r2";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
import { extractFileNameAndExtension, generateFileKey } from "@/lib/utils";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const formData = await request.formData();
|
||||
const endpoint = formData.get("endPoint");
|
||||
const bucket = formData.get("bucket");
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const R2 = createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
);
|
||||
|
||||
switch (endpoint) {
|
||||
case "create-multipart-upload":
|
||||
return createMultipartUpload(user, formData, R2);
|
||||
case "complete-multipart-upload":
|
||||
return completeMultipartUpload(
|
||||
formData,
|
||||
R2,
|
||||
user.id,
|
||||
configs.s3_config_01,
|
||||
);
|
||||
case "abort-multipart-upload":
|
||||
return abortMultipartUpload(formData, R2);
|
||||
case "upload-part":
|
||||
return uploadPart(formData, R2);
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Endpoint not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initiates a multipart upload
|
||||
async function createMultipartUpload(
|
||||
user: User,
|
||||
formData: FormData,
|
||||
R2: S3Client,
|
||||
): Promise<Response> {
|
||||
const fileName = formData.get("fileName") as string;
|
||||
const fileType = formData.get("fileType") as string;
|
||||
const fileSize = Number(formData.get("fileSize") as string);
|
||||
const bucket = formData.get("bucket") as string;
|
||||
const prefix = (formData.get("prefix") as string) || "";
|
||||
|
||||
const plan = await getPlanQuota(user.team!);
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userUrl",
|
||||
userId: user.id,
|
||||
limit: Number(plan.stMaxFileSize),
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const fileKey = generateFileKey(fileName, prefix);
|
||||
try {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: fileKey,
|
||||
ContentType: fileType,
|
||||
};
|
||||
|
||||
const command = new CreateMultipartUploadCommand({ ...params });
|
||||
const response = await R2.send(command);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
uploadId: response.UploadId,
|
||||
key: response.Key,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.log("Error From Create Multipart Upload => ", err);
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Completes a multipart upload
|
||||
async function completeMultipartUpload(
|
||||
formData: FormData,
|
||||
R2: S3Client,
|
||||
userId: string,
|
||||
bucketInfo: CloudStorageCredentials,
|
||||
): Promise<Response> {
|
||||
const key = formData.get("key") as string;
|
||||
const uploadId = formData.get("uploadId") as string;
|
||||
const bucket = formData.get("bucket") as string;
|
||||
const size = parseInt(formData.get("fileSize") as string);
|
||||
const fileType = formData.get("fileType") as string;
|
||||
// const fileName = formData.get("fileName") as string;
|
||||
|
||||
const parts = JSON.parse(formData.get("parts") as string);
|
||||
|
||||
try {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: { Parts: parts },
|
||||
};
|
||||
const command = new CompleteMultipartUploadCommand({ ...params });
|
||||
const response = await R2.send(command);
|
||||
|
||||
const extractKey = extractFileNameAndExtension(key);
|
||||
|
||||
await createUserFile({
|
||||
userId,
|
||||
name: extractKey.fileName,
|
||||
originalName: extractKey.nameWithoutExtension,
|
||||
mimeType: fileType,
|
||||
path: key,
|
||||
etag: "",
|
||||
storageClass: "",
|
||||
channel: bucketInfo.channel || "",
|
||||
platform: bucketInfo.platform || "",
|
||||
providerName: bucketInfo.provider_name || "",
|
||||
size,
|
||||
bucket,
|
||||
lastModified: new Date(),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(response), { status: 200 });
|
||||
} catch (err) {
|
||||
console.log("Error", err);
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aborts a multipart upload
|
||||
async function abortMultipartUpload(
|
||||
formData: FormData,
|
||||
R2: S3Client,
|
||||
): Promise<Response> {
|
||||
const key = formData.get("key") as string;
|
||||
const bucket = formData.get("bucket") as string;
|
||||
const uploadId = formData.get("uploadId") as string;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
};
|
||||
const command = new AbortMultipartUploadCommand({ ...params });
|
||||
const response = await R2.send(command);
|
||||
|
||||
return new Response(JSON.stringify(response), { status: 200 });
|
||||
} catch (err) {
|
||||
console.log("Error", err);
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads a part of a file
|
||||
async function uploadPart(formData: FormData, R2: S3Client): Promise<Response> {
|
||||
const key = formData.get("key") as string;
|
||||
const bucket = formData.get("bucket") as string;
|
||||
const uploadId = formData.get("uploadId") as string;
|
||||
const partNumber = Number(formData.get("partNumber")) as number;
|
||||
const chunk = formData.get("chunk") as File;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
PartNumber: partNumber,
|
||||
UploadId: uploadId,
|
||||
Body: buffer,
|
||||
};
|
||||
|
||||
const command = new UploadPartCommand({ ...params });
|
||||
const response = await R2.send(command);
|
||||
|
||||
return new Response(JSON.stringify({ etag: response.ETag }), {
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Error From Uploadpart => ", err);
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -10,11 +10,10 @@ import { ViewTransitions } from "next-view-transitions";
|
||||
import { cn, constructMetadata } from "@/lib/utils";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import ModalProvider from "@/components/modals/providers";
|
||||
import GoogleAnalytics from "@/components/shared/GoogleAnalytics";
|
||||
import UmamiAnalytics from "@/components/shared/UmamiAnalytics";
|
||||
import { TailwindIndicator } from "@/components/tailwind-indicator";
|
||||
|
||||
import GoogleAnalytics from "../components/shared/GoogleAnalytics";
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "1.0.8",
|
||||
"versionName": "1.1.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import { MetadataRoute } from "next"
|
||||
import { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: "/admin/*",
|
||||
},
|
||||
}
|
||||
sitemap: process.env.NEXT_PUBLIC_APP_URL + "/sitemap.xml",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { allDocs, allPages } from "contentlayer/generated";
|
||||
|
||||
async function getDocumentSlugs() {
|
||||
return allDocs.map((doc) => ({
|
||||
slug: doc.slugAsParams,
|
||||
}));
|
||||
}
|
||||
async function getStaticPageSlugs() {
|
||||
return allPages.map((page) => ({
|
||||
slug: page.slugAsParams,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://wr.do";
|
||||
const currentDate = new Date();
|
||||
|
||||
// static
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: "daily",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/login`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/feedback`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
// (docs)/[slug]
|
||||
const documentSlugs = await getDocumentSlugs();
|
||||
const documentPages: MetadataRoute.Sitemap = documentSlugs.map((slug) => ({
|
||||
url: `${baseUrl}/docs/${slug.slug}`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
// (marketing)/[slug]
|
||||
const marketingPageSlugs = await getStaticPageSlugs();
|
||||
const marketingPages: MetadataRoute.Sitemap = marketingPageSlugs.map(
|
||||
(slug) => ({
|
||||
url: `${baseUrl}/${slug.slug}`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.7,
|
||||
}),
|
||||
);
|
||||
|
||||
const protectedPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${baseUrl}/dashboard`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.7,
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...staticPages,
|
||||
...documentPages,
|
||||
...marketingPages,
|
||||
...protectedPages,
|
||||
];
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function SendEmailModal({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
|
||||
<DrawerContent className="fixed bottom-0 right-0 top-0 w-full rounded-none sm:max-w-xl">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
import { BucketInfo } from "@/components/file";
|
||||
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const DragAndDrop = ({
|
||||
setSelectedFile,
|
||||
bucketInfo,
|
||||
}: {
|
||||
setSelectedFile: Dispatch<SetStateAction<File[] | null>>;
|
||||
bucketInfo: BucketInfo;
|
||||
}) => {
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setSelectedFile((prev) => [...(prev ?? []), ...acceptedFiles]);
|
||||
},
|
||||
[setSelectedFile],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`grids flex h-52 w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-4 duration-150 ${
|
||||
isDragActive
|
||||
? "border-opacity-90 bg-muted/80 backdrop-blur-[2px]"
|
||||
: "border-opacity-50 bg-muted/10 backdrop-blur-[1px]"
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-fit transition-all duration-300">
|
||||
<Icons.cloudUpload className="size-20" />
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<div className="animate-fade-in text-primary">
|
||||
{t("Drop files to upload them to")} {bucketInfo.bucket}
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-out">
|
||||
<p>{t("Drag and drop file(s) here")}</p>
|
||||
<p className="my-2 text-sm text-muted-foreground">{t("or")}</p>
|
||||
<Button size="sm">{t("Browse file(s)")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default DragAndDrop;
|
||||
@@ -0,0 +1,807 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { User } from "@prisma/client";
|
||||
import {
|
||||
Archive,
|
||||
Download,
|
||||
FileCode,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType2,
|
||||
Folder,
|
||||
ImageOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { UserFileData } from "@/lib/dto/files";
|
||||
import {
|
||||
cn,
|
||||
downloadFileFromUrl,
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
truncateMiddle,
|
||||
} from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { BucketInfo, DisplayType, FileListData } from "@/components/file";
|
||||
|
||||
import { UrlForm } from "../forms/url-form";
|
||||
import { CopyButton } from "../shared/copy-button";
|
||||
import { EmptyPlaceholder } from "../shared/empty-placeholder";
|
||||
import { Icons } from "../shared/icons";
|
||||
import { PaginationWrapper } from "../shared/pagination";
|
||||
import QRCodeEditor from "../shared/qr";
|
||||
import { TimeAgoIntl } from "../shared/time-ago";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Modal } from "../ui/modal";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { TableCell, TableRow } from "../ui/table";
|
||||
|
||||
interface Props {
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role" | "team">;
|
||||
files?: FileListData;
|
||||
isLoading: boolean;
|
||||
bucketInfo: BucketInfo;
|
||||
action: string;
|
||||
view: DisplayType;
|
||||
showMutiCheckBox: boolean;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
selectedFiles: UserFileData[];
|
||||
setSelectedFiles: (files: UserFileData[]) => void;
|
||||
onRefresh: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteAll: () => void;
|
||||
}
|
||||
|
||||
export default function UserFileList({
|
||||
user,
|
||||
files,
|
||||
isLoading,
|
||||
bucketInfo,
|
||||
action,
|
||||
view,
|
||||
showMutiCheckBox,
|
||||
currentPage,
|
||||
pageSize,
|
||||
setCurrentPage,
|
||||
setPageSize,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onSelectAll,
|
||||
}: Props) {
|
||||
const t = useTranslations("List");
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [shortTarget, setShortTarget] = useState<UserFileData | null>(null);
|
||||
const [shortLinks, setShortLinks] = useState<string[]>([]);
|
||||
const [isShowQrcode, setShowQrcode] = useState(false);
|
||||
const [currentSelectFile, setCurrentSelectFile] =
|
||||
useState<UserFileData | null>();
|
||||
|
||||
const isAdmin = action.includes("/admin");
|
||||
|
||||
const getFileUrl = (key: string) => {
|
||||
return `${bucketInfo.custom_domain}/${key}`;
|
||||
};
|
||||
|
||||
const handleSelectFile = (file: UserFileData) => {
|
||||
if (selectedFiles.includes(file)) {
|
||||
setSelectedFiles(selectedFiles.filter((f) => f.id !== file.id));
|
||||
} else {
|
||||
setSelectedFiles([...selectedFiles, file]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (file: UserFileData) => {
|
||||
downloadFileFromUrl(getFileUrl(file.path), file.name);
|
||||
};
|
||||
|
||||
const handlePreviewRawFile = async (key: string) => {
|
||||
try {
|
||||
const response = await fetch(`${action}/r2/files`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, bucket: bucketInfo.bucket }),
|
||||
});
|
||||
const { signedUrl } = await response.json();
|
||||
window.open(signedUrl, "_blank");
|
||||
} catch (error) {
|
||||
console.error("Error downloading file:", error);
|
||||
alert("Error downloading file");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSingle = async (file: UserFileData) => {
|
||||
if (!confirm("Are you sure you want to delete this file?")) return;
|
||||
|
||||
try {
|
||||
toast.promise(
|
||||
fetch(`${action}/r2/files`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
keys: [file.path],
|
||||
ids: [file.id],
|
||||
bucket: bucketInfo.bucket,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
loading: "Deleting file...",
|
||||
success: "File deleted successfully!",
|
||||
error: "Error deleting file",
|
||||
finally: onRefresh,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
toast.success("Error deleting file");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateShortLink = async (urlId: string) => {
|
||||
if (!shortTarget) return;
|
||||
try {
|
||||
const response = await fetch(`${action}/r2/short`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ urlId, fileId: shortTarget?.id }),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Error generating short link");
|
||||
} else {
|
||||
onRefresh();
|
||||
// handleGetFileShortLinkByIds();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating short link:", error);
|
||||
toast.error("Error generating short link");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetFileShortLinkByIds = async () => {
|
||||
if (!files || !files.list) return;
|
||||
try {
|
||||
const ids = files.list.map((f) => f.shortUrlId || "");
|
||||
if (!ids?.some((id) => id !== "")) return;
|
||||
const response = await fetch(`${action}/r2/short`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setShortLinks(data.urls);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error get short link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleGetFileShortLinkByIds();
|
||||
}, [files]);
|
||||
|
||||
if (files && files.total === 0) {
|
||||
return (
|
||||
<EmptyPlaceholder className="col-span-full shadow-none">
|
||||
<EmptyPlaceholder.Icon name="fileText" />
|
||||
<EmptyPlaceholder.Title>{t("No Files")}</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
{t("You don't upload any files yet")}
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
const renderFileLinks = (file: UserFileData, index: number) => (
|
||||
<>
|
||||
{!isAdmin && file.shortUrlId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.unLink className="size-3 flex-shrink-0 text-blue-500" />
|
||||
<Link
|
||||
href={"https://" + shortLinks[index]}
|
||||
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800"
|
||||
target="_blank"
|
||||
>
|
||||
https://{shortLinks[index]}
|
||||
</Link>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`https://${shortLinks[index]}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.link className="size-3 flex-shrink-0" />
|
||||
<Link
|
||||
href={getFileUrl(file.path)}
|
||||
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800"
|
||||
target="_blank"
|
||||
>
|
||||
{getFileUrl(file.path)}
|
||||
</Link>
|
||||
<CopyButton className="size-6" value={getFileUrl(file.path)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.code className="size-3 flex-shrink-0" />
|
||||
<p className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800">
|
||||
{`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
|
||||
</p>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.type className="size-3 flex-shrink-0" />
|
||||
<p className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800">
|
||||
{`[${file.name}](${getFileUrl(file.path)})`}
|
||||
</p>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`[${file.name}](${getFileUrl(file.path)})`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="overflow-hidden rounded-lg border bg-primary-foreground">
|
||||
<div className="text-mute-foreground grid grid-cols-6 gap-4 bg-neutral-100 px-6 py-3 text-sm font-medium dark:bg-neutral-800 sm:grid-cols-10">
|
||||
{showMutiCheckBox && (
|
||||
<div className="col-span-1 flex">
|
||||
<Checkbox
|
||||
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
|
||||
checked={selectedFiles.length === files?.list.length}
|
||||
onCheckedChange={() => onSelectAll()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(showMutiCheckBox ? "col-span-2" : "col-span-3")}>
|
||||
{t("Name")}
|
||||
</div>
|
||||
<div className="col-span-2 hidden sm:flex">{t("Type")}</div>
|
||||
<div className="col-span-1">{t("Size")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("User")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("Date")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("Active")}</div>
|
||||
<div className="col-span-1">{t("Actions")}</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : (
|
||||
<div className="divide-y divide-neutral-200 dark:divide-neutral-600">
|
||||
{files?.list.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="text-mute-foreground grid grid-cols-6 gap-4 px-6 py-4 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-600 sm:grid-cols-10"
|
||||
>
|
||||
{showMutiCheckBox && (
|
||||
<div
|
||||
className="col-span-1 flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedFiles.find((f) => f.id === file.id) !== undefined
|
||||
}
|
||||
onCheckedChange={() => handleSelectFile(file)}
|
||||
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"items-center space-x-3 text-sm",
|
||||
showMutiCheckBox ? "col-span-2" : "col-span-3",
|
||||
)}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-1 break-all text-start",
|
||||
file.status !== 1 && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{truncateMiddle(file.path)}
|
||||
{file.status === 1 && (
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={getFileUrl(file.path)}
|
||||
/>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="w-72 space-y-1 text-wrap p-3 text-start"
|
||||
>
|
||||
{file.mimeType.startsWith("image/") &&
|
||||
file.status === 1 && (
|
||||
<img
|
||||
className="max-h-[70vh] w-72 rounded shadow"
|
||||
width={300}
|
||||
height={300}
|
||||
src={getFileUrl(file.path)}
|
||||
alt={`${file.path}`}
|
||||
/>
|
||||
)}
|
||||
{renderFileLinks(file, index)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center text-xs sm:flex">
|
||||
<Badge className="truncate" variant="outline">
|
||||
{file.mimeType || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center text-nowrap text-xs">
|
||||
{formatFileSize(file.size || 0)}
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center text-xs sm:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
{file.user.name ?? file.user.email}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{file.user.name}</p>
|
||||
<p>{file.user.email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center text-nowrap text-xs sm:flex">
|
||||
<TimeAgoIntl date={file.updatedAt as Date} />
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center text-xs sm:flex">
|
||||
<Switch checked={file.status === 1} disabled />
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="size-[25px] p-1.5"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.moreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCurrentSelectFile(file);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="size-4" />
|
||||
{t("QR Code")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShortTarget(file);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<Icons.link className="size-4" />
|
||||
{file.shortUrlId
|
||||
? t("Update short link")
|
||||
: t("Generate short link")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handlePreviewRawFile(file.path)}
|
||||
>
|
||||
<Icons.eye className="size-4" />
|
||||
{t("Raw Data")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Icons.download className="size-4" />
|
||||
{t("Download")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2 text-red-500"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => file.path && handleDeleteSingle(file)}
|
||||
>
|
||||
<Icons.trash className="size-4" />
|
||||
{t("Delete File")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGridView = () => (
|
||||
<div
|
||||
className="grid justify-items-center gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(10px, 100px))",
|
||||
}}
|
||||
>
|
||||
{isLoading &&
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<Skeleton key={v} className="size-[100px]" />
|
||||
))}
|
||||
{files?.list.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer items-end rounded-md transition-all hover:bg-blue-50",
|
||||
selectedFiles.find((f) => f.id === file.id) !== undefined &&
|
||||
"bg-blue-50",
|
||||
)}
|
||||
onClick={() => handleSelectFile(file)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-1 py-1">
|
||||
{showMutiCheckBox && (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedFiles.find((f) => f.id === file.id) !== undefined
|
||||
}
|
||||
// onCheckedChange={() => handleSelectFile(file)}
|
||||
className="absolute left-1 top-1 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
|
||||
/>
|
||||
)}
|
||||
{React.cloneElement(getFileIcon(file, bucketInfo), { size: 40 })}
|
||||
<div className="w-full text-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger className="mx-auto line-clamp-2 max-w-[60px] break-all px-2 pb-1 text-left text-xs font-medium text-muted-foreground group-hover:text-blue-500 sm:max-w-[100px]">
|
||||
{truncateMiddle(file.path || "")}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[300px] space-y-1 p-3 text-start"
|
||||
>
|
||||
{file.mimeType.startsWith("image/") &&
|
||||
file.status === 1 && (
|
||||
<img
|
||||
className="mb-2 max-h-[70vh] w-fit rounded shadow"
|
||||
width={300}
|
||||
height={300}
|
||||
src={getFileUrl(file.path)}
|
||||
alt={`${file.path}`}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1 text-sm font-semibold text-muted-foreground">
|
||||
{file.path}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<strong>Size:</strong> {formatFileSize(file.size || 0)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<strong>Type:</strong> {file.mimeType || "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<strong>User:</strong> {file.user.name || file.user.email}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<strong>Modified:</strong>{" "}
|
||||
{formatDate(file.lastModified?.toString() || "")}
|
||||
</p>
|
||||
{renderFileLinks(file, index)}
|
||||
<div className="flex items-center justify-end space-x-1 pt-2">
|
||||
<Button
|
||||
className="flex h-7 w-full items-center gap-2 text-xs"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePreviewRawFile(file.path)}
|
||||
disabled={file.status !== 1}
|
||||
>
|
||||
<Icons.eye className="size-4" />
|
||||
{t("Raw Data")}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1.5 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
disabled={file.status !== 1}
|
||||
onClick={() => {
|
||||
setCurrentSelectFile(file);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handlePreviewRawFile(file.path)}
|
||||
className="h-7 px-1.5"
|
||||
title="下载"
|
||||
size="sm"
|
||||
variant={"blue"}
|
||||
disabled={file.status !== 1}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
{file.status === 1 && (
|
||||
<Button
|
||||
onClick={() => handleDeleteSingle(file)}
|
||||
className="h-7 px-1.5"
|
||||
title="删除"
|
||||
size="sm"
|
||||
variant={"destructive"}
|
||||
disabled={file.status !== 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{view === "List" ? renderListView() : renderGridView()}
|
||||
{files && Math.ceil(files.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={files.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
className="md:max-w-2xl"
|
||||
showModal={isShowForm}
|
||||
setShowModal={setShowForm}
|
||||
>
|
||||
<UrlForm
|
||||
user={{ id: "", name: "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type="add"
|
||||
initData={{
|
||||
target: getFileUrl(shortTarget?.path || ""),
|
||||
userId: "",
|
||||
userName: "",
|
||||
url: "",
|
||||
prefix: "",
|
||||
visible: 1,
|
||||
active: 1,
|
||||
expiration: "-1",
|
||||
password: "",
|
||||
}}
|
||||
action="/api/url"
|
||||
onRefresh={handleGenerateShortLink}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="md:max-w-lg"
|
||||
showModal={isShowQrcode}
|
||||
setShowModal={setShowQrcode}
|
||||
>
|
||||
{currentSelectFile && (
|
||||
<QRCodeEditor
|
||||
user={{
|
||||
id: user.id,
|
||||
apiKey: user.apiKey || "",
|
||||
team: user.team || "free",
|
||||
}}
|
||||
url={getFileUrl(currentSelectFile.path)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-12">
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-10" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-3 sm:col-span-2">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-2 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-2 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-2 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const getFileIcon = (file: UserFileData, bucketInfo: BucketInfo) => {
|
||||
const filename = file.path;
|
||||
const mimeType = file.mimeType;
|
||||
const status = file.status;
|
||||
const iconProps = { size: 24, className: "text-gray-600" };
|
||||
|
||||
// 如果没有 mimeType,回退到文件夹判断
|
||||
if (!mimeType) {
|
||||
if (filename.endsWith("/")) {
|
||||
return <Folder {...iconProps} className="text-yellow-500" />;
|
||||
}
|
||||
return <FileText {...iconProps} className="text-gray-500" />;
|
||||
}
|
||||
|
||||
if (mimeType.startsWith("image/")) {
|
||||
if (mimeType === "image/svg+xml") {
|
||||
return <FileCode {...iconProps} className="text-blue-500" />;
|
||||
}
|
||||
if (status === 1) {
|
||||
return (
|
||||
<img
|
||||
className="max-h-12 w-fit max-w-24 rounded shadow"
|
||||
height={60}
|
||||
width={60}
|
||||
src={
|
||||
bucketInfo.custom_domain
|
||||
? `${bucketInfo.custom_domain}/${filename}`
|
||||
: filename
|
||||
}
|
||||
alt={filename}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <ImageOff {...iconProps} className="text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
// 压缩文件
|
||||
if (
|
||||
mimeType === "application/zip" ||
|
||||
mimeType === "application/x-rar-compressed" ||
|
||||
mimeType === "application/x-7z-compressed" ||
|
||||
mimeType === "application/x-tar" ||
|
||||
mimeType === "application/gzip" ||
|
||||
mimeType === "application/x-gzip"
|
||||
) {
|
||||
return <Archive {...iconProps} className="text-orange-500" />;
|
||||
}
|
||||
|
||||
// Microsoft Office 文档
|
||||
if (
|
||||
mimeType ===
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||
mimeType === "application/msword"
|
||||
) {
|
||||
return <FileText {...iconProps} className="text-blue-600" />;
|
||||
}
|
||||
|
||||
// Microsoft Office 演示文稿
|
||||
if (
|
||||
mimeType ===
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
|
||||
mimeType === "application/vnd.ms-powerpoint"
|
||||
) {
|
||||
return <FileText {...iconProps} className="text-red-500" />;
|
||||
}
|
||||
|
||||
// Microsoft Office 电子表格
|
||||
if (
|
||||
mimeType ===
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
mimeType === "application/vnd.ms-excel" ||
|
||||
mimeType === "text/csv"
|
||||
) {
|
||||
return <FileSpreadsheet {...iconProps} className="text-green-600" />;
|
||||
}
|
||||
|
||||
// JSON 文件
|
||||
if (mimeType === "application/json") {
|
||||
return <FileCode {...iconProps} className="text-yellow-600" />;
|
||||
}
|
||||
|
||||
// Markdown 文件
|
||||
if (mimeType === "text/markdown" || mimeType === "text/x-markdown") {
|
||||
return <FileType2 {...iconProps} className="text-gray-700" />;
|
||||
}
|
||||
|
||||
// 代码文件
|
||||
if (
|
||||
mimeType.startsWith("text/") ||
|
||||
mimeType === "application/javascript" ||
|
||||
mimeType === "application/typescript" ||
|
||||
mimeType === "application/x-javascript" ||
|
||||
mimeType === "text/javascript" ||
|
||||
mimeType === "text/typescript"
|
||||
) {
|
||||
return <FileCode {...iconProps} className="text-blue-400" />;
|
||||
}
|
||||
|
||||
// PDF 文件
|
||||
if (mimeType === "application/pdf") {
|
||||
return <FileText {...iconProps} className="text-red-600" />;
|
||||
}
|
||||
|
||||
// 音频文件
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return <FileText {...iconProps} className="text-purple-500" />;
|
||||
}
|
||||
|
||||
// 视频文件
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return <FileText {...iconProps} className="text-pink-500" />;
|
||||
}
|
||||
|
||||
// 默认文件图标
|
||||
return <FileText {...iconProps} className="text-gray-500" />;
|
||||
};
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { UserFileData } from "@/lib/dto/files";
|
||||
import { BucketItem, ClientStorageCredentials } from "@/lib/r2";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import UserFileList from "@/components/file/file-list";
|
||||
import Uploader from "@/components/file/uploader";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
import { EmptyPlaceholder } from "../shared/empty-placeholder";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { CircularStorageIndicator, FileSizeDisplay } from "./storage-size";
|
||||
|
||||
export interface FileListProps {
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface BucketInfo extends BucketItem {
|
||||
platform?: string;
|
||||
channel?: string;
|
||||
provider_name?: string;
|
||||
}
|
||||
|
||||
export type DisplayType = "List" | "Grid";
|
||||
|
||||
export interface FileListData {
|
||||
total: number;
|
||||
totalSize: number;
|
||||
list: UserFileData[];
|
||||
}
|
||||
|
||||
export interface StorageUserPlan {
|
||||
stMaxTotalSize: string;
|
||||
stMaxFileSize: string;
|
||||
}
|
||||
|
||||
export default function UserFileManager({ user, action }: FileListProps) {
|
||||
const t = useTranslations("List");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [displayType, setDisplayType] = useState<DisplayType>("List");
|
||||
const [showMutiCheckBox, setShowMutiCheckBox] = useState(false);
|
||||
const [bucketInfo, setBucketInfo] = useState<BucketInfo>({
|
||||
bucket: "",
|
||||
custom_domain: "",
|
||||
prefix: "",
|
||||
platform: "",
|
||||
channel: "",
|
||||
provider_name: "",
|
||||
public: true,
|
||||
});
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<UserFileData[]>([]);
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const isAdmin = action.includes("/admin");
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
|
||||
`${action}/r2/configs`,
|
||||
fetcher,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const { data: files, isLoading: isLoadingFiles } = useSWR<FileListData>(
|
||||
bucketInfo.bucket
|
||||
? `${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`
|
||||
: null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 5000, // 防抖
|
||||
},
|
||||
);
|
||||
|
||||
const { data: plan } = useSWR<StorageUserPlan>(
|
||||
`/api/plan?team=${user.team}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (r2Configs && r2Configs.buckets && r2Configs.buckets.length > 0) {
|
||||
setBucketInfo({
|
||||
...r2Configs.buckets[0],
|
||||
platform: r2Configs.platform,
|
||||
channel: r2Configs.channel,
|
||||
provider_name: r2Configs.provider_name,
|
||||
});
|
||||
}
|
||||
}, [r2Configs]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeBucket = (bucket: string) => {
|
||||
const newBucketInfo = r2Configs?.buckets?.find(
|
||||
(item) => item.bucket === bucket,
|
||||
);
|
||||
setBucketInfo({
|
||||
...bucketInfo,
|
||||
...newBucketInfo,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllFiles = () => {
|
||||
if (selectedFiles.length === files?.list.length) {
|
||||
setSelectedFiles([]);
|
||||
} else {
|
||||
setSelectedFiles(files?.list || []);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAllFiles = () => {
|
||||
startDeleteTransition(async () => {
|
||||
try {
|
||||
toast.promise(
|
||||
fetch(`${action}/r2/files`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
keys: selectedFiles.map((file) => file.path),
|
||||
ids: selectedFiles.map((file) => file.id),
|
||||
bucket: bucketInfo.bucket,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
loading: "Deleting files...",
|
||||
success: "Files deleted successfully!",
|
||||
error: "Error deleting files",
|
||||
finally: handleRefresh,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error deleting files:", error);
|
||||
toast.success("Error deleting files");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs value={displayType}>
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<TabsList className="mr-auto">
|
||||
<TabsTrigger value="List" onClick={() => setDisplayType("List")}>
|
||||
<Icons.list className="size-4" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="Grid" onClick={() => setDisplayType("Grid")}>
|
||||
<Icons.layoutGrid className="size-4" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{files && files.totalSize > 0 && plan && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger className="flex items-center gap-2">
|
||||
<CircularStorageIndicator
|
||||
files={files}
|
||||
plan={plan}
|
||||
size={36}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-80">
|
||||
<FileSizeDisplay files={files} plan={plan} t={t} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-[120px] rounded border-r-0 shadow-inner" />
|
||||
) : (
|
||||
<Select
|
||||
value={bucketInfo.bucket}
|
||||
onValueChange={handleChangeBucket}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Select a bucket" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel className="mx-auto text-center">
|
||||
{r2Configs?.provider_name}
|
||||
</SelectLabel>
|
||||
{r2Configs?.buckets?.map((item) => (
|
||||
<SelectItem
|
||||
key={item.bucket}
|
||||
value={item.bucket}
|
||||
onClick={() => handleChangeBucket(item.bucket)}
|
||||
>
|
||||
{item.bucket}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{/* <SelectSeparator /> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{!isAdmin && (
|
||||
<Uploader
|
||||
bucketInfo={bucketInfo}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
plan={plan}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 rounded-r-none border-r-0",
|
||||
showMutiCheckBox
|
||||
? "border-0 bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
||||
: "",
|
||||
)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowMutiCheckBox(!showMutiCheckBox)}
|
||||
>
|
||||
<Icons.listChecks className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"flex h-9 w-8 items-center justify-center gap-1 rounded-r-md border",
|
||||
showMutiCheckBox
|
||||
? "border-neutral-600 bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAllFiles}
|
||||
className="w-full"
|
||||
>
|
||||
<span className="text-xs">{t("Select all")}</span>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleDeleteAllFiles}
|
||||
disabled={isDeleting || selectedFiles.length === 0}
|
||||
>
|
||||
{isDeleting && (
|
||||
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
||||
)}
|
||||
<span className="text-xs">{t("Delete selected")}</span>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-9"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoadingFiles}
|
||||
>
|
||||
{isLoadingFiles ? (
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-8 flex flex-col items-center gap-3">
|
||||
<div className="flex size-20 animate-pulse items-center justify-center rounded-full bg-muted">
|
||||
<Icons.storage className="size-10" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
||||
{t("Loading storage buckets")}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !r2Configs?.buckets?.length && (
|
||||
<EmptyPlaceholder className="col-span-full mt-8 shadow-none">
|
||||
<EmptyPlaceholder.Icon name="storage" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No buckets found")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
{t(
|
||||
"The administrator has not configured the storage bucket, no file can be uploaded",
|
||||
)}
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
|
||||
{!isLoading && r2Configs?.buckets && r2Configs.buckets.length > 0 && (
|
||||
<UserFileList
|
||||
user={user}
|
||||
files={files}
|
||||
isLoading={isLoadingFiles}
|
||||
view={displayType}
|
||||
bucketInfo={bucketInfo}
|
||||
action={action}
|
||||
showMutiCheckBox={showMutiCheckBox}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
setCurrentPage={setCurrentPage}
|
||||
setPageSize={setPageSize}
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
onRefresh={handleRefresh}
|
||||
onSelectAll={handleSelectAllFiles}
|
||||
onDeleteAll={handleDeleteAllFiles}
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react";
|
||||
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
|
||||
export function FileSizeDisplay({ files, plan, t }) {
|
||||
const totalSize = files?.totalSize || 0;
|
||||
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
||||
const usagePercentage =
|
||||
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
|
||||
|
||||
const getStatusColor = (percentage) => {
|
||||
if (percentage >= 90) return "text-red-600";
|
||||
if (percentage >= 70) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage) => {
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 70) return "bg-yellow-500";
|
||||
return "bg-blue-500";
|
||||
};
|
||||
|
||||
const getStatusIcon = (percentage) => {
|
||||
if (percentage >= 90) return <AlertTriangle className="h-4 w-4" />;
|
||||
if (percentage >= 70) return <AlertTriangle className="h-4 w-4" />;
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md p-4">
|
||||
{/* 标题 */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
{t("storageUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{t("used")}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{usagePercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getProgressColor(usagePercentage)}`}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("usedSpace")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatFileSize(totalSize, { precision: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("totalCapacity")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatFileSize(maxSize, { precision: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("availableSpace")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatFileSize(maxSize - totalSize, { precision: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态提示 */}
|
||||
<div
|
||||
className={`mt-3 flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(usagePercentage)}`}
|
||||
>
|
||||
{getStatusIcon(usagePercentage)}
|
||||
<span className="text-xs">
|
||||
{usagePercentage >= 90
|
||||
? t("storageFull")
|
||||
: usagePercentage >= 70
|
||||
? t("storageHigh")
|
||||
: t("storageGood")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircularStorageIndicator({ files, plan, size = 32 }) {
|
||||
const totalSize = files?.totalSize || 0;
|
||||
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
||||
const usagePercentage =
|
||||
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
|
||||
|
||||
// 圆形参数
|
||||
const radius = (size - 6) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset =
|
||||
circumference - (usagePercentage / 100) * circumference;
|
||||
|
||||
// 根据使用率确定颜色
|
||||
const getColor = (percentage) => {
|
||||
if (percentage >= 90) return "#ef4444"; // red-500
|
||||
if (percentage >= 70) return "#f59e0b"; // amber-500
|
||||
return "#3b82f6"; // blue-500
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90 transform">
|
||||
{/* 背景圆圈 */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
/>
|
||||
{/* 进度圆圈 */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={getColor(usagePercentage)}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-300 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="absolute inset-0 flex scale-[.85] items-center justify-center text-xs font-medium"
|
||||
style={{ color: getColor(usagePercentage) }}
|
||||
>
|
||||
{Math.round(usagePercentage)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { BucketInfo } from "@/components/file";
|
||||
|
||||
import { CopyButton } from "../shared/copy-button";
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { UploadPendingItemType, UploadProgressType } from "./uploader";
|
||||
|
||||
const UploadPending = ({
|
||||
pendingUpload,
|
||||
progressList,
|
||||
bucketInfo,
|
||||
onAbort,
|
||||
}: {
|
||||
pendingUpload: UploadPendingItemType[] | null;
|
||||
progressList: UploadProgressType[] | undefined;
|
||||
bucketInfo: BucketInfo;
|
||||
onAbort: (uploadId: string, key: string) => void;
|
||||
}) => {
|
||||
const t = useTranslations("Components");
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg">
|
||||
{progressList && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">{t("Upload List")}</h2>
|
||||
<Badge className="flex items-center gap-1">
|
||||
{progressList.filter((i) => i.progress === 100).length}
|
||||
<span>/</span>
|
||||
{progressList.length}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{pendingUpload && (
|
||||
<p className="rounded-md border border-dashed bg-yellow-100 p-2 text-sm text-muted-foreground dark:bg-neutral-600">
|
||||
Do not close the window until the upload is complete
|
||||
</p>
|
||||
)}
|
||||
{pendingUpload &&
|
||||
pendingUpload.map((item) => {
|
||||
const progress =
|
||||
progressList?.find((p) => p.id === item.uploadId)?.progress || 0;
|
||||
return (
|
||||
<div
|
||||
key={item.uploadId}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg border",
|
||||
item.status === "uploading" &&
|
||||
"border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800",
|
||||
item.status === "completed" &&
|
||||
"border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20",
|
||||
item.status === "aborted" &&
|
||||
"border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20",
|
||||
"backdrop-blur-sm transition-all duration-300",
|
||||
)}
|
||||
>
|
||||
{/* 主进度条背景 */}
|
||||
{item.status === "uploading" && (
|
||||
<div className="absolute inset-0 overflow-hidden rounded-lg">
|
||||
<div
|
||||
className="h-full bg-gray-200 transition-all duration-500 ease-out dark:bg-gray-700"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="relative z-10 px-4 py-3">
|
||||
{/* 头部信息 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-3",
|
||||
item.status === "uploading"
|
||||
? "items-start"
|
||||
: "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{item.fileName}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(item.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 状态指示器 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status === "uploading" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{progress}%</span>
|
||||
<div className="flex items-center gap-1 rounded-full bg-gray-700 px-3 py-1 text-xs text-white dark:bg-gray-600">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-gray-300 dark:bg-gray-400"></div>
|
||||
{t("Uploading")}
|
||||
</div>
|
||||
<Button
|
||||
className="size-6"
|
||||
size={"icon"}
|
||||
variant="destructive"
|
||||
onClick={() => onAbort(item.uploadId, item.key)}
|
||||
>
|
||||
<Icons.close className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : item.status === "completed" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full bg-green-600 px-3 py-1 text-xs text-white dark:bg-green-700">
|
||||
<div className="h-2 w-2 rounded-full bg-green-300 dark:bg-green-400"></div>
|
||||
{t("Completed")}
|
||||
</div>
|
||||
<CopyButton
|
||||
value={
|
||||
bucketInfo.custom_domain
|
||||
? `${bucketInfo.custom_domain}/${item.fileName}`
|
||||
: item.path!
|
||||
}
|
||||
></CopyButton>
|
||||
</div>
|
||||
) : (
|
||||
item.status === "aborted" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
|
||||
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
|
||||
{t("Aborted")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{item.status === "uploading" && (
|
||||
<div className="mt-3">
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
{/* 进度填充 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-gray-800 transition-all duration-300 ease-out dark:bg-gray-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
{/* 动态光泽效果 */}
|
||||
<div
|
||||
className="absolute top-0 h-full w-16 bg-gradient-to-r from-transparent via-white/30 to-transparent transition-all duration-1000 ease-out dark:via-white/20"
|
||||
style={{
|
||||
left: `${Math.max(0, progress - 8)}%`,
|
||||
opacity: progress > 0 && progress < 100 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPending;
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { BucketInfo, StorageUserPlan } from "@/components/file";
|
||||
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "../ui/drawer";
|
||||
import DragAndDrop from "./drag-and-drop";
|
||||
import UploadPending from "./upload-pending";
|
||||
|
||||
export type UploadPendingItemType = {
|
||||
uploadId: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
status: "uploading" | "completed" | "aborted";
|
||||
key: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type UploadProgressType = {
|
||||
id: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export default function Uploader({
|
||||
bucketInfo,
|
||||
action,
|
||||
plan,
|
||||
onRefresh,
|
||||
}: {
|
||||
bucketInfo: BucketInfo;
|
||||
action: string;
|
||||
plan?: StorageUserPlan;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const t = useTranslations("Components");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File[] | null>(null);
|
||||
const [pendingUpload, setPendingUpload] = useState<
|
||||
UploadPendingItemType[] | null
|
||||
>(null);
|
||||
const [progressList, setProgressList] = useState<UploadProgressType[]>();
|
||||
|
||||
// Handles the upload process for selected files
|
||||
const handleUpload = useCallback(() => {
|
||||
selectedFile?.forEach((file: File) => {
|
||||
uploadFile(file);
|
||||
setSelectedFile((prev) => (prev ? prev.filter((f) => f !== file) : null));
|
||||
});
|
||||
}, [selectedFile]);
|
||||
|
||||
// Starts the multipart upload process
|
||||
const startUpload = async (
|
||||
file: File,
|
||||
): Promise<{ uploadId: string; key: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileName", file.name);
|
||||
formData.append("fileType", file.type);
|
||||
formData.append("fileSize", file.size.toString());
|
||||
formData.append("bucket", bucketInfo.bucket);
|
||||
formData.append("prefix", bucketInfo.prefix || "");
|
||||
formData.append("endPoint", "create-multipart-upload");
|
||||
const response = await fetch(`${action}/r2/uploads`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
return data; // { uploadId, key }
|
||||
};
|
||||
|
||||
// Uploads file parts in chunks
|
||||
const uploadParts = async (
|
||||
file: File,
|
||||
uploadId: string,
|
||||
key: string,
|
||||
onProgress: (progress: number) => void,
|
||||
): Promise<{ ETag: string; PartNumber: number }[]> => {
|
||||
const chunkSize = 5 * 1024 * 1024; // 5MB for each chunk
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
const parts: { ETag: string; PartNumber: number }[] = [];
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
|
||||
const partNumber = i + 1;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("chunk", chunk);
|
||||
formData.append("uploadId", uploadId);
|
||||
formData.append("key", key);
|
||||
formData.append("bucket", bucketInfo.bucket);
|
||||
formData.append("partNumber", partNumber.toString());
|
||||
formData.append("endPoint", "upload-part");
|
||||
const uploadResponse = await fetch(`${action}/r2/uploads`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const responseData = await uploadResponse.json();
|
||||
|
||||
if (responseData.error) {
|
||||
throw new Error(responseData.error);
|
||||
}
|
||||
|
||||
if (responseData.etag) {
|
||||
parts.push({ ETag: responseData.etag, PartNumber: partNumber });
|
||||
}
|
||||
|
||||
const progress = Math.round((partNumber / totalChunks) * 100);
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
// Completes the multipart upload process
|
||||
const completeUpload = async (
|
||||
uploadId: string,
|
||||
key: string,
|
||||
parts: { ETag: string; PartNumber: number }[],
|
||||
file: File,
|
||||
): Promise<{ Location: string }> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("key", key);
|
||||
formData.append("uploadId", uploadId);
|
||||
formData.append("bucket", bucketInfo.bucket);
|
||||
formData.append("parts", JSON.stringify(parts));
|
||||
formData.append("fileSize", file.size.toString());
|
||||
formData.append("fileType", file.type);
|
||||
formData.append("fileName", file.name);
|
||||
formData.append("endPoint", "complete-multipart-upload");
|
||||
const response = await fetch(`${action}/r2/uploads`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const abortUpload = async (uploadId: string, key: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("uploadId", uploadId);
|
||||
formData.append("key", key);
|
||||
formData.append("bucket", bucketInfo.bucket);
|
||||
formData.append("endPoint", "abort-multipart-upload");
|
||||
const response = await fetch(`${action}/r2/uploads`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
setPendingUpload(
|
||||
(prev) =>
|
||||
prev?.map((item) =>
|
||||
item.uploadId === uploadId
|
||||
? { ...item, status: "aborted", path: "" }
|
||||
: item,
|
||||
) ?? [],
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File): Promise<void> => {
|
||||
try {
|
||||
if (file.size > Number(plan?.stMaxFileSize || "26214400")) {
|
||||
toast.warning("Upload Failed", {
|
||||
description: `File '${file.name}' size exceeds the maximum allowed size of ${formatFileSize(Number(plan?.stMaxFileSize || "0"))} bytes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { uploadId, key } = await startUpload(file);
|
||||
|
||||
setProgressList((prev) => [
|
||||
...(prev ?? []),
|
||||
{ id: uploadId, progress: 0 },
|
||||
]);
|
||||
|
||||
setPendingUpload((prev) => [
|
||||
...(prev ?? []),
|
||||
{
|
||||
uploadId,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
path: "",
|
||||
status: "uploading",
|
||||
key,
|
||||
},
|
||||
]);
|
||||
const parts = await uploadParts(file, uploadId, key, (progress) => {
|
||||
// console.log(`Upload Progress: ${progress}%`);
|
||||
setProgressList(
|
||||
(prev) =>
|
||||
prev?.map((item) =>
|
||||
item.id === uploadId ? { ...item, progress } : item,
|
||||
) ?? [],
|
||||
);
|
||||
});
|
||||
|
||||
const result = await completeUpload(uploadId, key, parts, file);
|
||||
|
||||
setProgressList(
|
||||
(prev) =>
|
||||
prev?.map((item) =>
|
||||
item.id === uploadId ? { ...item, progress: 100 } : item,
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
setPendingUpload(
|
||||
(prev) =>
|
||||
prev?.map((item) =>
|
||||
item.uploadId === uploadId
|
||||
? { ...item, status: "completed", path: result.Location }
|
||||
: item,
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Triggers the upload process when files are selected
|
||||
useEffect(() => {
|
||||
if (selectedFile?.length) {
|
||||
handleUpload();
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOpen && (
|
||||
<Button
|
||||
className="flex h-9 items-center gap-1 text-nowrap"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Icons.cloudUpload className="size-5" />
|
||||
{t("Upload Files")}
|
||||
</Button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
|
||||
<DrawerContent className="h-screen w-full overflow-y-auto sm:max-w-xl">
|
||||
<DrawerHeader className="flex items-center justify-between">
|
||||
<DrawerTitle className="flex items-center gap-1">
|
||||
{t("Upload Files")}
|
||||
</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost" className="">
|
||||
<Icons.close className="size-4" />
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<DrawerDescription className="flex items-center justify-between px-4">
|
||||
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
<div className="truncate">{bucketInfo.provider_name}</div>
|
||||
<Icons.arrowRight className="size-3" />
|
||||
<div className="font-medium text-blue-600 dark:text-blue-400">
|
||||
{bucketInfo.bucket}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Max:{" "}
|
||||
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
|
||||
precision: 0,
|
||||
})}
|
||||
</p>
|
||||
</DrawerDescription>
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
<DragAndDrop
|
||||
setSelectedFile={setSelectedFile}
|
||||
bucketInfo={bucketInfo}
|
||||
/>
|
||||
<UploadPending
|
||||
pendingUpload={pendingUpload}
|
||||
progressList={progressList}
|
||||
bucketInfo={bucketInfo}
|
||||
onAbort={abortUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="flex flex-row items-center justify-between gap-2">
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">{t("Cancel")}</Button>
|
||||
</DrawerClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setSelectedFile([]);
|
||||
setPendingUpload([]);
|
||||
setProgressList([]);
|
||||
}}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -399,7 +399,7 @@ export function DomainForm({
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
|
||||
{t("API Token")}:
|
||||
{t("API Key")}:
|
||||
</Label>
|
||||
<div className="w-full sm:w-3/5">
|
||||
<Input
|
||||
@@ -422,7 +422,7 @@ export function DomainForm({
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
{t("How to get api token?")}
|
||||
{t("How to get api key?")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { create } from "lodash";
|
||||
import { create, get } from "lodash";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PlanQuotaFormData } from "@/lib/dto/plan";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { createPlanSchema } from "@/lib/validations/plan";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -43,6 +44,12 @@ export function PlanForm({
|
||||
const t = useTranslations("List");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
const [currentStMaxTotalSize, setCurrentStMaxTotalSize] = useState(
|
||||
initData?.stMaxTotalSize || "5242880000",
|
||||
);
|
||||
const [currentStMaxFileSize, setCurrentStMaxFileSize] = useState(
|
||||
initData?.stMaxFileSize || "26214400",
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -64,6 +71,9 @@ export function PlanForm({
|
||||
emEmailAddresses: initData?.emEmailAddresses || 100,
|
||||
emDomains: initData?.emDomains || 1,
|
||||
emSendEmails: initData?.emSendEmails || 100,
|
||||
stMaxFileSize: initData?.stMaxFileSize || "26214400",
|
||||
stMaxTotalSize: initData?.stMaxTotalSize || "524288000",
|
||||
stMaxFileCount: initData?.stMaxFileCount || 1000,
|
||||
appSupport: initData?.appSupport || "BASIC",
|
||||
appApiAccess: initData?.appApiAccess || false,
|
||||
isActive: initData?.isActive || false,
|
||||
@@ -290,7 +300,6 @@ export function PlanForm({
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
disabled
|
||||
{...register("slDomains", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
@@ -398,6 +407,81 @@ export function PlanForm({
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
{t("Storage Service")}
|
||||
</h2>
|
||||
{/* Max File Size - stMaxFileSize */}
|
||||
<FormSectionColumns title={t("Max File Size")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Record-Limit">
|
||||
{t("Max File Size")}
|
||||
</Label>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-file-size"
|
||||
className="shadow-inner"
|
||||
size={32}
|
||||
{...register("stMaxFileSize")}
|
||||
onChange={(e) => setCurrentStMaxFileSize(e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
=
|
||||
{formatFileSize(Number(currentStMaxFileSize), {
|
||||
precision: 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.stMaxFileSize ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.stMaxFileSize.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Maximum uploaded single file size in bytes")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
{/* Max File Size - stMaxTotalSize */}
|
||||
<FormSectionColumns title={t("Max Total Size")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Record-Limit">
|
||||
{t("Max Total Size")}
|
||||
</Label>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-total-size"
|
||||
className="shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("stMaxTotalSize")}
|
||||
onChange={(e) => setCurrentStMaxTotalSize(e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
=
|
||||
{formatFileSize(Number(currentStMaxTotalSize), {
|
||||
precision: 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.stMaxTotalSize ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.stMaxTotalSize.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Maximum uploaded total file size in bytes")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
{type === "edit" && initData?.name !== "free" && (
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface RecordFormProps {
|
||||
type: FormType;
|
||||
initData?: ShortUrlFormData | null;
|
||||
action: string;
|
||||
onRefresh: () => void;
|
||||
onRefresh: (id?: string) => void;
|
||||
}
|
||||
|
||||
export function UrlForm({
|
||||
@@ -141,10 +141,10 @@ export function UrlForm({
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
// const res = await response.json();
|
||||
const res = await response.json();
|
||||
toast.success(`Created successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
onRefresh(res.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export function Notification() {
|
||||
const { data, isLoading, error } = useSWR<Record<string, any>>(
|
||||
"/api/configs?key=system_notification",
|
||||
fetcher,
|
||||
{ dedupingInterval: 30000 },
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
BotMessageSquare,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
CloudUpload,
|
||||
Code,
|
||||
Copy,
|
||||
Crown,
|
||||
Download,
|
||||
@@ -57,6 +60,8 @@ import {
|
||||
Settings,
|
||||
SunMedium,
|
||||
Trash2,
|
||||
Type,
|
||||
Unlink,
|
||||
Unplug,
|
||||
User,
|
||||
UserCog,
|
||||
@@ -75,6 +80,7 @@ export const Icons = {
|
||||
arrowRight: ArrowRight,
|
||||
arrowUpRight: ArrowUpRight,
|
||||
arrowLeft: ArrowLeft,
|
||||
arrowUp: ArrowUp,
|
||||
arrowDown: ArrowDown,
|
||||
chevronLeft: ChevronLeft,
|
||||
chevronRight: ChevronRight,
|
||||
@@ -83,10 +89,54 @@ export const Icons = {
|
||||
check: Check,
|
||||
checkCheck: CheckCheck,
|
||||
close: X,
|
||||
code: Code,
|
||||
copy: Copy,
|
||||
type: Type,
|
||||
camera: Camera,
|
||||
calendar: Calendar,
|
||||
crown: Crown,
|
||||
cloudUpload: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
role="presentation"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#upload_svg__clip0)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.966 7.211a2.91 2.91 0 00-2-.68 4.822 4.822 0 00-9.243-1.147A3.41 3.41 0 001.3 6.18 3.65 3.65 0 000 8.938a3.562 3.562 0 003.554 3.555H6.5v-1H3.547A2.559 2.559 0 011 8.938a2.64 2.64 0 01.943-1.992 2.413 2.413 0 012.032-.527l.435.075.13-.422a3.821 3.821 0 017.47 1.016l.017.57.563-.091a2.071 2.071 0 011.729.404A2.029 2.029 0 0115 9.508a1.987 1.987 0 01-1.985 1.985h-.032c-.061.001-.428.006-2.483.006v1c1.93 0 2.392-.004 2.515-.007a3.01 3.01 0 001.951-5.282v.001z"
|
||||
></path>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10.95 9.456l-2.46-2.5-2.46 2.5.712.701L7.99 8.89v3.62h1v-3.62l1.248 1.268.713-.701z"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="upload_svg__clip0">
|
||||
<path d="M0 0h16v16H0z"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
storage: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
role="presentation"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M12.116 2.57c-.973-.326-2.384-.546-3.991-.546-1.607 0-3.018.22-3.99.545-.492.164-.814.337-.992.478a.89.89 0 00-.082.072c.02.069.078.158.225.268.21.158.548.313 1.025.448.947.266 2.292.409 3.814.409 1.522 0 2.867-.143 3.814-.41.477-.134.816-.29 1.025-.447.147-.11.204-.2.225-.268a.884.884 0 00-.082-.072c-.178-.141-.5-.314-.991-.478zM4.02 4.818a5.18 5.18 0 01-.97-.369v1.757c0 .079.039.206.25.377.214.173.556.347 1.03.5.944.306 2.284.49 3.795.49 1.51 0 2.85-.184 3.794-.49.474-.153.817-.327 1.03-.5.212-.171.251-.298.251-.377V4.45a5.18 5.18 0 01-.97.369c-1.08.304-2.534.45-4.105.45-1.571 0-3.026-.146-4.105-.45zm10.23 1.388V3.05C14.25 1.917 11.508 1 8.125 1S2 1.917 2 3.049V12.95C2 14.083 4.742 15 8.125 15s6.125-.917 6.125-2.049V6.207zM13.2 7.654c-.28.157-.602.29-.95.403-1.083.35-2.543.54-4.125.54-1.582 0-3.042-.19-4.125-.54a5.258 5.258 0 01-.95-.403v1.712c0 .078.039.205.25.376.214.173.556.348 1.03.501.944.306 2.284.489 3.795.489 1.51 0 2.85-.183 3.794-.489.474-.153.817-.328 1.03-.5.212-.172.251-.299.251-.377V7.654zM4 11.215a5.253 5.253 0 01-.95-.403v2.058c.018.019.047.046.093.083.178.141.5.314.992.478.972.325 2.383.545 3.99.545 1.607 0 3.018-.22 3.99-.545.492-.164.814-.337.992-.478a.809.809 0 00.093-.083v-2.058a5.25 5.25 0 01-.95.403c-1.083.351-2.543.541-4.125.541-1.582 0-3.042-.19-4.125-.54zm9.224 1.624s0 .002-.004.006a.028.028 0 01.004-.006zm-10.198 0l.004.006a.024.024 0 01-.004-.006zm1.599-6.29c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.513c-.29 0-.525.23-.525.513 0 .282.235.512.525.512zM5.15 9.28a.519.519 0 01-.525.513.519.519 0 01-.525-.513c0-.282.235-.512.525-.512.29 0 .525.23.525.512zm-.525 3.671c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.512c-.29 0-.525.23-.525.512 0 .283.235.512.525.512z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
eye: Eye,
|
||||
lock: LockKeyhole,
|
||||
list: List,
|
||||
@@ -330,6 +380,7 @@ export const Icons = {
|
||||
</svg>
|
||||
),
|
||||
link: Link,
|
||||
unLink: Unlink,
|
||||
mail: Mail,
|
||||
mailPlus: MailPlus,
|
||||
mailOpen: MailOpen,
|
||||
|
||||
@@ -12,7 +12,6 @@ import Link from "next/link";
|
||||
import { debounce } from "lodash";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from "@/lib/qr";
|
||||
import { WRDO_QR_LOGO } from "@/lib/qr/constants";
|
||||
|
||||
+12
-2
@@ -10,9 +10,13 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
items: [
|
||||
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
|
||||
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
|
||||
{ href: "/emails", icon: "mail", title: "Emails" },
|
||||
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
|
||||
{ href: "/chat", icon: "messages", title: "WRoom" },
|
||||
{ href: "/emails", icon: "mail", title: "Emails" },
|
||||
{
|
||||
href: "/dashboard/storage",
|
||||
icon: "storage",
|
||||
title: "Cloud Storage",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -72,6 +76,12 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "Records",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/storage",
|
||||
icon: "storage",
|
||||
title: "Cloud Storage Manage",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/system",
|
||||
icon: "settings",
|
||||
|
||||
@@ -156,6 +156,11 @@ export const docsConfig: DocsConfig = {
|
||||
href: "/docs/developer/database",
|
||||
icon: "page",
|
||||
},
|
||||
{
|
||||
title: "Cloud Storage",
|
||||
href: "/docs/developer/cloud-storage",
|
||||
icon: "page",
|
||||
},
|
||||
{
|
||||
title: "Telegram Bot",
|
||||
href: "/docs/developer/telegram-bot",
|
||||
|
||||
@@ -18,7 +18,7 @@ The `Short URL Service` and `Email Service` require no additional configuration
|
||||
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
|
||||
|
||||
- Zone ID
|
||||
- API Token
|
||||
- API Key
|
||||
- Email
|
||||
|
||||
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
|
||||
@@ -29,9 +29,9 @@ The unique identifier for a domain hosted on Cloudflare, located at:
|
||||
|
||||
https://dash.cloudflare.com/[account_id]/[zone_name]
|
||||
|
||||
### API Token
|
||||
### API Key
|
||||
|
||||
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
|
||||
Visit https://dash.cloudflare.com/profile/api-tokens, and find the **Global API** Key under the API Tokens section.
|
||||
|
||||
### Email
|
||||
|
||||
@@ -39,7 +39,7 @@ Email for registering a Cloudflare account
|
||||
|
||||
<Callout type="info">
|
||||
You can manage domains hosted under different Cloudflare accounts,
|
||||
provided the API Token and Email are sourced from the same account.
|
||||
provided the API Key and Email are sourced from the same account.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
@@ -72,11 +72,11 @@ NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
|
||||
### CLOUDFLARE_API_KEY
|
||||
|
||||
- Description: The API key used to authenticate requests to the Cloudflare API.
|
||||
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your Global API Key.
|
||||
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your **Global API Key**.
|
||||
- Example: 1234567890abcdef1234567890abcdef
|
||||
- Security Note: Keep this key confidential and never expose it in client-side code.
|
||||
|
||||
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
|
||||
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the **Global API Key** under the API Tokens section.
|
||||
|
||||
### CLOUDFLARE_EMAIL
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ description: 简单介绍 WR.DO 部署所需的环境变量
|
||||
或参考社区优秀部署文档:
|
||||
- https://linux.do/t/topic/711806
|
||||
- https://bravexist.cn/2025/06/wr.do.html
|
||||
- https://b23.tv/fWpMFQu (视频教程)
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -13,6 +13,7 @@ description: How to install the project.
|
||||
Or read unofficial deployment tutorials:
|
||||
- https://linux.do/t/topic/711806
|
||||
- https://bravexist.cn/2025/06/wr.do.html
|
||||
- https://b23.tv/fWpMFQu (Video tutorial)
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: S3 Configs
|
||||
description: How to config the s3 api.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Administrators can manage s3 configurations at `/admin/system`, including adding, deleting, and modifying s3 configurations.
|
||||
|
||||
## Cloudflare R2
|
||||
@@ -34,6 +34,16 @@ WR.DO is a all-in-one web utility platform featuring short links with analytics,
|
||||
- Support enabling application mode (user submission, admin approval)
|
||||
- Support email notification of administrator and user domain application status
|
||||
|
||||
- 💳 **Cloud Storage Service**
|
||||
- Connects to multiple channels (S3 API) cloud storage platforms (Cloudflare R2, AWS S3)
|
||||
- Supports single-channel multi-bucket configuration
|
||||
- Dynamic configuration (user quota settings) for file upload size limits
|
||||
- Supports drag-and-drop, batch, and chunked file uploads
|
||||
- Supports batch file deletion
|
||||
- Quickly generates short links and QR codes for files
|
||||
- Supports online preview of certain file types
|
||||
- Supports file uploads via API calls
|
||||
|
||||
- 📡 **Open API Module**:
|
||||
- Website metadata extraction API
|
||||
- Website screenshot capture API
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { Prisma, UserFile } from "@prisma/client";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
export interface UserFileData extends UserFile {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateUserFileInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
originalName?: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
etag?: string;
|
||||
storageClass?: string;
|
||||
channel: string;
|
||||
platform: string;
|
||||
providerName: string;
|
||||
bucket: string;
|
||||
shortUrlId?: string;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
export interface UpdateUserFileInput {
|
||||
name?: string;
|
||||
originalName?: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
path?: string;
|
||||
etag?: string;
|
||||
storageClass?: string;
|
||||
channel?: string;
|
||||
platform?: string;
|
||||
providerName?: string;
|
||||
bucket?: string;
|
||||
shortUrlId?: string;
|
||||
status?: number;
|
||||
lastModified?: Date;
|
||||
}
|
||||
|
||||
export interface QueryUserFileOptions {
|
||||
bucket?: string;
|
||||
userId?: string;
|
||||
providerName?: string;
|
||||
status?: number;
|
||||
channel?: string;
|
||||
platform?: string;
|
||||
shortUrlId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
orderBy?: "createdAt" | "lastModified" | "size";
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
// 创建文件记录
|
||||
export async function createUserFile(data: CreateUserFileInput) {
|
||||
try {
|
||||
const userFile = await prisma.userFile.create({
|
||||
data: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Failed to create file record:", error);
|
||||
return { success: false, error: "Failed to create file record" };
|
||||
}
|
||||
}
|
||||
|
||||
// 根据ID查询文件记录
|
||||
export async function getUserFileById(id: string) {
|
||||
try {
|
||||
const userFile = await prisma.userFile.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Failed to query file record:", error);
|
||||
return { success: false, error: "Failed to query file record" };
|
||||
}
|
||||
}
|
||||
|
||||
// 条件查询文件记录
|
||||
export async function getUserFiles(options: QueryUserFileOptions = {}) {
|
||||
try {
|
||||
const {
|
||||
bucket,
|
||||
userId,
|
||||
providerName,
|
||||
status,
|
||||
channel,
|
||||
platform,
|
||||
shortUrlId,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
orderBy = "createdAt",
|
||||
order = "desc",
|
||||
} = options;
|
||||
|
||||
const where: Prisma.UserFileWhereInput = {
|
||||
bucket,
|
||||
...(status && { status }),
|
||||
...(userId && { userId }),
|
||||
...(providerName && { providerName }),
|
||||
...(channel && { channel }),
|
||||
...(platform && { platform }),
|
||||
...(shortUrlId && { shortUrlId }),
|
||||
};
|
||||
|
||||
const [files, total, totalSize] = await Promise.all([
|
||||
prisma.userFile.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { [orderBy]: order },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.userFile.count({ where }),
|
||||
prisma.userFile.aggregate({
|
||||
where,
|
||||
_sum: { size: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
totalSize: totalSize._sum.size || 0,
|
||||
list: files,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[GetUserFiles Error]", error);
|
||||
return { success: false, error: "[GetUserFiles Error]" };
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文件记录
|
||||
export async function updateUserFile(id: string, data: UpdateUserFileInput) {
|
||||
try {
|
||||
const userFile = await prisma.userFile.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Failed to update file record:", error);
|
||||
return { success: false, error: "Failed to update file record" };
|
||||
}
|
||||
}
|
||||
|
||||
// 软删除文件记录
|
||||
export async function softDeleteUserFile(id: string) {
|
||||
try {
|
||||
const userFile = await prisma.userFile.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Delete file record failed:", error);
|
||||
return { success: false, error: "Delete file record failed" };
|
||||
}
|
||||
}
|
||||
|
||||
// 批量软删除
|
||||
export async function softDeleteUserFiles(ids: string[]) {
|
||||
try {
|
||||
const result = await prisma.userFile.updateMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
data: {
|
||||
status: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error("Delete file records failed:", error);
|
||||
return { success: false, error: "Delete file records failed" };
|
||||
}
|
||||
}
|
||||
|
||||
// 物理删除文件记录
|
||||
export async function deleteUserFile(id: string) {
|
||||
try {
|
||||
const userFile = await prisma.userFile.delete({
|
||||
where: { id },
|
||||
});
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Delete file record failed:", error);
|
||||
return { success: false, error: "Delete file record failed" };
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户文件统计
|
||||
export async function getUserFileStats(userId: string) {
|
||||
try {
|
||||
const [totalFiles, totalSize, filesByProvider] = await Promise.all([
|
||||
prisma.userFile.count({
|
||||
where: { userId, status: 1 },
|
||||
}),
|
||||
prisma.userFile.aggregate({
|
||||
where: { userId, status: 1 },
|
||||
_sum: { size: true },
|
||||
}),
|
||||
prisma.userFile.groupBy({
|
||||
by: ["providerName"],
|
||||
where: { userId, status: 1 },
|
||||
_count: { id: true },
|
||||
_sum: { size: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalFiles,
|
||||
totalSize: totalSize._sum.size || 0,
|
||||
filesByProvider,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get file statistics:", error);
|
||||
return { success: false, error: "Failed to get file statistics" };
|
||||
}
|
||||
}
|
||||
|
||||
// 根据路径查找文件
|
||||
export async function getUserFileByPath(path: string, providerName?: string) {
|
||||
try {
|
||||
const where: Prisma.UserFileWhereInput = {
|
||||
path,
|
||||
status: 1,
|
||||
...(providerName && { providerName }),
|
||||
};
|
||||
|
||||
const userFile = await prisma.userFile.findFirst({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Failed to query file record:", error);
|
||||
return { success: false, error: "Failed to query file record" };
|
||||
}
|
||||
}
|
||||
|
||||
// 根据短链接ID查询文件
|
||||
export async function getUserFileByShortUrlId(shortUrlId: string) {
|
||||
try {
|
||||
const userFile = await prisma.userFile.findFirst({
|
||||
where: {
|
||||
shortUrlId,
|
||||
status: 1,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { success: true, data: userFile };
|
||||
} catch (error) {
|
||||
console.error("Failed to query file record:", error);
|
||||
return { success: false, error: "Failed to query file record" };
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期文件记录
|
||||
export async function cleanupExpiredFiles(days: number = 30) {
|
||||
try {
|
||||
const expiredDate = new Date();
|
||||
expiredDate.setDate(expiredDate.getDate() - days);
|
||||
|
||||
const result = await prisma.userFile.deleteMany({
|
||||
where: {
|
||||
status: 0,
|
||||
updatedAt: {
|
||||
lt: expiredDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error("Failed to clean up expired files:", error);
|
||||
return { success: false, error: "Failed to clean up expired files" };
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export interface PlanQuota {
|
||||
emEmailAddresses: number;
|
||||
emDomains: number;
|
||||
emSendEmails: number;
|
||||
stMaxFileSize: string;
|
||||
stMaxTotalSize: string;
|
||||
stMaxFileCount: number;
|
||||
appSupport: string;
|
||||
appApiAccess: boolean;
|
||||
isActive: boolean;
|
||||
@@ -42,6 +45,9 @@ export async function getPlanQuota(planName: string) {
|
||||
emEmailAddresses: 0,
|
||||
emDomains: 0,
|
||||
emSendEmails: 0,
|
||||
stMaxFileSize: "26214400",
|
||||
stMaxTotalSize: "524288000",
|
||||
stMaxFileCount: 1000,
|
||||
appSupport: "BASIC",
|
||||
appApiAccess: true,
|
||||
isActive: true,
|
||||
@@ -60,6 +66,9 @@ export async function getPlanQuota(planName: string) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toLowerCase(),
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
@@ -121,6 +130,9 @@ export async function updatePlanQuota(plan: PlanQuotaFormData) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
@@ -144,6 +156,9 @@ export async function createPlan(plan: PlanQuota) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: true,
|
||||
|
||||
@@ -124,6 +124,19 @@ export async function getUserShortUrlCount(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserShortLinksByIds(ids: string[], userId: string) {
|
||||
try {
|
||||
return await prisma.userUrl.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
userId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUrlClicksByIds(
|
||||
ids: string[],
|
||||
userId: string,
|
||||
|
||||
@@ -35,7 +35,8 @@ function parseConfigValue(value: string, type: ConfigType): any {
|
||||
case "OBJECT":
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
case "STRING":
|
||||
|
||||
+4
-1
@@ -200,10 +200,13 @@ export const updateUser = async (userId: string, data: UpdateUserForm) => {
|
||||
|
||||
export const deleteUserById = async (userId: string) => {
|
||||
try {
|
||||
const session = await prisma.user.delete({
|
||||
const session = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
active: 0,
|
||||
},
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export interface CloudStorageCredentials {
|
||||
enabled?: boolean;
|
||||
platform?: string;
|
||||
channel?: string;
|
||||
provider_name?: string;
|
||||
account_id?: string;
|
||||
access_key_id?: string;
|
||||
secret_access_key?: string;
|
||||
endpoint?: string;
|
||||
buckets: BucketItem[];
|
||||
}
|
||||
|
||||
export interface ClientStorageCredentials {
|
||||
enabled?: boolean;
|
||||
platform?: string;
|
||||
channel?: string;
|
||||
provider_name?: string;
|
||||
buckets: BucketItem[];
|
||||
}
|
||||
|
||||
export interface BucketItem {
|
||||
bucket: string;
|
||||
custom_domain?: string;
|
||||
prefix?: string;
|
||||
file_types?: string;
|
||||
file_size?: string;
|
||||
region?: string;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
export interface FileObject {
|
||||
Key?: string;
|
||||
LastModified?: Date;
|
||||
ETag?: string;
|
||||
Size?: number;
|
||||
StorageClass?: string;
|
||||
}
|
||||
|
||||
export function createS3Client(
|
||||
endpoint: string,
|
||||
accessKeyId: string,
|
||||
secretAccessKey: string,
|
||||
region: string = "auto",
|
||||
) {
|
||||
return new S3Client({
|
||||
region: region,
|
||||
endpoint: endpoint,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: Buffer,
|
||||
key: string,
|
||||
s3: S3Client,
|
||||
bucket: string,
|
||||
) {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: file,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await s3.send(command);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSignedUrlForUpload(
|
||||
key: string,
|
||||
contentType: string,
|
||||
s3: S3Client,
|
||||
bucket: string,
|
||||
): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
try {
|
||||
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
|
||||
return signedUrl;
|
||||
} catch (error) {
|
||||
console.error("Error generating signed URL:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSignedUrlForDownload(
|
||||
key: string,
|
||||
s3: S3Client,
|
||||
bucket: string,
|
||||
): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
try {
|
||||
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
|
||||
return signedUrl;
|
||||
} catch (error) {
|
||||
console.error("Error generating signed URL:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFiles(
|
||||
prefix: string = "",
|
||||
s3: S3Client,
|
||||
bucket: string,
|
||||
): Promise<FileObject[]> {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await s3.send(command);
|
||||
return response.Contents || [];
|
||||
} catch (error) {
|
||||
console.error("Error listing files:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileInfo(R2: S3Client, bucket: string, key: string) {
|
||||
try {
|
||||
const headCommand = new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const headResponse = await R2.send(headCommand);
|
||||
|
||||
return {
|
||||
size: headResponse.ContentLength || 0,
|
||||
etag: headResponse.ETag || "",
|
||||
lastModified: headResponse.LastModified || new Date(),
|
||||
contentType: headResponse.ContentType || "",
|
||||
storageClass: headResponse.StorageClass || "",
|
||||
metadata: headResponse.Metadata || {},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting file info:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFile(key: string, s3: S3Client, bucket: string) {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await s3.send(command);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -67,7 +67,7 @@ export async function restrictByTimeRange({
|
||||
if (count >= limit) {
|
||||
return {
|
||||
status: 409,
|
||||
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} creation limit (${limit}). Please try again later.`,
|
||||
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} usage limit (${limit}). Please try again later.`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
+179
-5
@@ -189,6 +189,54 @@ export const truncate = (str: string, length: number) => {
|
||||
return `${str.slice(0, length)}...`;
|
||||
};
|
||||
|
||||
export const truncateMiddle = (
|
||||
text: string,
|
||||
maxLength: number = 20,
|
||||
): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
|
||||
// 找到最后一个点的位置(文件扩展名)
|
||||
const lastDotIndex = text.lastIndexOf(".");
|
||||
|
||||
if (lastDotIndex === -1 || lastDotIndex === 0) {
|
||||
// 没有扩展名,直接中间截断
|
||||
const half = Math.floor((maxLength - 3) / 2);
|
||||
return text.slice(0, half) + "..." + text.slice(-half);
|
||||
}
|
||||
|
||||
const extension = text.slice(lastDotIndex);
|
||||
const nameWithoutExt = text.slice(0, lastDotIndex);
|
||||
|
||||
// 如果扩展名太长,直接截断整个文件名
|
||||
if (extension.length > maxLength / 2) {
|
||||
const half = Math.floor((maxLength - 3) / 2);
|
||||
return text.slice(0, half) + "..." + text.slice(-half);
|
||||
}
|
||||
|
||||
// 计算可用于文件名的长度
|
||||
const availableLength = maxLength - extension.length - 3;
|
||||
|
||||
if (availableLength <= 0) {
|
||||
return "..." + extension;
|
||||
}
|
||||
|
||||
// 如果文件名部分不需要截断
|
||||
if (nameWithoutExt.length <= availableLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 中间截断文件名部分
|
||||
const startLength = Math.ceil(availableLength / 2);
|
||||
const endLength = Math.floor(availableLength / 2);
|
||||
|
||||
return (
|
||||
nameWithoutExt.slice(0, startLength) +
|
||||
"..." +
|
||||
nameWithoutExt.slice(-endLength) +
|
||||
extension
|
||||
);
|
||||
};
|
||||
|
||||
export const getBlurDataURL = async (url: string | null) => {
|
||||
if (!url) {
|
||||
return "data:image/webp;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
@@ -274,12 +322,40 @@ export function htmlToText(html: string): string {
|
||||
return doc.body.textContent || "";
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
export function formatFileSize(
|
||||
bytes: number,
|
||||
options: {
|
||||
precision?: number;
|
||||
binary?: boolean; // true for 1024, false for 1000
|
||||
longNames?: boolean; // true for "bytes", false for "B"
|
||||
} = {},
|
||||
): string {
|
||||
const { precision = 1, binary = true, longNames = false } = options;
|
||||
|
||||
// 输入验证
|
||||
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
|
||||
return longNames ? "0 bytes" : "0 B";
|
||||
}
|
||||
|
||||
if (bytes === 0) {
|
||||
return longNames ? "0 bytes" : "0 B";
|
||||
}
|
||||
|
||||
const k = binary ? 1024 : 1000;
|
||||
const sizes = longNames
|
||||
? ["bytes", "KB", "MB", "GB", "TB", "PB"]
|
||||
: ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
const sizeIndex = Math.min(i, sizes.length - 1);
|
||||
const size = bytes / Math.pow(k, sizeIndex);
|
||||
|
||||
// 特殊处理 bytes 单位的复数形式
|
||||
if (longNames && sizeIndex === 0) {
|
||||
return bytes === 1 ? "1 byte" : `${bytes} bytes`;
|
||||
}
|
||||
|
||||
return `${size.toFixed(precision)} ${sizes[sizeIndex]}`;
|
||||
}
|
||||
|
||||
export function downloadFile(url: string, filename: string): Promise<void> {
|
||||
@@ -397,3 +473,101 @@ export function verifyPassword(
|
||||
const hashToVerify = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
return hash === hashToVerify;
|
||||
}
|
||||
|
||||
export const formatFileSizeX = (bytes: number) => {
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed() + " KB";
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(2) + " MB";
|
||||
return (bytes / 1073741824).toFixed(2) + " GB";
|
||||
};
|
||||
|
||||
export function extractFileName(filePath: string): string {
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 移除开头的斜杠
|
||||
let normalizedPath = filePath.trim();
|
||||
while (normalizedPath.startsWith("/")) {
|
||||
normalizedPath = normalizedPath.substring(1);
|
||||
}
|
||||
|
||||
// 移除结尾的斜杠
|
||||
while (normalizedPath.endsWith("/")) {
|
||||
normalizedPath = normalizedPath.substring(0, normalizedPath.length - 1);
|
||||
}
|
||||
|
||||
// 如果路径为空,返回空字符串
|
||||
if (!normalizedPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 提取文件名
|
||||
const lastSlashIndex = normalizedPath.lastIndexOf("/");
|
||||
return lastSlashIndex === -1
|
||||
? normalizedPath
|
||||
: normalizedPath.substring(lastSlashIndex + 1);
|
||||
}
|
||||
|
||||
// 提取文件扩展名
|
||||
export function extractFileExtension(filePath: string): string {
|
||||
const fileName = extractFileName(filePath);
|
||||
|
||||
if (!fileName) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
|
||||
// 如果没有找到点,或者点在开头(隐藏文件),返回空字符串
|
||||
if (lastDotIndex === -1 || lastDotIndex === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fileName.substring(lastDotIndex + 1);
|
||||
}
|
||||
|
||||
// 同时提取文件名和扩展名的组合函数
|
||||
export function extractFileNameAndExtension(filePath: string): {
|
||||
fileName: string;
|
||||
extension: string;
|
||||
nameWithoutExtension: string;
|
||||
} {
|
||||
const fileName = extractFileName(filePath);
|
||||
|
||||
if (!fileName) {
|
||||
return {
|
||||
fileName: "",
|
||||
extension: "",
|
||||
nameWithoutExtension: "",
|
||||
};
|
||||
}
|
||||
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
|
||||
if (lastDotIndex === -1 || lastDotIndex === 0) {
|
||||
return {
|
||||
fileName: fileName,
|
||||
extension: "",
|
||||
nameWithoutExtension: fileName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileName: fileName,
|
||||
extension: fileName.substring(lastDotIndex + 1),
|
||||
nameWithoutExtension: fileName.substring(0, lastDotIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateFileKey(fileName: string, prefix?: string): string {
|
||||
if (prefix) {
|
||||
return `${prefix}/${fileName}`;
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}/${month}/${day}/${fileName}`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export const createPlanSchema = z.object({
|
||||
emEmailAddresses: z.number().optional().default(0),
|
||||
emDomains: z.number().optional().default(0),
|
||||
emSendEmails: z.number().optional().default(0),
|
||||
stMaxFileSize: z.string().optional().default("26214400"),
|
||||
stMaxTotalSize: z.string().optional().default("524288000"),
|
||||
stMaxFileCount: z.number().optional().default(1000),
|
||||
appSupport: z.string().optional().default("BASIC"),
|
||||
appApiAccess: z.boolean().optional().default(true),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
|
||||
+68
-5
@@ -130,7 +130,7 @@
|
||||
"API Token": "API Token",
|
||||
"Account Email": "Account Email",
|
||||
"How to get zone id?": "How to get zone id?",
|
||||
"How to get api token?": "How to get api token?",
|
||||
"How to get api key?": "How to get api key?",
|
||||
"How to get cloudflare account email?": "How to get cloudflare account email?",
|
||||
"Resend Configs": "Resend Configs",
|
||||
"Associate with 'Subdomain Service' status": "Associate with 'Subdomain Service' status",
|
||||
@@ -185,7 +185,35 @@
|
||||
"Duplicate": "Duplicate",
|
||||
"Confirm duplicate domain": "Confirm duplicate domain",
|
||||
"This will duplicate all configuration information for the {domain} domain, and create a new domain": "This will duplicate all configuration information for the {domain} domain, and create a new domain",
|
||||
"Add User": "Add User"
|
||||
"Add User": "Add User",
|
||||
"Loading storage buckets": "Loading storage buckets",
|
||||
"No buckets found": "No buckets found",
|
||||
"The administrator has not configured the storage bucket, no file can be uploaded": "The administrator has not configured the storage bucket, no file can be uploaded",
|
||||
"No Files": "No Files",
|
||||
"You don't upload any files yet": "You don't upload any files yet",
|
||||
"Size": "Size",
|
||||
"Date": "Date",
|
||||
"Download": "Download",
|
||||
"Generate short link": "Shorten link",
|
||||
"Delete File": "Delete",
|
||||
"Select all": "Select all",
|
||||
"Delete selected": "Delete selected",
|
||||
"Update short link": "Update short link",
|
||||
"QR Code": "QR Code",
|
||||
"Raw Data": "Raw Data",
|
||||
"Storage Service": "Storage Service",
|
||||
"Max File Size": "Max File Size",
|
||||
"Maximum uploaded single file size in bytes": "Maximum uploaded single file size in bytes",
|
||||
"Max Total Size": "Max Total Size",
|
||||
"Maximum uploaded total file size in bytes": "Maximum uploaded total file size in bytes",
|
||||
"storageUsage": "Storage Usage",
|
||||
"used": "Used",
|
||||
"usedSpace": "Used Space",
|
||||
"totalCapacity": "Total Capacity",
|
||||
"availableSpace": "Available Space",
|
||||
"storageFull": "Storage space is almost full",
|
||||
"storageHigh": "Storage space usage is high",
|
||||
"storageGood": "Storage space is sufficient"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "Dashboard",
|
||||
@@ -324,7 +352,21 @@
|
||||
"Actived": "Active",
|
||||
"Disabled": "Disabled",
|
||||
"Expired": "Expired",
|
||||
"PasswordProtected": "Password Protected"
|
||||
"PasswordProtected": "Password Protected",
|
||||
"Upload List": "Upload List",
|
||||
"Uploading": "Uploading",
|
||||
"Completed": "Completed",
|
||||
"Aborted": "Aborted",
|
||||
"Drop files to upload them to": "Drop files to upload them to",
|
||||
"Drag and drop file(s) here": "Drag and drop file(s) here",
|
||||
"or": "or",
|
||||
"Browse file(s)": "Browse file(s)",
|
||||
"Cloud Storage": "Cloud Storage",
|
||||
"List and manage cloud storage": "List and manage cloud storage",
|
||||
"Cancel": "Cancel",
|
||||
"Clear": "Clear",
|
||||
"Upload Files": "Upload Files",
|
||||
"Uploud channel": "Uploud channel"
|
||||
},
|
||||
"Landing": {
|
||||
"settings": "Settings",
|
||||
@@ -424,7 +466,9 @@
|
||||
"Admin": "Admin",
|
||||
"Sign in": "Sign in",
|
||||
"Log out": "Log out",
|
||||
"System Settings": "System Settings"
|
||||
"System Settings": "System Settings",
|
||||
"Cloud Storage": "Cloud Storage",
|
||||
"Cloud Storage Manager": "Cloud Storage"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "Search emails",
|
||||
@@ -554,6 +598,25 @@
|
||||
"Email Suffix White List": "Email Suffix White List",
|
||||
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com": "Set email suffix white list, split by comma, such as: gmail.com,yahoo.com,hotmail.com",
|
||||
"Application Status Email Notifications": "Application Status Email Notifications",
|
||||
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled"
|
||||
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled",
|
||||
"Cloud Storage Configs": "Cloud Storage Configs",
|
||||
"Verified": "Verified",
|
||||
"Verify Configuration": "Verify Configuration",
|
||||
"Clear": "Clear",
|
||||
"How to get the R2 credentials?": "How to get the R2 credentials?",
|
||||
"Endpoint": "Endpoint",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Enabled": "Enabled",
|
||||
"Bucket": "Bucket",
|
||||
"Bucket Name": "Bucket Name",
|
||||
"Public Domain": "Public Domain",
|
||||
"Max File Size": "Max File Size",
|
||||
"Region": "Region",
|
||||
"Prefix": "Prefix",
|
||||
"Optional": "Optional",
|
||||
"Allowed File Types": "Allowed File Types",
|
||||
"Public": "Public",
|
||||
"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": "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"
|
||||
}
|
||||
}
|
||||
|
||||
+69
-6
@@ -89,7 +89,7 @@
|
||||
"The record is currently pending for admin approval": "正在等待管理员审核",
|
||||
"The target is currently inaccessible": "目标链接目前无法访问",
|
||||
"Please check the target and try again": "请检查解析记录并重试",
|
||||
"If the target is not activated within 3 days": "如果目标链接在 3 天内依然无法访问",
|
||||
"If the target is not activated within 3 days": "如果目标链接在 3 天后依然无法访问",
|
||||
"the administrator will": "管理员将",
|
||||
"delete this record": "删除此记录",
|
||||
"Review": "审核",
|
||||
@@ -130,7 +130,7 @@
|
||||
"API Token": "API Token",
|
||||
"Account Email": "账户邮箱",
|
||||
"How to get zone id?": "如何获取 Zone ID?",
|
||||
"How to get api token?": "如何获取 API Token?",
|
||||
"How to get api key?": "如何获取 API 密钥?",
|
||||
"How to get cloudflare account email?": "如何获取账户邮箱?",
|
||||
"Resend Configs": "Resend 配置",
|
||||
"Associate with 'Subdomain Service' status": "与 '子域名服务' 启用状态关联",
|
||||
@@ -185,7 +185,35 @@
|
||||
"Duplicate": "复制",
|
||||
"Confirm duplicate domain": "确认复制域名",
|
||||
"This will duplicate all configuration information for the {domain} domain, and create a new domain": "这将复制 {domain} 域名的所有配置信息,并创建一个新域名",
|
||||
"Add User": "添加用户"
|
||||
"Add User": "添加用户",
|
||||
"Loading storage buckets": "正在加载存储桶",
|
||||
"No buckets found": "未配置存储桶",
|
||||
"The administrator has not configured the storage bucket, no file can be uploaded": "管理员未配置存储桶,无法上传文件",
|
||||
"No Files": "暂无文件",
|
||||
"You don't upload any files yet": "您还没有上传任何文件",
|
||||
"Size": "大小",
|
||||
"Date": "日期",
|
||||
"Download": "下载文件",
|
||||
"Generate short link": "生成短链",
|
||||
"Delete File": "删除文件",
|
||||
"Select all": "全部选中",
|
||||
"Delete selected": "删除选中",
|
||||
"Update short link": "更新短链",
|
||||
"QR Code": "二维码",
|
||||
"Raw Data": "在线预览",
|
||||
"Storage Service": "存储服务",
|
||||
"Max File Size": "单文件大小上限",
|
||||
"Maximum uploaded single file size in bytes": "单个文件最大上传大小(字节)",
|
||||
"Max Total Size": "总文件大小上限",
|
||||
"Maximum uploaded total file size in bytes": "所有文件最大上传总大小(字节)",
|
||||
"storageUsage": "存储空间使用情况",
|
||||
"used": "已使用",
|
||||
"usedSpace": "已使用空间",
|
||||
"totalCapacity": "总容量",
|
||||
"availableSpace": "剩余空间",
|
||||
"storageFull": "存储空间即将用完",
|
||||
"storageHigh": "存储空间使用较多",
|
||||
"storageGood": "存储空间充足"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "用户面板",
|
||||
@@ -324,7 +352,21 @@
|
||||
"Actived": "有效",
|
||||
"Disabled": "已禁用",
|
||||
"Expired": "已过期",
|
||||
"PasswordProtected": "密码保护"
|
||||
"PasswordProtected": "密码保护",
|
||||
"Upload List": "上传列表",
|
||||
"Uploading": "上传中",
|
||||
"Completed": "已完成",
|
||||
"Aborted": "已中止",
|
||||
"Drop files to upload them to": "将文件上传到",
|
||||
"Drag and drop file(s) here": "将文件拖到此处上传",
|
||||
"or": "或",
|
||||
"Browse file(s)": "浏览本地文件",
|
||||
"Cloud Storage": "云存储",
|
||||
"List and manage cloud storage": "上传和管理云存储文件",
|
||||
"Cancel": "取消",
|
||||
"Clear": "清空",
|
||||
"Upload Files": "上传文件",
|
||||
"Uploud channel": "渠道"
|
||||
},
|
||||
"Landing": {
|
||||
"settings": "设置",
|
||||
@@ -424,7 +466,9 @@
|
||||
"Admin": "管理面板",
|
||||
"Sign in": "登录",
|
||||
"Log out": "退出登录",
|
||||
"System Settings": "系统设置"
|
||||
"System Settings": "系统设置",
|
||||
"Cloud Storage": "云存储",
|
||||
"Cloud Storage Manage": "云存储管理"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "搜索邮箱...",
|
||||
@@ -554,6 +598,25 @@
|
||||
"Email Suffix White List": "白名单",
|
||||
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com": "设置邮箱后缀白名单,多个后缀请用逗号分隔,例如:gmail.com,yahoo.com,hotmail.com",
|
||||
"Application Status Email Notifications": "申请状态邮件通知",
|
||||
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "开启后,用户申请子域名时将邮件通知管理员审核,审核完成后邮件通知用户结果。此功能仅在子域申请模式开启时有效"
|
||||
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "开启后,用户申请子域名时将邮件通知管理员审核,审核完成后邮件通知用户结果。此功能仅在子域申请模式开启时有效",
|
||||
"Cloud Storage Configs": "云存储配置",
|
||||
"Verified": "已就绪",
|
||||
"Verify Configuration": "验证配置",
|
||||
"Clear": "清空",
|
||||
"How to get the R2 credentials?": "如何获取 R2 授权配置?",
|
||||
"Endpoint": "S3 端点",
|
||||
"Access Key ID": "访问密钥 ID",
|
||||
"Secret Access Key": "机密访问密钥",
|
||||
"Enabled": "启用",
|
||||
"Bucket": "存储桶",
|
||||
"Bucket Name": "存储桶名称",
|
||||
"Public Domain": "公开域名或自定义域名",
|
||||
"Max File Size": "上传文件大小限制",
|
||||
"Region": "存储桶区域",
|
||||
"Prefix": "前缀",
|
||||
"Optional": "可选",
|
||||
"Allowed File Types": "允许的文件类型",
|
||||
"Public": "公开",
|
||||
"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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
siteUrl: "https://wr.do",
|
||||
generateRobotsTxt: true, // (optional)
|
||||
sitemapSize: 7000, // Number of URLs per sitemap file
|
||||
};
|
||||
@@ -122,5 +122,4 @@ const withPWA = nextPWA({
|
||||
disable: false,
|
||||
});
|
||||
|
||||
// module.exports = withContentlayer(withPWA(withNextIntl(nextConfig)));
|
||||
export default withContentlayer(withPWA(withNextIntl(nextConfig)));
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
@@ -8,7 +8,6 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"postbuild": "next-sitemap",
|
||||
"turbo": "next dev --turbo",
|
||||
"start": "next start",
|
||||
"start-docker": "npm-run-all check-db start-server",
|
||||
@@ -26,6 +25,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.4.1",
|
||||
"@aws-sdk/client-s3": "^3.840.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.840.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@prisma/client": "^5.17.0",
|
||||
@@ -100,7 +101,6 @@
|
||||
"next-contentlayer2": "^0.5.0",
|
||||
"next-intl": "^4.1.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-themes": "^0.3.0",
|
||||
"next-view-transitions": "^0.3.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
@@ -113,6 +113,7 @@
|
||||
"react-countup": "^6.5.3",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-email": "2.1.5",
|
||||
"react-globe.gl": "^2.33.2",
|
||||
"react-hook-form": "^7.52.1",
|
||||
|
||||
Generated
+1259
-34
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
-- CF R2 配置
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
's3_config_01',
|
||||
'{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://<account_id>.r2.cloudflarestorage.com","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"auto","public":true}]}',
|
||||
'OBJECT',
|
||||
'R2 存储桶配置'
|
||||
);
|
||||
|
||||
-- AWS S3 配置
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
's3_config_02',
|
||||
'{"enabled":true,"platform":"aws","channel":"s3","provider_name":"Amazon S3","endpoint":"https://s3.<region>.amazonaws.com","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"us-east-1","public":true}]}',
|
||||
'OBJECT',
|
||||
'Amazon S3 存储桶配置'
|
||||
);
|
||||
|
||||
-- 阿里云 OSS 配置
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
's3_config_03',
|
||||
'{"enabled":true,"platform":"ali","channel":"oss","provider_name":"阿里云 OSS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}',
|
||||
'OBJECT',
|
||||
'阿里云 OSS 存储桶配置'
|
||||
);
|
||||
|
||||
-- 腾讯云 COS 配置
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
's3_config_04',
|
||||
'{"enabled":true,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}',
|
||||
'OBJECT',
|
||||
'腾讯云 COS 存储桶配置'
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "user_files"
|
||||
(
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"originalName" TEXT,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"etag" TEXT,
|
||||
"storageClass" TEXT,
|
||||
"channel" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL,
|
||||
"providerName" TEXT NOT NULL,
|
||||
"bucket" TEXT NOT NULL,
|
||||
"shortUrlId" TEXT,
|
||||
"status" INTEGER NOT NULL DEFAULT 1,
|
||||
"lastModified" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_files_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "user_files_userId_providerName_status_lastModified_createdAt_idx"
|
||||
ON "user_files"("userId", "providerName", "status", "lastModified", "createdAt");
|
||||
|
||||
ALTER TABLE "user_files" ADD CONSTRAINT "user_files_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- BigInt
|
||||
ALTER TABLE "plans" ADD COLUMN "stMaxFileSize" TEXT NOT NULL DEFAULT '26214400';
|
||||
ALTER TABLE "plans" ADD COLUMN "stMaxTotalSize" TEXT NOT NULL DEFAULT '524288000';
|
||||
ALTER TABLE "plans" ADD COLUMN "stMaxFileCount" INTEGER NOT NULL DEFAULT 1000;
|
||||
|
||||
@@ -70,6 +70,7 @@ model User {
|
||||
ScrapeMeta ScrapeMeta[]
|
||||
UserEmail UserEmail[]
|
||||
UserSendEmail UserSendEmail[]
|
||||
UserFile UserFile[]
|
||||
|
||||
@@index([createdAt])
|
||||
@@map(name: "users")
|
||||
@@ -312,6 +313,11 @@ model Plan {
|
||||
emDomains Int
|
||||
emSendEmails Int
|
||||
|
||||
// Storage (ST) related settings
|
||||
stMaxFileSize String @default("26214400")
|
||||
stMaxTotalSize String @default("524288000")
|
||||
stMaxFileCount Int @default(1000)
|
||||
|
||||
// App (APP) related settings
|
||||
appSupport String // "BASIC", "LIVE"
|
||||
appApiAccess Boolean
|
||||
@@ -323,3 +329,33 @@ model Plan {
|
||||
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
model UserFile {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
shortUrlId String?
|
||||
|
||||
name String
|
||||
originalName String?
|
||||
mimeType String
|
||||
size Int
|
||||
path String
|
||||
etag String?
|
||||
storageClass String?
|
||||
|
||||
channel String
|
||||
platform String
|
||||
providerName String
|
||||
bucket String
|
||||
|
||||
status Int @default(1) // 0 删除, 1 正常, 2 禁用
|
||||
|
||||
lastModified DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, providerName, status, lastModified, createdAt])
|
||||
@@map(name: "user_files")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "1.0.8",
|
||||
"versionName": "1.1.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# *
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Host
|
||||
Host: https://wr.do
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://wr.do/sitemap.xml
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "1.0.8",
|
||||
"versionName": "1.1.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>https://wr.do/sitemap-0.xml</loc></sitemap>
|
||||
</sitemapindex>
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user