Skip to content

Commit b162826

Browse files
Merge pull request #11 from solid/feat/my-storages
feat: implemented file operations menu, and rename fuctionality for f…
2 parents fd1ab11 + cb01700 commit b162826

14 files changed

Lines changed: 709 additions & 53 deletions

app/components/FileItem.tsx

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"use client";
22

33
import { useState, useRef } from "react";
4-
import Button from "./shared/Button";
5-
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
64
import { getFileIcon, formatFileSize, formatDate, type FileType } from "../lib/helpers";
5+
import FileItemMenu from "./FileItemMenu";
76

87
export type { FileType };
98

@@ -22,6 +21,7 @@ interface FileItemProps {
2221
view: "grid" | "list";
2322
onSelect: (file: FileItemData) => void;
2423
onDoubleClick: (file: FileItemData) => void;
24+
onRename?: (file: FileItemData) => void;
2525
isSelected?: boolean;
2626
}
2727

@@ -30,13 +30,22 @@ export default function FileItem({
3030
view,
3131
onSelect,
3232
onDoubleClick,
33+
onRename,
3334
isSelected = false,
3435
}: FileItemProps) {
3536
const [isHovered, setIsHovered] = useState(false);
3637
const clickCountRef = useRef(0);
3738
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
39+
const lastTapRef = useRef(0);
40+
const touchHandledRef = useRef(false);
3841

3942
const handleClick = (e: React.MouseEvent) => {
43+
// Prevent click handler from running if we just handled a touch event
44+
if (touchHandledRef.current) {
45+
touchHandledRef.current = false;
46+
return;
47+
}
48+
4049
clickCountRef.current += 1;
4150

4251
if (clickCountRef.current === 1) {
@@ -59,20 +68,68 @@ export default function FileItem({
5968
}
6069
};
6170

71+
const handleTouchStart = (e: React.TouchEvent) => {
72+
touchHandledRef.current = true;
73+
// Reset the flag after a delay to allow click events to be ignored
74+
setTimeout(() => {
75+
touchHandledRef.current = false;
76+
}, 400);
77+
78+
const currentTime = new Date().getTime();
79+
const tapLength = currentTime - lastTapRef.current;
80+
81+
if (tapLength < 300 && tapLength > 0) {
82+
// Double tap detected
83+
e.preventDefault();
84+
e.stopPropagation();
85+
if (clickTimeoutRef.current) {
86+
clearTimeout(clickTimeoutRef.current);
87+
clickTimeoutRef.current = null;
88+
}
89+
clickCountRef.current = 0;
90+
onDoubleClick(file);
91+
} else {
92+
// Single tap - wait to see if there's a second tap
93+
clickCountRef.current = 1;
94+
clickTimeoutRef.current = setTimeout(() => {
95+
if (clickCountRef.current === 1) {
96+
onSelect(file);
97+
}
98+
clickCountRef.current = 0;
99+
clickTimeoutRef.current = null;
100+
}, 300);
101+
}
102+
103+
lastTapRef.current = currentTime;
104+
};
105+
62106
if (view === "grid") {
63107
return (
64108
<section
65109
className={`group relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 p-2 transition-colors sm:p-4 ${isSelected
66110
? "border-[#7B42F6] bg-[#F9F6FF]"
67111
: "border-transparent bg-white hover:border-gray-300 hover:bg-gray-50"
68112
}`}
113+
style={{ touchAction: 'manipulation' }}
69114
onMouseEnter={() => setIsHovered(true)}
70115
onMouseLeave={() => setIsHovered(false)}
71116
onClick={handleClick}
117+
onTouchStart={handleTouchStart}
72118
role="button"
73119
tabIndex={0}
74120
aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`}
75121
>
122+
{isHovered && (
123+
<FileItemMenu
124+
file={file}
125+
position="top-right"
126+
onRename={onRename}
127+
onDownload={(f) => console.log("Download:", f.name)}
128+
onCopy={(f) => console.log("Copy:", f.name)}
129+
onMove={(f) => console.log("Move:", f.name)}
130+
onDelete={(f) => console.log("Delete:", f.name)}
131+
/>
132+
)}
76133
<div className="mb-1 flex h-12 w-12 items-center justify-center sm:mb-2 sm:h-16 sm:w-16">
77134
{getFileIcon(file.type, file.mimeType)}
78135
</div>
@@ -88,9 +145,11 @@ export default function FileItem({
88145
<section
89146
className={`group flex cursor-pointer items-center gap-2 border-b border-gray-100 px-2 py-2 transition-colors sm:gap-4 sm:px-4 sm:py-3 ${isSelected ? "bg-[#F9F6FF]" : "bg-white hover:bg-gray-50"
90147
}`}
148+
style={{ touchAction: 'manipulation' }}
91149
onMouseEnter={() => setIsHovered(true)}
92150
onMouseLeave={() => setIsHovered(false)}
93151
onClick={handleClick}
152+
onTouchStart={handleTouchStart}
94153
role="button"
95154
tabIndex={0}
96155
aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`}
@@ -108,17 +167,15 @@ export default function FileItem({
108167
{file.size && formatFileSize(file.size)}
109168
</div>
110169
{isHovered && (
111-
<div className="flex-shrink-0">
112-
<Button
113-
variant="icon"
114-
aria-label="More options"
115-
onClick={(e) => {
116-
e.stopPropagation();
117-
}}
118-
>
119-
<EllipsisVerticalIcon className="h-4 w-4 sm:h-5 sm:w-5" />
120-
</Button>
121-
</div>
170+
<FileItemMenu
171+
file={file}
172+
position="right"
173+
onRename={onRename}
174+
onDownload={(f) => console.log("Download:", f.name)}
175+
onCopy={(f) => console.log("Copy:", f.name)}
176+
onMove={(f) => console.log("Move:", f.name)}
177+
onDelete={(f) => console.log("Delete:", f.name)}
178+
/>
122179
)}
123180
</section>
124181
);

app/components/FileItemMenu.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"use client";
2+
3+
import { useState, useRef, useEffect } from "react";
4+
import Button from "./shared/Button";
5+
import {
6+
EllipsisVerticalIcon,
7+
PencilIcon,
8+
ArrowDownTrayIcon,
9+
DocumentDuplicateIcon,
10+
ArrowRightCircleIcon,
11+
TrashIcon,
12+
} from "@heroicons/react/24/outline";
13+
import { useClickOutside } from "../lib/hooks";
14+
import { FileItemData } from "./FileItem";
15+
16+
interface FileItemMenuProps {
17+
file: FileItemData;
18+
onRename?: (file: FileItemData) => void;
19+
onDownload?: (file: FileItemData) => void;
20+
onCopy?: (file: FileItemData) => void;
21+
onMove?: (file: FileItemData) => void;
22+
onDelete?: (file: FileItemData) => void;
23+
position?: "top-right" | "right";
24+
}
25+
26+
export default function FileItemMenu({
27+
file,
28+
onRename,
29+
onDownload,
30+
onCopy,
31+
onMove,
32+
onDelete,
33+
position = "right",
34+
}: FileItemMenuProps) {
35+
const [showMenu, setShowMenu] = useState(false);
36+
const [menuPosition, setMenuPosition] = useState<"bottom" | "top">("bottom");
37+
const menuButtonRef = useRef<HTMLButtonElement>(null);
38+
const menuRef = useRef<HTMLDivElement>(null);
39+
40+
useClickOutside({
41+
isEnabled: showMenu,
42+
onOutsideClick: () => setShowMenu(false),
43+
refs: [menuRef, menuButtonRef],
44+
});
45+
46+
useEffect(() => {
47+
if (showMenu && menuButtonRef.current) {
48+
const buttonRect = menuButtonRef.current.getBoundingClientRect();
49+
const viewportHeight = window.innerHeight;
50+
const spaceBelow = viewportHeight - buttonRect.bottom;
51+
const spaceAbove = buttonRect.top;
52+
const estimatedMenuHeight = 250; // Approximate height of the menu
53+
54+
// If there's not enough space below but enough space above, show menu above
55+
if (spaceBelow < estimatedMenuHeight && spaceAbove > estimatedMenuHeight) {
56+
setMenuPosition("top");
57+
} else {
58+
setMenuPosition("bottom");
59+
}
60+
}
61+
}, [showMenu]);
62+
63+
const handleAction = (action: ((file: FileItemData) => void) | undefined) => {
64+
if (action) {
65+
action(file);
66+
}
67+
setShowMenu(false);
68+
};
69+
70+
const menuItems = [
71+
{
72+
label: "Rename",
73+
icon: PencilIcon,
74+
action: onRename,
75+
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
76+
iconClassName: "text-gray-500",
77+
},
78+
{
79+
label: "Download",
80+
icon: ArrowDownTrayIcon,
81+
action: onDownload,
82+
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
83+
iconClassName: "text-gray-500",
84+
},
85+
{
86+
label: "Copy",
87+
icon: DocumentDuplicateIcon,
88+
action: onCopy,
89+
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
90+
iconClassName: "text-gray-500",
91+
},
92+
{
93+
label: "Move",
94+
icon: ArrowRightCircleIcon,
95+
action: onMove,
96+
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
97+
iconClassName: "text-gray-500",
98+
},
99+
{
100+
label: "Delete",
101+
icon: TrashIcon,
102+
action: onDelete,
103+
className: "text-red-600 hover:bg-red-50",
104+
iconClassName: "text-red-500",
105+
},
106+
];
107+
108+
const menuButton = (
109+
<Button
110+
ref={menuButtonRef}
111+
variant="icon"
112+
aria-label="More options"
113+
aria-expanded={showMenu}
114+
onClick={(e) => {
115+
e.stopPropagation();
116+
setShowMenu(!showMenu);
117+
}}
118+
className={position === "top-right" ? "bg-white/90 hover:bg-white shadow-sm" : ""}
119+
>
120+
<EllipsisVerticalIcon className="h-4 w-4 sm:h-5 sm:w-5" />
121+
</Button>
122+
);
123+
124+
const dropdownMenu = showMenu && (
125+
<div
126+
ref={menuRef}
127+
className={`absolute ${position === "top-right" ? "right-0" : "right-0"} ${
128+
menuPosition === "top" ? "bottom-full mb-1" : "top-full mt-1"
129+
} z-[100] w-48 rounded-lg bg-white border border-gray-200 shadow-lg overflow-hidden`}
130+
role="menu"
131+
onClick={(e) => e.stopPropagation()}
132+
>
133+
{menuItems.map((item, index) => {
134+
const Icon = item.icon;
135+
const isLast = index === menuItems.length - 1;
136+
return (
137+
<button
138+
key={item.label}
139+
type="button"
140+
onClick={(e) => {
141+
e.stopPropagation();
142+
handleAction(item.action);
143+
}}
144+
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors cursor-pointer ${
145+
isLast ? item.className : `${item.className} border-b border-gray-100`
146+
}`}
147+
role="menuitem"
148+
>
149+
<Icon className={`h-5 w-5 ${item.iconClassName}`} />
150+
<span>{item.label}</span>
151+
</button>
152+
);
153+
})}
154+
</div>
155+
);
156+
157+
if (position === "top-right") {
158+
return (
159+
<div className="absolute top-2 right-2 z-10">
160+
<div className="relative">
161+
{menuButton}
162+
{dropdownMenu}
163+
</div>
164+
</div>
165+
);
166+
}
167+
168+
return (
169+
<div className="relative flex-shrink-0">
170+
{menuButton}
171+
{dropdownMenu}
172+
</div>
173+
);
174+
}
175+

app/components/FileList.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useState, useEffect } from "react";
44
import FileItem, { FileItemData } from "./FileItem";
55
import Toolbar from "./shared/Toolbar";
66
import EmptyState from "./shared/EmptyState";
@@ -10,17 +10,29 @@ interface FileListProps {
1010
currentPath: string;
1111
onFileSelect: (file: FileItemData) => void;
1212
onFileDoubleClick: (file: FileItemData) => void;
13+
onFileRename?: (file: FileItemData) => void;
1314
selectedFileIds: string[];
1415
}
1516

17+
const VIEW_STORAGE_KEY = "solid-file-manager-view";
18+
1619
export default function FileList({
1720
files,
1821
currentPath,
1922
onFileSelect,
2023
onFileDoubleClick,
24+
onFileRename,
2125
selectedFileIds,
2226
}: FileListProps) {
23-
const [view, setView] = useState<"grid" | "list">("list");
27+
const [view, setView] = useState<"grid" | "list">(() => {
28+
if (typeof window === "undefined") return "list";
29+
const stored = localStorage.getItem(VIEW_STORAGE_KEY);
30+
return (stored === "grid" || stored === "list") ? stored : "list";
31+
});
32+
33+
useEffect(() => {
34+
localStorage.setItem(VIEW_STORAGE_KEY, view);
35+
}, [view]);
2436

2537
return (
2638
<main className="flex h-full flex-col">
@@ -43,6 +55,7 @@ export default function FileList({
4355
view={view}
4456
onSelect={onFileSelect}
4557
onDoubleClick={onFileDoubleClick}
58+
onRename={onFileRename}
4659
isSelected={selectedFileIds.includes(file.id)}
4760
/>
4861
))}
@@ -56,6 +69,7 @@ export default function FileList({
5669
view={view}
5770
onSelect={onFileSelect}
5871
onDoubleClick={onFileDoubleClick}
72+
onRename={onFileRename}
5973
isSelected={selectedFileIds.includes(file.id)}
6074
/>
6175
))}

0 commit comments

Comments
 (0)