feat(web): add file upload UI to create-share modal

Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-21 14:45:38 +00:00
parent 71e99b1ed2
commit fba40cf510
4 changed files with 262 additions and 23 deletions

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
// Get the multipart form data directly
const formData = await req.formData();
const url = `${API_BASE_URL}/shares/create-with-files`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
cookie: cookieHeader || "",
// Don't set Content-Type, let fetch set it with boundary
},
body: formData,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { IconCalendar, IconEye, IconLock, IconShare } from "@tabler/icons-react"; import { IconCalendar, IconEye, IconLock, IconShare, IconUpload, IconX } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileTree, TreeFile, TreeFolder } from "@/components/tables/files-tree"; import { FileTree, TreeFile, TreeFolder } from "@/components/tables/files-tree";
@@ -13,7 +14,8 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { createShare } from "@/http/endpoints"; import { createShare, createShareWithFiles } from "@/http/endpoints";
import { formatFileSize } from "@/utils/format-file-size";
interface CreateShareModalProps { interface CreateShareModalProps {
isOpen: boolean; isOpen: boolean;
@@ -39,6 +41,7 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
const [files, setFiles] = useState<TreeFile[]>([]); const [files, setFiles] = useState<TreeFile[]>([]);
const [folders, setFolders] = useState<TreeFolder[]>([]); const [folders, setFolders] = useState<TreeFolder[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [newFiles, setNewFiles] = useState<File[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(false); const [isLoadingData, setIsLoadingData] = useState(false);
@@ -85,18 +88,35 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
maxViews: "", maxViews: "",
}); });
setSelectedItems([]); setSelectedItems([]);
setNewFiles([]);
setCurrentTab("details"); setCurrentTab("details");
} }
}, [isOpen, loadData]); }, [isOpen, loadData]);
const onDrop = useCallback((acceptedFiles: File[]) => {
setNewFiles((prev) => [...prev, ...acceptedFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
});
const removeNewFile = (index: number) => {
setNewFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formData.name.trim()) { if (!formData.name.trim()) {
toast.error("Share name is required"); toast.error("Share name is required");
return; return;
} }
if (selectedItems.length === 0) { const hasExistingItems = selectedItems.length > 0;
toast.error("Please select at least one file or folder"); const hasNewFiles = newFiles.length > 0;
if (!hasExistingItems && !hasNewFiles) {
toast.error("Please select at least one file/folder or upload new files");
return; return;
} }
@@ -106,11 +126,7 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
const selectedFiles = selectedItems.filter((id) => files.some((file) => file.id === id)); const selectedFiles = selectedItems.filter((id) => files.some((file) => file.id === id));
const selectedFolders = selectedItems.filter((id) => folders.some((folder) => folder.id === id)); const selectedFolders = selectedItems.filter((id) => folders.some((folder) => folder.id === id));
await createShare({ const expiration = formData.expiresAt
name: formData.name,
description: formData.description || undefined,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: formData.expiresAt
? (() => { ? (() => {
const dateValue = formData.expiresAt; const dateValue = formData.expiresAt;
if (dateValue.length === 10) { if (dateValue.length === 10) {
@@ -118,11 +134,32 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
} }
return new Date(dateValue).toISOString(); return new Date(dateValue).toISOString();
})() })()
: undefined, : undefined;
// Use the new endpoint if there are new files to upload
if (hasNewFiles) {
await createShareWithFiles({
name: formData.name,
description: formData.description || undefined,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: expiration,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
existingFiles: selectedFiles.length > 0 ? selectedFiles : undefined,
existingFolders: selectedFolders.length > 0 ? selectedFolders : undefined,
newFiles: newFiles,
});
} else {
// Use the traditional endpoint if only selecting existing files
await createShare({
name: formData.name,
description: formData.description || undefined,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: expiration,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined, maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
files: selectedFiles, files: selectedFiles,
folders: selectedFolders, folders: selectedFolders,
}); });
}
toast.success(t("createShare.success")); toast.success(t("createShare.success"));
onSuccess(); onSuccess();
@@ -146,8 +183,10 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
}; };
const selectedCount = selectedItems.length; const selectedCount = selectedItems.length;
const newFilesCount = newFiles.length;
const totalCount = selectedCount + newFilesCount;
const canProceedToFiles = formData.name.trim().length > 0; const canProceedToFiles = formData.name.trim().length > 0;
const canSubmit = formData.name.trim().length > 0 && selectedCount > 0; const canSubmit = formData.name.trim().length > 0 && totalCount > 0;
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
@@ -161,7 +200,7 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
<div className="flex flex-col gap-6 flex-1 min-h-0 w-full overflow-hidden"> <div className="flex flex-col gap-6 flex-1 min-h-0 w-full overflow-hidden">
<Tabs value={currentTab} onValueChange={setCurrentTab} className="flex-1"> <Tabs value={currentTab} onValueChange={setCurrentTab} className="flex-1">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="details">{t("createShare.tabs.shareDetails")}</TabsTrigger> <TabsTrigger value="details">{t("createShare.tabs.shareDetails")}</TabsTrigger>
<TabsTrigger value="files" disabled={!canProceedToFiles}> <TabsTrigger value="files" disabled={!canProceedToFiles}>
{t("createShare.tabs.selectFiles")} {t("createShare.tabs.selectFiles")}
@@ -171,6 +210,14 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
</span> </span>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="upload" disabled={!canProceedToFiles}>
{t("createShare.tabs.uploadFiles") || "Upload Files"}
{newFilesCount > 0 && (
<span className="ml-1 text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5">
{newFilesCount}
</span>
)}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="details" className="space-y-4 mt-4"> <TabsContent value="details" className="space-y-4 mt-4">
@@ -320,6 +367,87 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
<Button variant="outline" onClick={() => setCurrentTab("details")}> <Button variant="outline" onClick={() => setCurrentTab("details")}>
{t("common.back")} {t("common.back")}
</Button> </Button>
<div className="space-x-2">
<Button variant="outline" onClick={() => setCurrentTab("upload")}>
{t("createShare.nextUploadFiles") || "Upload New Files"}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit || isLoading}>
{isLoading ? t("common.creating") : t("createShare.create")}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="upload" className="space-y-4 mt-4 flex-1 min-h-0">
<div className="space-y-4">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"
}`}
>
<input {...getInputProps()} />
<IconUpload className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
{isDragActive ? (
<p className="text-sm text-muted-foreground">Drop files here...</p>
) : (
<div>
<p className="text-sm font-medium mb-1">
{t("createShare.upload.dragDrop") || "Drag & drop files here"}
</p>
<p className="text-xs text-muted-foreground">
{t("createShare.upload.orClick") || "or click to browse"}
</p>
</div>
)}
</div>
{newFiles.length > 0 && (
<div className="space-y-2">
<Label>{t("createShare.upload.selectedFiles") || "Selected Files"}</Label>
<div className="max-h-[200px] overflow-y-auto space-y-2">
{newFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-muted/50 rounded-md text-sm"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<IconUpload className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{file.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(file.size)}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeNewFile(index)}
className="ml-2 h-6 w-6 p-0"
>
<IconX className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<div className="text-sm text-muted-foreground">
{totalCount > 0 ? (
<span>
{totalCount} {totalCount === 1 ? "item" : "items"} selected ({selectedCount} existing,{" "}
{newFilesCount} new)
</span>
) : (
<span>No items selected</span>
)}
</div>
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={() => setCurrentTab("files")}>
{t("common.back")}
</Button>
<div className="space-x-2"> <div className="space-x-2">
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
{t("common.cancel")} {t("common.cancel")}

View File

@@ -10,6 +10,8 @@ import type {
CreateShareAliasResult, CreateShareAliasResult,
CreateShareBody, CreateShareBody,
CreateShareResult, CreateShareResult,
CreateShareWithFilesBody,
CreateShareWithFilesResult,
DeleteShareResult, DeleteShareResult,
GetShareByAliasParams, GetShareByAliasParams,
GetShareByAliasResult, GetShareByAliasResult,
@@ -39,6 +41,63 @@ export const createShare = <TData = CreateShareResult>(
return apiInstance.post(`/api/shares/create`, createShareBody, options); return apiInstance.post(`/api/shares/create`, createShareBody, options);
}; };
/**
* Create a new share with file uploads
* @summary Create a new share and upload files in a single action
*/
export const createShareWithFiles = <TData = CreateShareWithFilesResult>(
createShareWithFilesBody: CreateShareWithFilesBody,
options?: AxiosRequestConfig
): Promise<TData> => {
const formData = new FormData();
// Add text fields
if (createShareWithFilesBody.name) {
formData.append("name", createShareWithFilesBody.name);
}
if (createShareWithFilesBody.description) {
formData.append("description", createShareWithFilesBody.description);
}
if (createShareWithFilesBody.expiration) {
formData.append("expiration", createShareWithFilesBody.expiration);
}
if (createShareWithFilesBody.password) {
formData.append("password", createShareWithFilesBody.password);
}
if (createShareWithFilesBody.maxViews !== undefined && createShareWithFilesBody.maxViews !== null) {
formData.append("maxViews", createShareWithFilesBody.maxViews.toString());
}
if (createShareWithFilesBody.folderId !== undefined) {
formData.append("folderId", createShareWithFilesBody.folderId || "");
}
// Add array fields as JSON strings
if (createShareWithFilesBody.existingFiles && createShareWithFilesBody.existingFiles.length > 0) {
formData.append("existingFiles", JSON.stringify(createShareWithFilesBody.existingFiles));
}
if (createShareWithFilesBody.existingFolders && createShareWithFilesBody.existingFolders.length > 0) {
formData.append("existingFolders", JSON.stringify(createShareWithFilesBody.existingFolders));
}
if (createShareWithFilesBody.recipients && createShareWithFilesBody.recipients.length > 0) {
formData.append("recipients", JSON.stringify(createShareWithFilesBody.recipients));
}
// Add files
if (createShareWithFilesBody.newFiles && createShareWithFilesBody.newFiles.length > 0) {
createShareWithFilesBody.newFiles.forEach((file) => {
formData.append("files", file);
});
}
return apiInstance.post(`/api/shares/create-with-files`, formData, {
...options,
headers: {
...options?.headers,
"Content-Type": "multipart/form-data",
},
});
};
/** /**
* Update a share * Update a share
* @summary Update a share * @summary Update a share

View File

@@ -139,6 +139,19 @@ export interface CreateShareBody {
recipients?: string[]; recipients?: string[];
} }
export interface CreateShareWithFilesBody {
name?: string;
description?: string;
expiration?: string;
existingFiles?: string[];
existingFolders?: string[];
password?: string;
maxViews?: number | null;
recipients?: string[];
folderId?: string | null;
newFiles?: File[];
}
export interface UpdateShareBody { export interface UpdateShareBody {
id: string; id: string;
name?: string; name?: string;
@@ -186,6 +199,7 @@ export interface GetShareByAliasParams {
} }
export type CreateShareResult = AxiosResponse<CreateShare201>; export type CreateShareResult = AxiosResponse<CreateShare201>;
export type CreateShareWithFilesResult = AxiosResponse<CreateShare201>;
export type UpdateShareResult = AxiosResponse<UpdateShare200>; export type UpdateShareResult = AxiosResponse<UpdateShare200>;
export type ListUserSharesResult = AxiosResponse<ListUserShares200>; export type ListUserSharesResult = AxiosResponse<ListUserShares200>;
export type GetShareResult = AxiosResponse<GetShare200>; export type GetShareResult = AxiosResponse<GetShare200>;