Files
portabase/src/components/common/file-uploader.tsx
T
Charles GTEandGitHub 3fa0f9b76d fix: feature-base-refactoring (#297)
* docs: add AGENTS.md with feature-based architecture conventions

Defines the target src/ structure, feature list, naming conventions
(kebab-case, file type patterns), import direction rules, and
feature boundary guidelines for the planned big-bang refactor.

* docs: add features/channel/ to architecture conventions

Generic channel UI (card, modal, form) goes to features/channel/.
Provider-specific sub-folders: channel/notifications/ and channel/storages/.

* refactor: move event emitter to lib/, project service to db/services/, dedup use-mobile hook

* fix: remove unused auth import in db/services/project.ts

* refactor: migrate wrappers/common to components/common (flat)

* fix: remove dead commented-out code from button-with-loading.tsx

* refactor: merge features/browser into features/theme, flatten upload/updates, migrate statistics

* refactor: migrate wrappers/auth to features/auth

Move all auth components and forms from src/components/wrappers/auth/
into src/features/auth/ with flat kebab-case naming. Handle extra
reset-password-form.tsx and differing reset-password-schema.ts by
copying them as reset-password-page-form.tsx and reset-password-page.schema.ts.

* refactor: remove dead auth files and fix zPassword schema

Delete reset-password-section.tsx and reset-password-page-form.tsx (zero consumers).
Fix reset-password-page.schema.ts to import zPassword from @/lib/zod.

* refactor: migrate wrappers/dashboard/common to features/layout, rename Header.tsx to header.tsx

* fix: decouple layout from theme/updates cross-feature imports

Use slot props (actions, updateNotification) so app/ composes
ModeToggle and UpdateNotification into Header and AppSidebar.
Delete dead side-bar-logo.tsx (zero consumers).

* refactor: merge features/keys into features/agents, flatten sub-dirs, migrate agent wrappers

- Merge src/features/keys/keys.action.ts into src/features/agents/
- Flatten src/features/agents/components/ and hooks/ into feature root
- Migrate all src/components/wrappers/dashboard/agent/ files into src/features/agents/
- Update all import paths across app/ and src/ accordingly
- Fix broken code-snippet import in agent-key-modal.tsx (was pointing to non-existent wrappers path)

* fix: move github-releases to lib/, remove dead code and unused imports

Move GitHub release fetch logic from features/updates/github.ts to
lib/github-releases.ts so both features/updates and features/agents
can import it without cross-feature imports.
Remove commented-out impersonation block from agent-database-card.tsx.
Remove unused imports (CopyButton, Terminal, Key, Info).

* refactor: flatten features/projects/components, migrate project wrappers

Move project.dialog/form out of sub-dir, migrate project-card, project-database-card, and delete-project button/action from wrappers into features/projects/. Update all import paths across src/ and app/. Leave wrappers/dashboard/projects/database/ untouched for Task 10.

* fix: extract DatabaseCard to components/common, remove cross-feature import

DatabaseCard was imported by both features/projects and features/agents,
violating the no-cross-feature-import rule. Extract to components/common/
database-card.tsx so both features import from the shared layer.
Also tighten extendedProps type and remove commented-out maxCount prop.

* refactor: migrate all database/backup/restore/health UI to features/database

Consolidates ~40 files from 5 source locations (features/dashboard/backup,
features/dashboard/restore, wrappers/dashboard/backup, wrappers/dashboard/database,
wrappers/dashboard/health, wrappers/dashboard/projects/database) into the new
flat features/database directory with kebab-case filenames and updated @/ imports.

* fix: remove dead restore-form files and console.log from health-grid

Delete restore-form.tsx (186 lines all commented-out, zero consumers)
and restore-form.schema.ts (only a commented import).
Remove console.log(logs) from getOldestLog helper in health-grid.tsx.

Cross-feature imports (database→storages for dispatchStorage/storeBackupFiles)
are accepted as known architectural coupling — storage dispatch is called from
database server actions at runtime and cannot route through app/ props.

* refactor: migrate wrappers/admin/users to features/users, fix table-colums typo

* fix: fix relative imports, string interpolation, and console.error in users feature

Replace relative imports with @/ aliases in user-actions-cell.tsx and
admin-user-edit-modal.tsx. Fix string interpolation bug in dialog title
(was regular string, now template literal). Remove console.error from
admin-user-change-role-modal.tsx.

* refactor: migrate wrappers/profile to features/profile, fix profile-apperance typo

* fix: remove console.error calls and ts-ignore in profile feature

Remove console.error from profile-account.tsx, 2fa-form.tsx (x2).
Replace @ts-ignore on fileInput.onchange with typed cast in avatar-with-upload.tsx.

* refactor: rename features/organization to features/organizations, migrate all org wrappers

Moves 37 org-related files from features/organization/, features/dashboard/,
hooks/, wrappers/admin/organizations/, wrappers/admin/channels/organization/,
and wrappers/dashboard/organization/ into a flat features/organizations/ feature
directory. Updates all import paths codebase-wide. Fixes @ts-ignore and removes
unused @better-fetch/fetch import in admin-organization-form.

* fix: remove console.error, @ts-ignore, and unused destructures in features/organizations

* refactor: migrate wrappers/organization/migration to features/migration

Move all 5 migration files to src/features/migration/, update internal
imports to @/features/migration/ absolute paths, remove console.log,
and delete src/components/wrappers/dashboard/organization/ entirely.

* refactor: migrate wrappers/admin/settings to features/settings

Move all 14 settings files (email, notification, storage, s3) from
wrappers/dashboard/admin/settings/ into features/settings/, rewriting
imports, replacing @ts-ignore with @ts-expect-error, and renaming error
params to _error where applicable.

* refactor: rename notifications files to kebab convention, migrate notification logs

- Rename dispatch.ts/helpers.ts/types.ts to notifications.*.ts
- Migrate notification logs components from wrappers/dashboard/admin/notifications/logs/ to features/notifications/
- Fix all import paths across codebase and in providers/
- Remove unused useRouter import, replace @ts-ignore with @ts-expect-error
- Delete src/components/wrappers/dashboard/admin/notifications/

* refactor: rename storages dispatch/helpers/types to kebab convention

- Rename dispatch.ts/helpers.ts/types.ts to storages.*.ts
- Fix all import paths across codebase and in providers/
- Fix relative ../types and ../../types imports in all storage providers to absolute @/ paths
- Replace @ts-ignore with @ts-expect-error, remove console.debug/console.error

* refactor: create features/channel with generic UI and notifications/storages provider sub-folders

Consolidates 4 source locations into src/features/channel/:
- components/wrappers/dashboard/admin/channels/ → features/channel/ (11 generic UI files)
- features/notifications/providers/ → features/channel/notifications/ (9 backend + 18 form files)
- features/storages/providers/ → features/channel/storages/ (backend + forms)

The two conflicting google-drive/helpers.ts files (client helpers vs OAuth action)
are kept separate: helpers.ts and google-drive-refresh.action.ts.

Rewrites all imports codebase-wide to point to new locations.
Removes console.log/console.error calls, upgrades @ts-ignore to @ts-expect-error.

* fix: remove console.error from local storage provider

* fix: multi-select.tsx scroll issue

* fix: multi-select.tsx scroll issue
2026-05-24 17:09:12 +02:00

273 lines
10 KiB
TypeScript

"use client";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Dispatch, SetStateAction, createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useDropzone, DropzoneState, FileRejection, DropzoneOptions } from "react-dropzone";
import { toast } from "sonner";
import { Trash2 as RemoveIcon } from "lucide-react";
type DirectionOptions = "rtl" | "ltr" | undefined;
type FileUploaderContextType = {
dropzoneState: DropzoneState;
isLOF: boolean;
isFileTooBig: boolean;
removeFileFromSet: (index: number) => void;
activeIndex: number;
setActiveIndex: Dispatch<SetStateAction<number>>;
orientation: "horizontal" | "vertical";
direction: DirectionOptions;
};
const FileUploaderContext = createContext<FileUploaderContextType | null>(null);
export const useFileUpload = () => {
const context = useContext(FileUploaderContext);
if (!context) {
throw new Error("useFileUpload must be used within a FileUploaderProvider");
}
return context;
};
type FileUploaderProps = {
value: File[] | null;
reSelect?: boolean;
onValueChange: (value: File[] | null) => void;
dropzoneOptions: DropzoneOptions;
orientation?: "horizontal" | "vertical";
};
/**
* File upload Docs: {@link: https://localhost:3000/docs/file-upload}
*/
export const FileUploader = forwardRef<HTMLDivElement, FileUploaderProps & React.HTMLAttributes<HTMLDivElement>>(
({ className, dropzoneOptions, value, onValueChange, reSelect, orientation = "vertical", children, dir, ...props }, ref) => {
const [isFileTooBig, setIsFileTooBig] = useState(false);
const [isLOF, setIsLOF] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const {
accept = {
"image/*": [".jpg", ".jpeg", ".png", ".gif"],
},
maxFiles = 1,
maxSize = 4 * 1024 * 1024,
multiple = true,
} = dropzoneOptions;
const reSelectAll = maxFiles === 1 ? true : reSelect;
const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr";
const removeFileFromSet = useCallback(
(i: number) => {
if (!value) return;
const newFiles = value.filter((_, index) => index !== i);
onValueChange(newFiles);
},
[value, onValueChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!value) return;
const moveNext = () => {
const nextIndex = activeIndex + 1;
setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex);
};
const movePrev = () => {
const nextIndex = activeIndex - 1;
setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex);
};
const prevKey = orientation === "horizontal" ? (direction === "ltr" ? "ArrowLeft" : "ArrowRight") : "ArrowUp";
const nextKey = orientation === "horizontal" ? (direction === "ltr" ? "ArrowRight" : "ArrowLeft") : "ArrowDown";
if (e.key === nextKey) {
moveNext();
} else if (e.key === prevKey) {
movePrev();
} else if (e.key === "Enter" || e.key === "Space") {
if (activeIndex === -1) {
dropzoneState.inputRef.current?.click();
}
} else if (e.key === "Delete" || e.key === "Backspace") {
if (activeIndex !== -1) {
removeFileFromSet(activeIndex);
if (value.length - 1 === 0) {
setActiveIndex(-1);
return;
}
movePrev();
}
} else if (e.key === "Escape") {
setActiveIndex(-1);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[value, activeIndex, removeFileFromSet]
);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const files = acceptedFiles;
if (!files) {
toast.error("file error , probably too big");
return;
}
const newValues: File[] = value ? [...value] : [];
if (reSelectAll) {
newValues.splice(0, newValues.length);
}
files.forEach((file) => {
if (newValues.length < maxFiles) {
newValues.push(file);
}
});
onValueChange(newValues);
if (rejectedFiles.length > 0) {
for (let i = 0; i < rejectedFiles.length; i++) {
if (rejectedFiles[i].errors[0]?.code === "file-too-large") {
toast.error(`File is too large. Max size is ${maxSize / 1024 / 1024}MB`);
break;
}
if (rejectedFiles[i].errors[0]?.message) {
toast.error(rejectedFiles[i].errors[0].message);
break;
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[reSelectAll, value]
);
useEffect(() => {
if (!value) return;
if (value.length === maxFiles) {
setIsLOF(true);
return;
}
setIsLOF(false);
}, [value, maxFiles]);
const opts = dropzoneOptions ? dropzoneOptions : { accept, maxFiles, maxSize, multiple };
const dropzoneState = useDropzone({
...opts,
onDrop,
onDropRejected: () => setIsFileTooBig(true),
onDropAccepted: () => setIsFileTooBig(false),
});
return (
<FileUploaderContext.Provider
value={{
dropzoneState,
isLOF,
isFileTooBig,
removeFileFromSet,
activeIndex,
setActiveIndex,
orientation,
direction,
}}
>
<div
ref={ref}
tabIndex={0}
onKeyDownCapture={handleKeyDown}
className={cn("grid w-full focus:outline-none", className, {
"gap-2": value && value.length > 0,
})}
dir={dir}
{...props}
>
{children}
</div>
</FileUploaderContext.Provider>
);
}
);
FileUploader.displayName = "FileUploader";
export const FileUploaderContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ children, className, ...props }, ref) => {
const { orientation } = useFileUpload();
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef} aria-description="content file holder">
<div
{...props}
ref={ref}
className={cn(
"grid gap-4",
orientation === "horizontal" ? "grid-rows-1 sm:grid-rows-2 lg:grid-rows-3" : "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
className
)}
>
{children}
</div>
</div>
);
});
FileUploaderContent.displayName = "FileUploaderContent";
export const FileUploaderItem = forwardRef<HTMLDivElement, { index: number } & React.HTMLAttributes<HTMLDivElement>>(
({ className, index, children, ...props }, ref) => {
const { removeFileFromSet, activeIndex, direction } = useFileUpload();
const isSelected = index === activeIndex;
return (
<div ref={ref} className={cn("p-1 justify-between cursor-pointer relative", className, isSelected ? "bg-muted" : "")} {...props}>
<div className="font-medium leading-none tracking-tight flex items-center gap-1.5 h-full w-full">{children}</div>
<button
type="button"
className={cn("absolute", direction === "rtl" ? "top-1 left-1" : "top-1 right-1")}
onClick={() => removeFileFromSet(index)}
>
<span className="sr-only">remove item {index}</span>
<RemoveIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" />
</button>
</div>
);
}
);
FileUploaderItem.displayName = "FileUploaderItem";
export const FileInput = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, children, ...props }, ref) => {
const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();
const rootProps = isLOF ? {} : dropzoneState.getRootProps();
return (
<div ref={ref} {...props} className={`relative w-full ${isLOF ? "opacity-50 cursor-not-allowed " : "cursor-pointer "}`}>
<div
className={cn(
`w-full rounded-lg duration-300 ease-in-out
${dropzoneState.isDragAccept ? "border-green-500" : dropzoneState.isDragReject || isFileTooBig ? "border-red-500" : "border-gray-300"}`,
className
)}
{...rootProps}
>
{children}
</div>
<Input ref={dropzoneState.inputRef} disabled={isLOF} {...dropzoneState.getInputProps()} className={`${isLOF ? "cursor-not-allowed" : ""}`} />
</div>
);
});
FileInput.displayName = "FileInput";