This commit is contained in:
Rodolfo Martinez 2025-12-05 15:41:08 -05:00
parent da906be65d
commit ee537fc4a0
7 changed files with 828 additions and 664 deletions

View file

@ -1,6 +1,7 @@
// File: src/components/UIX/IconPicker/IconPicker.jsx
// Icon picker component for selecting emojis or predefined icons for collections
import React, { useState } from "react";
// UIX version - Icon picker component for selecting emojis or predefined icons
// Theme-aware with proper UIX component usage
import React, { useState, useCallback, useMemo } from "react";
import {
XMarkIcon,
FolderIcon,
@ -41,6 +42,9 @@ import {
TrophyIcon,
UserGroupIcon,
} from "@heroicons/react/24/outline";
import { useUIXTheme } from "../themes/useUIXTheme";
import Button from "../Button/Button";
import Tabs from "../Tabs/Tabs";
// Predefined icons with their identifiers and labels
const PREDEFINED_ICONS = [
@ -84,103 +88,58 @@ const PREDEFINED_ICONS = [
// Comprehensive emoji collection organized by logical categories
const EMOJI_CATEGORIES = {
// Most commonly used for file organization
"Files & Folders": [
"Folders & Work": [
"📁", "📂", "🗂️", "📑", "📄", "📃", "📋", "📝", "✏️", "🖊️",
"📎", "📌", "🔖", "🏷️", "📰", "🗃️", "🗄️", "📦", "📥", "📤",
],
// Work, business, and professional
"Work & Business": [
"💼", "🏢", "🏛️", "🏦", "💰", "💵", "💳", "🧾", "📊", "📈",
"📉", "💹", "🗓️", "📅", "⏰", "⌚", "🖥️", "💻", "⌨️", "🖨️",
],
// Technology, electronics, and connectivity
"Tech & Devices": [
"Tech & Media": [
"📱", "📲", "☎️", "📞", "📟", "📠", "🔌", "🔋", "💾", "💿",
"📀", "🖱️", "🖲️", "🎮", "🕹️", "🛜", "📡", "📺", "📻", "🎙️",
],
// Media, entertainment, and creativity
"Media & Creative": [
"📷", "📸", "📹", "🎥", "🎬", "🎞️", "📽️", "🎵", "🎶", "🎤",
"🎧", "🎼", "🎹", "🎸", "🥁", "🎨", "🖼️", "🎭", "🎪", "🎠",
"🔒", "🔓", "🔐", "🔑", "🗝️", "🛡️", "⚔️", "🔫", "🚨", "🚔",
"👮", "🕵️", "🦺", "🧯", "🪖", "⛑️", "🔏", "👁️‍🗨️", "🛂",
"💬", "💭", "🗨️", "🗯️", "📧", "📨", "📩", "📮", "📪", "📫",
"📬", "📭", "✉️", "💌", "📯", "🔔", "🔕", "📢", "📣", "🗣️",
],
// Education, learning, and research
"Education & Science": [
"📚", "📖", "📕", "📗", "📘", "📙", "🎓", "🏫", "✍️", "📐",
"📏", "🔬", "🔭", "🧪", "🧫", "🧬", "🔍", "🔎", "💡", "📡",
],
// Communication and social
"Communication": [
"💬", "💭", "🗨️", "🗯️", "📧", "📨", "📩", "📮", "📪", "📫",
"📬", "📭", "✉️", "💌", "📯", "🔔", "🔕", "📢", "📣", "🗣️",
],
// Home, family, and personal life
"Home & Life": [
"🏠", "🏡", "🏘️", "🛏️", "🛋️", "🪑", "🚿", "🛁", "🧹", "🧺",
"👨‍👩‍👧‍👦", "👪", "❤️", "💕", "💝", "💖", "🧸", "🎁", "🎀", "🎈",
],
// Health, fitness, and wellness
"Health & Wellness": [
"Health & Food": [
"🏥", "💊", "💉", "🩺", "🩹", "🩼", "♿", "🧘", "🏃", "🚴",
"🏋️", "🤸", "⚕️", "🩸", "🧠", "👁️", "🦷", "💪", "🧬", "🍎",
],
// Food and beverages
"Food & Drinks": [
"🍕", "🍔", "🍟", "🌮", "🌯", "🍜", "🍝", "🍣", "🍱", "🥗",
"🍰", "🎂", "🧁", "🍩", "🍪", "☕", "🍵", "🥤", "🍷", "🍺",
],
// Travel, transportation, and places
"Travel & Places": [
"✈️", "🚀", "🛸", "🚁", "🚂", "🚗", "🚕", "🚌", "🚢", "⛵",
"🗺️", "🧭", "🏖️", "🏔️", "🏕️", "🗽", "🗼", "🏰", "⛺", "🌍",
],
// Sports, games, and hobbies
"Sports & Hobbies": [
"⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏓", "🏸", "🎯", "🎱",
"🎳", "🏆", "🥇", "🥈", "🥉", "🎲", "♟️", "🧩", "🎰", "🎮",
],
// Nature, weather, and environment
"Nature & Weather": [
"Nature & Animals": [
"🌸", "🌺", "🌻", "🌹", "🌷", "🌴", "🌲", "🍀", "🌿", "🍃",
"☀️", "🌙", "⭐", "🌈", "☁️", "🌧️", "❄️", "🔥", "💧", "🌊",
],
// Animals and pets
"Animals": [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🦁",
"🐯", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦋", "🐝",
],
// Symbols, signs, and indicators
"Symbols & Status": [
"✅", "❌", "⭕", "❗", "❓", "💯", "🔴", "🟠", "🟡", "🟢",
"🔵", "🟣", "⚫", "⚪", "🔶", "🔷", "💠", "🔘", "🏁", "🚩",
],
// Security and protection
"Security": [
"🔒", "🔓", "🔐", "🔑", "🗝️", "🛡️", "⚔️", "🔫", "🚨", "🚔",
"👮", "🕵️", "🦺", "🧯", "🪖", "⛑️", "🔏", "🔒", "👁️‍🗨️", "🛂",
],
// Time and scheduling
"Time & Planning": [
"⏰", "⏱️", "⏲️", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖",
"📅", "📆", "🗓️", "⌛", "⏳", "🔜", "🔙", "🔚", "🔛", "🔝",
],
// Celebration and events
"Celebration": [
"🎉", "🎊", "🎂", "🎁", "🎀", "🎈", "🎄", "🎃", "🎆", "🎇",
"✨", "💫", "🌟", "⭐", "🏅", "🎖️", "🏆", "🥳", "🎯", "🎪",
@ -188,146 +147,181 @@ const EMOJI_CATEGORIES = {
};
const IconPicker = ({ value, onChange, onClose, isOpen }) => {
const { getThemeClasses } = useUIXTheme();
const [activeTab, setActiveTab] = useState("emoji");
const [activeEmojiCategory, setActiveEmojiCategory] = useState("Files & Folders");
const [activeEmojiCategory, setActiveEmojiCategory] = useState("Folders & Work");
// Memoize category keys
const categoryKeys = useMemo(() => Object.keys(EMOJI_CATEGORIES), []);
// Memoize tabs configuration
const tabsConfig = useMemo(() => [
{ id: "emoji", label: "Emoji" },
{ id: "icons", label: "Icons" },
], []);
// Handlers
const handleSelect = useCallback((iconValue) => {
onChange(iconValue);
onClose();
}, [onChange, onClose]);
const handleReset = useCallback(() => {
onChange("");
onClose();
}, [onChange, onClose]);
const handleTabChange = useCallback((tab) => {
setActiveTab(tab);
}, []);
const handleCategoryChange = useCallback((category) => {
setActiveEmojiCategory(category);
}, []);
// Handle backdrop click
const handleBackdropClick = useCallback((e) => {
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
// Handle escape key
const handleKeyDown = useCallback((e) => {
if (e.key === "Escape") {
onClose();
}
}, [onClose]);
if (!isOpen) return null;
const handleSelect = (iconValue) => {
onChange(iconValue);
onClose();
};
const handleReset = () => {
onChange("");
onClose();
};
// Check if current value is an icon or emoji
const isIconSelected = value?.startsWith("icon:");
const selectedIconId = isIconSelected ? value.replace("icon:", "") : null;
// Get category keys for rendering
const categoryKeys = Object.keys(EMOJI_CATEGORIES);
// Check if current value is an icon or emoji (with type safety)
const safeValue = typeof value === 'string' ? value : '';
const isIconSelected = safeValue.startsWith("icon:");
const selectedIconId = isIconSelected ? safeValue.replace("icon:", "") : null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl lg:max-w-3xl w-full max-h-[85vh] flex flex-col">
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="icon-picker-title"
>
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-xl w-full max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[85vh] flex flex-col`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Choose Icon</h3>
<button
<div className={`flex items-center justify-between p-4 border-b ${getThemeClasses("border-secondary")}`}>
<h3 id="icon-picker-title" className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>
Choose Icon
</h3>
<Button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
variant="ghost"
size="sm"
aria-label="Close icon picker"
>
<XMarkIcon className="h-5 w-5" />
</button>
</Button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab("emoji")}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === "emoji"
? "text-red-800 border-b-2 border-red-800"
: "text-gray-500 hover:text-gray-700"
}`}
>
Emoji
</button>
<button
onClick={() => setActiveTab("icons")}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === "icons"
? "text-red-800 border-b-2 border-red-800"
: "text-gray-500 hover:text-gray-700"
}`}
>
Icons
</button>
</div>
<Tabs
tabs={tabsConfig}
activeTab={activeTab}
onTabChange={handleTabChange}
variant="underline"
size="sm"
fullWidth
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "emoji" ? (
<div className="flex gap-4">
{/* Category Sidebar */}
<div className="w-40 flex-shrink-0 border-r border-gray-200 pr-4">
<div className={`w-44 flex-shrink-0 border-r ${getThemeClasses("border-secondary")} pr-4`}>
<div className="sticky top-0 space-y-1 max-h-[60vh] overflow-y-auto">
{categoryKeys.map((category) => (
<button
<Button
key={category}
onClick={() => setActiveEmojiCategory(category)}
className={`w-full text-left px-3 py-2 text-xs font-medium rounded-lg transition-colors ${
onClick={() => handleCategoryChange(category)}
variant={activeEmojiCategory === category ? "ghost" : "ghost"}
size="sm"
className={`w-full justify-start text-xs ${
activeEmojiCategory === category
? "bg-red-100 text-red-800"
: "text-gray-600 hover:bg-gray-100"
? `${getThemeClasses("bg-accent-light")} ${getThemeClasses("text-accent")}`
: ""
}`}
>
{category}
</button>
</Button>
))}
</div>
</div>
{/* Emoji Grid */}
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-700 mb-3">
<h4 className={`text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
{activeEmojiCategory}
</h4>
<div className="grid grid-cols-10 gap-1">
<div className="grid grid-cols-8 sm:grid-cols-10 lg:grid-cols-12 gap-1">
{EMOJI_CATEGORIES[activeEmojiCategory].map((emoji, index) => (
<button
<Button
key={`${emoji}-${index}`}
onClick={() => handleSelect(emoji)}
className={`p-2 text-2xl rounded-lg hover:bg-gray-100 transition-colors ${
value === emoji ? "bg-red-100 ring-2 ring-red-500" : ""
variant="ghost"
size="sm"
className={`p-2 text-2xl ${
safeValue === emoji ? `${getThemeClasses("bg-accent-light")} ring-2 ${getThemeClasses("ring-accent")}` : ""
}`}
title={emoji}
>
{emoji}
</button>
</Button>
))}
</div>
</div>
</div>
) : (
<div className="grid grid-cols-6 gap-2">
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-2">
{PREDEFINED_ICONS.map(({ id, Icon, label }) => (
<button
<Button
key={id}
onClick={() => handleSelect(`icon:${id}`)}
className={`flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 transition-colors ${
selectedIconId === id ? "bg-red-100 ring-2 ring-red-500" : ""
variant="ghost"
className={`flex flex-col items-center p-3 h-auto ${
selectedIconId === id ? `${getThemeClasses("bg-accent-light")} ring-2 ${getThemeClasses("ring-accent")}` : ""
}`}
title={label}
>
<Icon className="h-6 w-6 text-gray-700" />
<span className="text-xs text-gray-500 mt-1 truncate w-full text-center">
<Icon className={`h-6 w-6 ${getThemeClasses("text-primary")}`} />
<span className={`text-xs ${getThemeClasses("text-secondary")} mt-1 truncate w-full text-center`}>
{label}
</span>
</button>
</Button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
<button
<div className={`flex items-center justify-between p-4 border-t ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} rounded-b-xl`}>
<Button
onClick={handleReset}
className="flex items-center px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
variant="ghost"
size="sm"
icon={FolderIcon}
>
<FolderIcon className="h-4 w-4 mr-2" />
Reset to Default
</button>
<button
</Button>
<Button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
variant="secondary"
size="sm"
>
Cancel
</button>
</Button>
</div>
</div>
</div>

View file

@ -13,11 +13,14 @@ import { useUIXTheme } from "../themes/useUIXTheme.jsx";
* @param {function} onTabChange - Tab change handler for callback mode
* @param {string} className - Additional CSS classes
* @param {string} mode - 'callback' for onClick behavior, 'routing' for href navigation (default: 'callback')
* @param {string} variant - 'default' for standard tabs, 'pills' for pill-style, 'underline' for simple underline (default: 'default')
* @param {string} size - 'sm', 'md', 'lg' (default: 'md')
* @param {boolean} fullWidth - Whether tabs should take full width (default: false)
*/
// Tab Item Component - Separated for performance
const TabItem = memo(
function TabItem({ tab, index, isActive, mode, onTabChange, themeClasses }) {
function TabItem({ tab, index, isActive, mode, onTabChange, themeClasses, variant, size, fullWidth }) {
// Memoize click handler
const handleClick = useCallback(() => {
// Use tab's own onClick if provided, otherwise use parent onTabChange
@ -28,53 +31,88 @@ const TabItem = memo(
}
}, [tab, onTabChange]);
// Size classes
const sizeClasses = {
sm: "py-2 px-3 text-sm",
md: "py-3 px-4 text-sm sm:text-base",
lg: "py-3 sm:py-4 px-1 text-base sm:text-lg",
};
// Memoize icon element
const IconElement = useMemo(() => {
if (!tab.icon) return null;
return <tab.icon className="w-4 sm:w-5 h-4 sm:h-5 ml-1" />;
}, [tab.icon]);
const iconSize = size === "sm" ? "w-4 h-4" : size === "lg" ? "w-5 h-5" : "w-4 sm:w-5 h-4 sm:h-5";
return <tab.icon className={`${iconSize} ml-1`} />;
}, [tab.icon, size]);
// Memoize classes based on active state
// Memoize classes based on active state and variant
const tabClasses = useMemo(() => {
const baseClasses = [
"border-b-4",
"border-transparent",
"py-3",
"sm:py-4",
"px-1",
"text-base",
"sm:text-lg",
"font-medium",
"whitespace-nowrap",
"inline-flex",
"items-center",
"justify-center",
"transition-colors",
"duration-200",
sizeClasses[size] || sizeClasses.md,
];
// Determine hover border color based on theme
const hoverBorderClass = {
red: "hover:border-red-500",
blue: "hover:border-blue-500",
purple: "hover:border-purple-500",
green: "hover:border-green-500",
charcoal: "hover:border-slate-500",
}[themeClasses.currentTheme] || "hover:border-gray-500";
if (fullWidth) {
baseClasses.push("flex-1");
}
if (isActive) {
// Active tab: just show primary text, no border
baseClasses.push(themeClasses.textPrimary);
// Variant-specific styling
if (variant === "underline") {
// Simple underline style - good for modals
baseClasses.push("border-b-2");
if (isActive) {
baseClasses.push(themeClasses.textAccent, themeClasses.borderAccent);
} else {
baseClasses.push(
"border-transparent",
themeClasses.textSecondary,
themeClasses.hoverTextPrimary
);
}
} else if (variant === "pills") {
// Pill-style tabs
baseClasses.push("rounded-lg");
if (isActive) {
baseClasses.push(themeClasses.bgAccentLight, themeClasses.textAccent);
} else {
baseClasses.push(
themeClasses.textSecondary,
themeClasses.hoverBgMuted,
themeClasses.hoverTextPrimary
);
}
} else {
// Inactive tab: secondary text with hover effect
baseClasses.push(
themeClasses.textSecondary,
themeClasses.hoverTextPrimary,
hoverBorderClass
);
// Default variant - original behavior
baseClasses.push("border-b-4", "border-transparent");
// Determine hover border color based on theme
const hoverBorderClass = {
red: "hover:border-red-500",
blue: "hover:border-blue-500",
purple: "hover:border-purple-500",
green: "hover:border-green-500",
charcoal: "hover:border-slate-500",
}[themeClasses.currentTheme] || "hover:border-gray-500";
if (isActive) {
baseClasses.push(themeClasses.textPrimary);
} else {
baseClasses.push(
themeClasses.textSecondary,
themeClasses.hoverTextPrimary,
hoverBorderClass
);
}
}
return baseClasses.filter(Boolean).join(" ");
}, [isActive, themeClasses]);
}, [isActive, themeClasses, variant, size, fullWidth]);
// Routing mode with active state (non-interactive)
if (mode === "routing" && isActive) {
@ -130,7 +168,10 @@ const TabItem = memo(
prevProps.isActive === nextProps.isActive &&
prevProps.mode === nextProps.mode &&
prevProps.onTabChange === nextProps.onTabChange &&
prevProps.themeClasses === nextProps.themeClasses
prevProps.themeClasses === nextProps.themeClasses &&
prevProps.variant === nextProps.variant &&
prevProps.size === nextProps.size &&
prevProps.fullWidth === nextProps.fullWidth
);
},
);
@ -145,6 +186,9 @@ const Tabs = memo(
onTabChange,
className = "",
mode = "callback",
variant = "default",
size = "md",
fullWidth = false,
}) {
const { getThemeClasses, currentTheme } = useUIXTheme();
@ -153,7 +197,12 @@ const Tabs = memo(
() => ({
textPrimary: getThemeClasses("text-primary"),
textSecondary: getThemeClasses("text-secondary"),
textAccent: getThemeClasses("text-accent"),
hoverTextPrimary: getThemeClasses("hover:text-primary"),
bgAccentLight: getThemeClasses("bg-accent-light"),
hoverBgMuted: getThemeClasses("hover:bg-muted"),
borderAccent: getThemeClasses("border-accent"),
borderSecondary: getThemeClasses("border-secondary"),
currentTheme: currentTheme,
}),
[getThemeClasses, currentTheme],
@ -168,11 +217,28 @@ const Tabs = memo(
return classes.join(" ");
}, [className]);
// Memoize nav classes - add border bottom for separator
const navClasses = useMemo(
() => `px-4 sm:px-6 border-b border-gray-200 dark:border-gray-700`,
[],
);
// Memoize nav classes based on variant
const navClasses = useMemo(() => {
if (variant === "underline") {
return `border-b ${themeClasses.borderSecondary}`;
} else if (variant === "pills") {
return "";
}
return `px-4 sm:px-6 border-b border-gray-200 dark:border-gray-700`;
}, [variant, themeClasses.borderSecondary]);
// Memoize inner nav classes
const innerNavClasses = useMemo(() => {
const classes = ["flex", "overflow-x-auto", "scrollbar-hide"];
if (variant === "underline") {
// No margin adjustment needed for underline
} else if (variant === "pills") {
classes.push("space-x-2", "p-1");
} else {
classes.push("-mb-px", "space-x-4", "sm:space-x-8");
}
return classes.join(" ");
}, [variant]);
// Memoize tab items
const TabItems = useMemo(() => {
@ -189,16 +255,19 @@ const Tabs = memo(
mode={mode}
onTabChange={onTabChange}
themeClasses={themeClasses}
variant={variant}
size={size}
fullWidth={fullWidth}
/>
);
});
}, [tabs, mode, activeTab, onTabChange, themeClasses]);
}, [tabs, mode, activeTab, onTabChange, themeClasses, variant, size, fullWidth]);
return (
<div className={containerClasses}>
<div className={navClasses}>
<nav
className="-mb-px flex space-x-4 sm:space-x-8 overflow-x-auto scrollbar-hide"
className={innerNavClasses}
aria-label="Tabs"
>
{TabItems}
@ -216,7 +285,10 @@ const Tabs = memo(
prevProps.activeTab === nextProps.activeTab &&
prevProps.onTabChange === nextProps.onTabChange &&
prevProps.className === nextProps.className &&
prevProps.mode === nextProps.mode
prevProps.mode === nextProps.mode &&
prevProps.variant === nextProps.variant &&
prevProps.size === nextProps.size &&
prevProps.fullWidth === nextProps.fullWidth
);
},
);

View file

@ -1,19 +1,26 @@
import React, { useState, useEffect, useRef } from "react";
// File: src/components/UIX/TagSelector/TagSelector.jsx
// Card-style tag selector with modal - theme-aware, matches IconPicker design
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useTags } from "../../../services/Services";
import { TagIcon, ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import TagBadge from "../TagBadge/TagBadge";
import { TagIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { useUIXTheme } from "../themes/useUIXTheme";
import Button from "../Button/Button";
// UUID validation regex for tag IDs
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* TagSelector Component
*
* Multi-select dropdown for choosing tags with search functionality.
* Card-style tag selector with modal picker.
* Designed to match the IconPicker card layout.
*
* @param {Object} props
* @param {Array} props.value - Array of selected tag IDs
* @param {Function} props.onChange - Callback (tagIds) => void
* @param {boolean} props.disabled - Whether the selector is disabled
* @param {string} props.label - Label for the selector (default: "Tags")
* @param {string} props.placeholder - Placeholder text
* @param {boolean} props.required - Whether tag selection is required
* @param {string} props.error - Error message to display
*/
@ -22,36 +29,17 @@ export default function TagSelector({
onChange,
disabled = false,
label = "Tags",
placeholder = "Select tags...",
required = false,
error = null
}) {
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags();
const [availableTags, setAvailableTags] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [pendingSelection, setPendingSelection] = useState([]);
useEffect(() => {
loadTags();
}, []);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
const loadTags = async () => {
const loadTags = useCallback(async () => {
try {
setIsLoading(true);
const tags = await tagManager.listTags();
@ -62,133 +50,309 @@ export default function TagSelector({
} finally {
setIsLoading(false);
}
};
}, [tagManager]);
const handleToggleTag = (tagId) => {
if (value.includes(tagId)) {
onChange(value.filter(id => id !== tagId));
} else {
onChange([...value, tagId]);
}
};
useEffect(() => {
loadTags();
}, [loadTags]);
const handleRemoveTag = (tag) => {
onChange(value.filter(id => id !== tag.id));
};
// Validate and filter incoming value prop
const validatedValue = useMemo(() => {
if (!Array.isArray(value)) return [];
return value.filter(id => typeof id === 'string' && UUID_REGEX.test(id));
}, [value]);
const selectedTags = availableTags.filter(tag => value.includes(tag.id));
const filteredTags = availableTags.filter(tag =>
tag.name.toLowerCase().includes(searchTerm.toLowerCase())
const selectedTags = useMemo(() =>
availableTags.filter(tag => validatedValue.includes(tag.id)),
[availableTags, validatedValue]
);
// Truncated display of selected tag names (max 3 shown)
const selectedTagsDisplay = useMemo(() => {
if (selectedTags.length === 0) return "";
if (selectedTags.length <= 3) {
return selectedTags.map(t => t.name).join(", ");
}
return `${selectedTags.slice(0, 3).map(t => t.name).join(", ")} +${selectedTags.length - 3} more`;
}, [selectedTags]);
const handleOpenModal = useCallback(() => {
setPendingSelection([...validatedValue]);
setIsModalOpen(true);
}, [validatedValue]);
const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
setPendingSelection([]);
}, []);
const handleToggleTag = useCallback((tagId) => {
// Validate tag ID format before adding
if (typeof tagId !== 'string' || !UUID_REGEX.test(tagId)) {
console.warn('[TagSelector] Invalid tag ID format:', tagId);
return;
}
setPendingSelection(prev => {
if (prev.includes(tagId)) {
return prev.filter(id => id !== tagId);
} else {
return [...prev, tagId];
}
});
}, []);
const handleConfirm = useCallback(() => {
onChange(pendingSelection);
setIsModalOpen(false);
setPendingSelection([]);
}, [pendingSelection, onChange]);
const handleReset = useCallback(() => {
onChange([]);
setIsModalOpen(false);
setPendingSelection([]);
}, [onChange]);
// Handle backdrop click
const handleBackdropClick = useCallback((e) => {
if (e.target === e.currentTarget) {
handleCloseModal();
}
}, [handleCloseModal]);
// Handle escape key
const handleKeyDown = useCallback((e) => {
if (e.key === "Escape") {
handleCloseModal();
}
}, [handleCloseModal]);
const selectedCount = validatedValue.length;
// Compact mode: when label is empty, render a minimal card without the outer label
const isCompactMode = !label;
return (
<div className="w-full" ref={dropdownRef}>
{/* Label */}
<div className="w-full h-full flex flex-col">
{/* Label - only show when label is provided */}
{label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
<span className="flex items-center">
<TagIcon className="h-4 w-4 mr-2" />
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{!required && (
<span className={`ml-2 text-xs font-normal ${getThemeClasses("text-secondary")}`}>
(Optional)
</span>
)}
</span>
</label>
)}
{/* Selected tags display */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{selectedTags.map(tag => (
<TagBadge
key={tag.id}
tag={tag}
onRemove={!disabled ? handleRemoveTag : undefined}
removable={!disabled}
{/* Card container - compact mode has simpler layout */}
{isCompactMode ? (
// Compact mode: simple button with selected count
<div className="flex flex-col space-y-3">
{selectedCount > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.slice(0, 3).map(tag => (
<span
key={tag.id}
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium`}
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
>
<span
className="w-2 h-2 rounded-full mr-1.5"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</span>
))}
{selectedCount > 3 && (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getThemeClasses("bg-muted")} ${getThemeClasses("text-secondary")}`}>
+{selectedCount - 3} more
</span>
)}
</div>
)}
<div className="flex space-x-2">
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled || isLoading}
variant="secondary"
size="sm"
/>
))}
>
{selectedCount > 0 ? `Change Tags (${selectedCount})` : "Choose Tags"}
</Button>
{selectedCount > 0 && (
<Button
type="button"
onClick={() => onChange([])}
disabled={disabled}
variant="ghost"
size="sm"
>
Clear
</Button>
)}
</div>
</div>
) : (
// Full mode: card with icon preview - matches Icon selector
<div className={`flex items-center p-4 rounded-lg border ${error ? 'border-red-500' : getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} flex-1`}>
{/* Tag preview */}
<div className={`w-12 h-12 rounded-lg flex items-center justify-center mr-4 flex-shrink-0 ${getThemeClasses("bg-card")} border ${getThemeClasses("border-secondary")}`}>
{selectedCount > 0 ? (
<span className={`text-lg font-semibold ${getThemeClasses("text-accent")}`}>
{selectedCount}
</span>
) : (
<TagIcon className={`h-6 w-6 ${getThemeClasses("text-muted")}`} />
)}
</div>
<div className="flex-1">
<p className={`text-sm ${getThemeClasses("text-primary")} font-medium mb-1`}>
{selectedCount > 0
? `${selectedCount} tag${selectedCount !== 1 ? 's' : ''} selected`
: "No tags selected"
}
</p>
<p className={`text-xs ${getThemeClasses("text-secondary")} mb-3 truncate`}>
{selectedCount > 0
? selectedTagsDisplay
: "Add tags to organize your folder"
}
</p>
<div className="flex space-x-2">
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled || isLoading}
variant="secondary"
size="sm"
>
{selectedCount > 0 ? "Change Tags" : "Choose Tags"}
</Button>
{selectedCount > 0 && (
<Button
type="button"
onClick={() => onChange([])}
disabled={disabled}
variant="ghost"
size="sm"
>
Clear
</Button>
)}
</div>
</div>
</div>
)}
{/* Dropdown trigger button */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={`
w-full px-4 py-2 border rounded-lg text-left flex items-center justify-between
${disabled
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-900'
}
${error ? 'border-red-500' : 'border-gray-300'}
focus:outline-none focus:ring-2 focus:ring-blue-500
`}
>
<span className="flex items-center">
<TagIcon className="h-4 w-4 mr-2 text-gray-500" />
<span className="text-sm">
{selectedTags.length > 0
? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected`
: placeholder
}
</span>
</span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
/>
</button>
{/* Error message */}
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
<p className={`mt-1 text-sm ${getThemeClasses("text-error")}`}>{error}</p>
)}
{/* Dropdown menu */}
{isOpen && (
<div className="absolute z-50 mt-2 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-hidden">
{/* Search input */}
<div className="p-3 border-b border-gray-200 sticky top-0 bg-white">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search tags..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={(e) => e.stopPropagation()}
/>
{/* Modal */}
{isModalOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="tag-picker-title"
>
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-xl w-full max-w-lg max-h-[85vh] flex flex-col`}>
{/* Header */}
<div className={`flex items-center justify-between p-4 border-b ${getThemeClasses("border-secondary")}`}>
<h3 id="tag-picker-title" className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>
Choose Tags
</h3>
<Button
onClick={handleCloseModal}
variant="ghost"
size="sm"
aria-label="Close tag picker"
>
<XMarkIcon className="h-5 w-5" />
</Button>
</div>
</div>
{/* Tag list */}
<div className="max-h-60 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-sm text-gray-500">
Loading tags...
</div>
) : filteredTags.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
{searchTerm ? 'No tags found' : 'No tags available'}
</div>
) : (
filteredTags.map(tag => (
<label
key={tag.id}
className="flex items-center px-4 py-2.5 hover:bg-gray-50 cursor-pointer transition-colors"
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className={`p-8 text-center ${getThemeClasses("text-secondary")}`}>
Loading tags...
</div>
) : availableTags.length === 0 ? (
<div className={`p-8 text-center ${getThemeClasses("text-secondary")}`}>
<TagIcon className={`h-12 w-12 mx-auto mb-3 ${getThemeClasses("text-muted")}`} />
<p>No tags available</p>
<p className="text-sm mt-1">Create tags in your profile settings</p>
</div>
) : (
<div className="flex flex-wrap gap-3">
{availableTags.map(tag => {
const isSelected = pendingSelection.includes(tag.id);
return (
<Button
key={tag.id}
type="button"
onClick={() => handleToggleTag(tag.id)}
variant="ghost"
className={`px-4 py-2 rounded-full border-2 transition-all ${
isSelected
? `border-current ${getThemeClasses("bg-accent-light")}`
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
}`}
style={isSelected ? { borderColor: tag.color, color: tag.color } : {}}
>
<span
className="w-3 h-3 rounded-full mr-2 flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className={isSelected ? "" : getThemeClasses("text-primary")}>
{tag.name}
</span>
</Button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className={`flex items-center justify-between p-4 border-t ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} rounded-b-xl`}>
<Button
onClick={handleReset}
variant="ghost"
size="sm"
icon={TagIcon}
>
Clear All
</Button>
<div className="flex space-x-2">
<Button
onClick={handleCloseModal}
variant="secondary"
size="sm"
>
<input
type="checkbox"
checked={value.includes(tag.id)}
onChange={() => handleToggleTag(tag.id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer"
/>
<span
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm text-gray-900 truncate">{tag.name}</span>
</label>
))
)}
Cancel
</Button>
<Button
onClick={handleConfirm}
variant="primary"
size="sm"
>
Confirm ({pendingSelection.length})
</Button>
</div>
</div>
</div>
</div>
)}

View file

@ -37,8 +37,6 @@ import {
} from "../../../../components/UIX";
import {
FolderIcon,
PhotoIcon,
CheckIcon,
HomeIcon,
PlusIcon,
SparklesIcon,
@ -59,29 +57,12 @@ const CollectionCreate = () => {
const [generalError, setGeneralError] = useState("");
const [errors, setErrors] = useState({});
const [collectionName, setCollectionName] = useState("");
const [collectionType, setCollectionType] = useState("folder");
const [customIcon, setCustomIcon] = useState("");
const [showIconPicker, setShowIconPicker] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState([]);
// Memoize collection type options to prevent recreation
const collectionTypeOptions = useMemo(
() => [
{
value: "folder",
label: "Documents",
description: "For files and documents",
icon: FolderIcon,
},
{
value: "album",
label: "Photos",
description: "For images and photos",
icon: PhotoIcon,
},
],
[],
);
// Default collection type - always "folder" (Documents)
const collectionType = "folder";
// Event handlers with useCallback to prevent recreation
// NOTE: UIX Input component passes value directly, not event object
@ -98,17 +79,6 @@ const CollectionCreate = () => {
setGeneralError("");
}, []);
const handleTypeChange = useCallback((value) => {
setCollectionType(value);
// Clear collection_type error - remove key instead of setting to ""
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.collection_type;
return newErrors;
});
setGeneralError("");
}, []);
const handleIconChange = useCallback((value) => {
setCustomIcon(value);
}, []);
@ -185,7 +155,9 @@ const CollectionCreate = () => {
// Pass tag IDs to backend - it will look up and embed the full tag data
if (selectedTagIds.length > 0) {
collectionData.tag_ids = selectedTagIds;
console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length);
if (import.meta.env.DEV) {
console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length);
}
}
const result = await createCollectionManager.createCollection(
@ -278,7 +250,6 @@ const CollectionCreate = () => {
},
[
collectionName,
collectionType,
customIcon,
parentCollectionId,
selectedTagIds,
@ -403,130 +374,71 @@ const CollectionCreate = () => {
)}
</div>
{/* Type Selection */}
<div className="mb-6">
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
Folder Type
</label>
<div className="grid grid-cols-2 gap-3">
{collectionTypeOptions.map((option) => {
const Icon = option.icon;
const isSelected = collectionType === option.value;
return (
<label
key={option.value}
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${
isSelected
? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}`
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
}`}
>
<input
type="radio"
name="type"
value={option.value}
checked={isSelected}
onChange={() => handleTypeChange(option.value)}
className="sr-only"
disabled={isLoading}
/>
<div className="flex items-center">
<Icon
className={`h-5 w-5 mr-3 ${
isSelected
? getThemeClasses("text-primary")
: getThemeClasses("text-muted")
}`}
/>
<div>
<span className={`block font-medium ${getThemeClasses("text-primary")}`}>
{option.label}
</span>
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>
{option.description}
</span>
</div>
</div>
{isSelected && (
<CheckIcon className={`absolute right-3 top-3 h-5 w-5 ${getThemeClasses("text-primary")}`} />
)}
</label>
);
})}
</div>
{errors.collection_type && (
<p className={`mt-1 text-sm ${getThemeClasses("text-error")}`}>
{errors.collection_type}
</p>
)}
</div>
{/* Custom Icon Section */}
<div className="mb-6">
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
<span className="flex items-center">
<SparklesIcon className="h-4 w-4 mr-2" />
Customize Icon
<span className={`ml-2 text-xs font-normal ${getThemeClasses("text-secondary")}`}>
(Optional)
{/* Custom Icon and Tags - Two Column Layout */}
<div className="mb-6 grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">
{/* Custom Icon Section */}
<div className="flex flex-col">
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
<span className="flex items-center">
<SparklesIcon className="h-4 w-4 mr-2" />
Customize Icon
<span className={`ml-2 text-xs font-normal ${getThemeClasses("text-secondary")}`}>
(Optional)
</span>
</span>
</span>
</label>
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
<CollectionIconPreview
customIcon={customIcon}
collectionType={collectionType}
size="xl"
className="mr-4"
/>
<div className="flex-1">
<p className={`text-sm ${getThemeClasses("text-primary")} font-medium mb-1`}>
{customIcon ? "Custom Icon" : "Default Icon"}
</p>
<p className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}>
{customIcon
? "Click to change or reset to default"
: "Add a custom emoji or icon to personalize your folder"}
</p>
<div className="flex space-x-2">
<Button
type="button"
onClick={handleOpenIconPicker}
disabled={isLoading}
variant="secondary"
size="sm"
>
{customIcon ? "Change Icon" : "Choose Icon"}
</Button>
{customIcon && (
</label>
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} flex-1`}>
<CollectionIconPreview
customIcon={customIcon}
collectionType={collectionType}
size="xl"
className="mr-4"
/>
<div className="flex-1">
<p className={`text-sm ${getThemeClasses("text-primary")} font-medium mb-1`}>
{customIcon ? "Custom Icon" : "Default Icon"}
</p>
<p className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}>
{customIcon
? "Click to change or reset to default"
: "Add a custom emoji or icon to personalize your folder"}
</p>
<div className="flex space-x-2">
<Button
type="button"
onClick={() => setCustomIcon("")}
onClick={handleOpenIconPicker}
disabled={isLoading}
variant="ghost"
variant="secondary"
size="sm"
>
Reset
{customIcon ? "Change Icon" : "Choose Icon"}
</Button>
)}
{customIcon && (
<Button
type="button"
onClick={() => setCustomIcon("")}
disabled={isLoading}
variant="ghost"
size="sm"
>
Reset
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Tag Selection */}
<div className="mb-6">
<TagSelector
value={selectedTagIds}
onChange={handleTagChange}
disabled={isLoading}
label="Tags"
placeholder="Select tags to organize this folder..."
/>
<p className={`mt-1 text-xs ${getThemeClasses("text-secondary")}`}>
Tags help you organize and find your folders easily
</p>
{/* Tag Selection */}
<div className="flex flex-col">
<TagSelector
value={selectedTagIds}
onChange={handleTagChange}
disabled={isLoading}
label="Tags"
placeholder="Search tags..."
/>
</div>
</div>
{/* Submit Button */}

View file

@ -10,7 +10,7 @@ import {
} from "../../../../services/Services";
import withPasswordProtection from "../../../../hocs/withPasswordProtection";
import Layout from "../../../../components/Layout/Layout";
import { Button, Alert, useUIXTheme, Breadcrumb, Card, Input, IconDropdown, UserListItem, InfoNotice, Modal } from "../../../../components/UIX";
import { Button, Alert, useUIXTheme, Breadcrumb, Card, Input, IconDropdown, UserListItem, InfoNotice, Modal, Checkbox } from "../../../../components/UIX";
import {
ShareIcon,
UserPlusIcon,
@ -147,7 +147,7 @@ const CollectionShare = () => {
// Force refresh members on initial load to get latest data
loadCollectionMembers(true);
}
}, [collectionId, getCollectionManager, shareCollectionManager]);
}, [collectionId, getCollectionManager, shareCollectionManager, loadCollectionData, loadCollectionMembers]);
// Clear messages after 5 seconds
useEffect(() => {
@ -169,7 +169,7 @@ const CollectionShare = () => {
};
}, []);
const loadCollectionData = async () => {
const loadCollectionData = useCallback(async () => {
try {
// Force refresh to get latest data including owner_email from backend
const result = await getCollectionManager.getCollection(collectionId, true);
@ -189,7 +189,9 @@ const CollectionShare = () => {
setOwnerEmail(currentEmail);
}
} catch (lookupErr) {
console.warn("Could not determine owner email:", lookupErr);
if (import.meta.env.DEV) {
console.warn("Could not determine owner email:", lookupErr);
}
}
}
}
@ -207,9 +209,9 @@ const CollectionShare = () => {
setError("Could not load folder details. Please try again.");
}
}
};
}, [collectionId, getCollectionManager, authManager]);
const loadCollectionMembers = async (forceRefresh = false) => {
const loadCollectionMembers = useCallback(async (forceRefresh = false) => {
setIsLoading(true);
try {
const members = await shareCollectionManager.getCollectionMembers(
@ -259,9 +261,9 @@ const CollectionShare = () => {
} finally {
setIsLoading(false);
}
};
}, [collectionId, shareCollectionManager, success]);
const handleVerifyRecipient = async () => {
const handleVerifyRecipient = useCallback(async () => {
// Clear any pending debounce
if (verifyDebounceRef.current) {
clearTimeout(verifyDebounceRef.current);
@ -340,10 +342,10 @@ const CollectionShare = () => {
setIsVerifying(false);
}
}, 300);
};
}, [recipientEmail, authManager, userLookupManager]);
// Handle sending invitation email to non-registered user
const handleSendInvite = async () => {
const handleSendInvite = useCallback(async () => {
if (!recipientEmail.trim()) {
setError("Please enter an email address");
return;
@ -389,9 +391,9 @@ const CollectionShare = () => {
} finally {
setIsSendingInvite(false);
}
};
}, [recipientEmail, inviteEmailService]);
const handleShareCollection = async () => {
const handleShareCollection = useCallback(async () => {
if (!recipientVerified || !recipientId || !recipientEmail) {
setError("Please verify the recipient first");
return;
@ -468,14 +470,14 @@ const CollectionShare = () => {
} finally {
setIsLoading(false);
}
};
}, [recipientVerified, recipientId, recipientEmail, gdprConsent, permissionLevel, collectionId, shareCollectionManager, loadCollectionMembers]);
const handleRemoveMember = (memberId, memberEmail) => {
const handleRemoveMember = useCallback((memberId, memberEmail) => {
setMemberToRemove({ id: memberId, email: memberEmail });
setShowRemoveModal(true);
};
}, []);
const confirmRemoveMember = async () => {
const confirmRemoveMember = useCallback(async () => {
if (!memberToRemove) return;
setIsLoading(true);
@ -522,9 +524,9 @@ const CollectionShare = () => {
setIsLoading(false);
setMemberToRemove(null);
}
};
}, [memberToRemove, collectionId, shareCollectionManager, dashboardManager, loadCollectionMembers]);
const handleEmailChange = (newEmail) => {
const handleEmailChange = useCallback((newEmail) => {
// UIX Input onChange receives value directly, not event
setRecipientEmail(newEmail);
@ -551,9 +553,9 @@ const CollectionShare = () => {
) {
setError("You cannot share a folder with yourself");
}
};
}, [recipientVerified, userNotFound, inviteSuccess, error, authManager]);
const formatTimeAgo = (dateString) => {
const formatTimeAgo = useCallback((dateString) => {
if (!dateString) return "";
const date = new Date(dateString);
const now = new Date();
@ -568,12 +570,12 @@ const CollectionShare = () => {
if (diffInDays === 1) return "Yesterday";
if (diffInDays < 7) return `${diffInDays} days ago`;
return date.toLocaleDateString();
};
}, []);
const currentUserEmail = authManager?.getCurrentUserEmail();
// Build breadcrumb
const breadcrumbItems = [
// Build breadcrumb - memoized to prevent recreation on every render
const breadcrumbItems = useMemo(() => [
{
label: "My Files",
to: "/file-manager",
@ -588,7 +590,7 @@ const CollectionShare = () => {
label: "Share",
isActive: true,
},
];
], [collection?.name, collectionId]);
return (
<Layout>
@ -635,10 +637,10 @@ const CollectionShare = () => {
</div>
{/* Content */}
<div className="px-6 pb-6 pt-5">
<div className="space-y-6 max-w-4xl mx-auto">
<div className="px-2 pb-2 pt-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Add People Section */}
<div className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}>
<div className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-4 flex flex-col`}>
<div className="flex items-center mb-6">
<UserPlusIcon className={`h-6 w-6 ${getThemeClasses("text-primary")} mr-3`} />
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>Add People</h2>
@ -746,12 +748,11 @@ const CollectionShare = () => {
{/* GDPR Consent Checkbox */}
{recipientVerified && (
<div className={`p-4 ${getThemeClasses("bg-info-light")} border ${getThemeClasses("border-info")} rounded-lg`}>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
<div className="flex items-start space-x-3">
<Checkbox
checked={gdprConsent}
onChange={(e) => setGdprConsent(e.target.checked)}
className={`mt-1 h-4 w-4 rounded ${getThemeClasses("checkbox-focus")}`}
onChange={setGdprConsent}
className="mt-0.5"
/>
<div className="flex-1">
<p className={`text-sm font-medium ${getThemeClasses("text-info")}`}>
@ -761,7 +762,7 @@ const CollectionShare = () => {
By checking this box, you consent to processing the recipient's email address and user ID for the purpose of secure folder sharing. This data will be retained for 90 days after access is revoked.
</p>
</div>
</label>
</div>
</div>
)}
@ -794,7 +795,7 @@ const CollectionShare = () => {
</div>
{/* Current Members */}
<div className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}>
<div className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-4 flex flex-col`}>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<UserGroupIcon className={`h-6 w-6 ${getThemeClasses("text-primary")} mr-3`} />
@ -892,7 +893,10 @@ const CollectionShare = () => {
)}
</div>
{/* Security Notice */}
</div>
{/* Security Notice - Full width below the grid */}
<div className="mt-4">
<InfoNotice
variant="security"
title="End-to-End Encryption"

View file

@ -609,16 +609,21 @@ const FileUpload = () => {
});
}, 1500);
} else {
// No pre-selected collection, but user selected one from dropdown
// Navigate to the selected collection folder
setTimeout(async () => {
await clearAllRelevantCaches();
triggerDashboardRefresh();
navigate("/dashboard", {
navigate(`/file-manager/collections/${selectedCollection}`, {
state: {
refreshDashboard: true,
uploadCompleted: true,
refresh: true,
refreshFiles: true,
forceFileRefresh: true,
uploadedFileCount: successCount,
uploadTimestamp: Date.now(),
cacheCleared: true,
refreshDashboard: true,
},
replace: false,
});
@ -802,7 +807,6 @@ const FileUpload = () => {
{!preSelectedCollectionId && (
<div className="flex items-end gap-3">
<Select
label="Select destination folder"
value={selectedCollection}
onChange={setSelectedCollection}
options={collectionOptions}
@ -846,75 +850,71 @@ const FileUpload = () => {
</div>
)}
{/* Drop Zone */}
<div
role="button"
tabIndex={0}
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
aria-disabled={isUploading}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !isUploading && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${
isDragging
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
: `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md`
} ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`}
>
{/* Drop Zone and Files List - Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Drop Zone */}
<div
className={`h-16 w-16 rounded-2xl mx-auto mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${
role="button"
tabIndex={0}
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
aria-disabled={isUploading}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !isUploading && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-8 text-center cursor-pointer transition-all duration-300 flex flex-col items-center justify-center ${
isDragging
? getThemeClasses("bg-gradient-secondary") +
" scale-110"
: getThemeClasses("bg-gradient-secondary")
}`}
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
: `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md`
} ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`}
>
<CloudArrowUpIcon className="h-8 w-8 text-white" />
<div
className={`h-16 w-16 rounded-2xl mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${
isDragging
? getThemeClasses("bg-gradient-secondary") +
" scale-110"
: getThemeClasses("bg-gradient-secondary")
}`}
>
<CloudArrowUpIcon className="h-8 w-8 text-white" />
</div>
<h3
className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}
>
{isDragging ? "Drop files here" : "Upload your files"}
</h3>
<p className={`text-xs ${getThemeClasses("text-muted")}`}>
Maximum file size: 5GB All file types supported
</p>
{preSelectedCollectionId &&
preSelectedCollectionInfo?.name && (
<p
className={`text-sm font-bold mt-3 ${getThemeClasses("text-primary")}`}
>
Uploading to: {preSelectedCollectionInfo.name}
</p>
)}
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
disabled={isUploading}
className="sr-only"
aria-label="Select files to upload"
id="file-upload-input"
/>
</div>
<h3
className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}
>
{isDragging ? "Drop files here" : "Upload your files"}
</h3>
<p
className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}
>
Drag and drop or click to browse
</p>
<p className={`text-xs ${getThemeClasses("text-muted")}`}>
Maximum file size: 5GB All file types supported
</p>
{preSelectedCollectionId &&
preSelectedCollectionInfo?.name && (
<p
className={`text-sm font-bold mt-3 ${getThemeClasses("text-primary")}`}
>
Uploading to: {preSelectedCollectionInfo.name}
</p>
)}
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
disabled={isUploading}
className="sr-only"
aria-label="Select files to upload"
id="file-upload-input"
/>
</div>
{/* Files List */}
{files.length > 0 && (
{/* Files List - Always visible */}
<div
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm`}
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm flex flex-col`}
>
<div
className={`p-4 border-b ${getThemeClasses("border-secondary")}`}
@ -923,123 +923,139 @@ const FileUpload = () => {
<h3
className={`font-semibold ${getThemeClasses("text-primary")}`}
>
{files.length} file{files.length !== 1 ? "s" : ""}{" "}
selected
{files.length > 0
? `${files.length} file${files.length !== 1 ? "s" : ""} selected`
: "Files Selected"
}
</h3>
<span
className={`text-sm ${getThemeClasses("text-secondary")}`}
>
Total: {formatFileSize(totalSize)}
</span>
{files.length > 0 && (
<span
className={`text-sm ${getThemeClasses("text-secondary")}`}
>
Total: {formatFileSize(totalSize)}
</span>
)}
</div>
</div>
<div
role="list"
aria-label="Selected files for upload"
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
>
{files.map((file) => (
<div
key={file.id}
role="listitem"
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<div
className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-muted")} flex items-center justify-center`}
>
{getFileIcon(file)}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4
className={`text-sm font-medium truncate pr-2 ${getThemeClasses("text-primary")}`}
{files.length > 0 ? (
<div
role="list"
aria-label="Selected files for upload"
className={`divide-y ${getThemeClasses("border-secondary")} max-h-72 overflow-y-auto flex-1`}
>
{files.map((file) => (
<div
key={file.id}
role="listitem"
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<div
className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-muted")} flex items-center justify-center`}
>
{file.name}
</h4>
<span
className={`text-xs ${getThemeClasses("text-muted")} flex-shrink-0`}
>
{formatFileSize(file.size)}
</span>
{getFileIcon(file)}
</div>
</div>
{/* Progress bar for uploading files */}
{file.status === "uploading" &&
uploadProgress[file.id] !== undefined && (
<div className="mt-2">
<div
className={`w-full ${getThemeClasses("bg-muted")} rounded-full h-2 overflow-hidden`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4
className={`text-sm font-medium truncate pr-2 ${getThemeClasses("text-primary")}`}
>
{file.name}
</h4>
<span
className={`text-xs ${getThemeClasses("text-muted")} flex-shrink-0`}
>
{formatFileSize(file.size)}
</span>
</div>
{/* Progress bar for uploading files */}
{file.status === "uploading" &&
uploadProgress[file.id] !== undefined && (
<div className="mt-2">
<div
className={`h-full ${getThemeClasses("bg-gradient-secondary")} transition-all duration-300`}
style={{
width: `${uploadProgress[file.id]}%`,
}}
></div>
className={`w-full ${getThemeClasses("bg-muted")} rounded-full h-2 overflow-hidden`}
>
<div
className={`h-full ${getThemeClasses("bg-gradient-secondary")} transition-all duration-300`}
style={{
width: `${uploadProgress[file.id]}%`,
}}
></div>
</div>
<p
className={`text-xs ${getThemeClasses("text-info")} mt-1`}
>
{uploadProgress[file.id]}% uploaded
</p>
</div>
<p
className={`text-xs ${getThemeClasses("text-info")} mt-1`}
>
{uploadProgress[file.id]}% uploaded
</p>
)}
{file.status === "error" && file.error && (
<p
className={`text-xs ${getThemeClasses("text-error")} mt-1 truncate`}
>
{file.error}
</p>
)}
</div>
<div className="flex-shrink-0">
{file.status === "pending" && (
<Button
onClick={(e) => {
e.stopPropagation();
removeFile(file.id);
}}
disabled={isUploading}
variant="ghost"
size="sm"
aria-label={`Remove ${file.name}`}
className={`${getThemeClasses("hover:text-error")}`}
>
<XMarkIcon className="h-5 w-5" />
</Button>
)}
{file.status === "uploading" && (
<div className="p-2">
<div
className={`animate-spin rounded-full h-5 w-5 border-2 ${getThemeClasses("border-primary")} border-t-transparent`}
></div>
</div>
)}
{file.status === "error" && file.error && (
<p
className={`text-xs ${getThemeClasses("text-error")} mt-1 truncate`}
>
{file.error}
</p>
)}
</div>
<div className="flex-shrink-0">
{file.status === "pending" && (
<Button
onClick={(e) => {
e.stopPropagation();
removeFile(file.id);
}}
disabled={isUploading}
variant="ghost"
size="sm"
aria-label={`Remove ${file.name}`}
className={`${getThemeClasses("hover:text-error")}`}
>
<XMarkIcon className="h-5 w-5" />
</Button>
)}
{file.status === "uploading" && (
<div className="p-2">
<div
className={`animate-spin rounded-full h-5 w-5 border-2 ${getThemeClasses("border-primary")} border-t-transparent`}
></div>
</div>
)}
{file.status === "complete" && (
<div className="p-2">
<CheckCircleIcon
className={`h-5 w-5 ${getThemeClasses("text-success")}`}
/>
</div>
)}
{file.status === "error" && (
<div className="p-2">
<ExclamationTriangleIcon
className={`h-5 w-5 ${getThemeClasses("text-error")}`}
/>
</div>
)}
{file.status === "complete" && (
<div className="p-2">
<CheckCircleIcon
className={`h-5 w-5 ${getThemeClasses("text-success")}`}
/>
</div>
)}
{file.status === "error" && (
<div className="p-2">
<ExclamationTriangleIcon
className={`h-5 w-5 ${getThemeClasses("text-error")}`}
/>
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={`flex-1 flex items-center justify-center p-8 ${getThemeClasses("text-secondary")}`}>
<div className="text-center">
<DocumentIcon className={`h-12 w-12 mx-auto mb-3 ${getThemeClasses("text-muted")}`} />
<p className="text-sm">No files selected yet</p>
<p className={`text-xs mt-1 ${getThemeClasses("text-muted")}`}>
Select files from the upload area
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Sidebar - only show as separate column when collection is pre-selected */}
@ -1222,10 +1238,10 @@ const FileUpload = () => {
{/* Options section - show below content when no pre-selected collection */}
{!preSelectedCollectionId && files.length > 0 && (
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6 items-stretch">
{/* Upload Status */}
<div
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6 flex flex-col`}
>
<h3
className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`}
@ -1305,7 +1321,7 @@ const FileUpload = () => {
{/* Tag Selection */}
{pendingFiles.length > 0 && (
<div
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6 flex flex-col`}
>
<div className="flex items-center gap-2 mb-4">
<TagIcon
@ -1322,20 +1338,22 @@ const FileUpload = () => {
>
Tags will be applied to all uploaded files
</p>
<TagSelector
value={selectedTagIds}
onChange={setSelectedTagIds}
disabled={isUploading}
label=""
placeholder="Select tags..."
/>
<div className="flex-1 flex flex-col justify-end">
<TagSelector
value={selectedTagIds}
onChange={setSelectedTagIds}
disabled={isUploading}
label=""
placeholder="Select tags..."
/>
</div>
</div>
)}
{/* Consent & Security Notice */}
{pendingFiles.length > 0 && (
<div
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")} h-fit`}
className={`p-6 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")} flex flex-col justify-center`}
>
<div className="flex items-start space-x-3">
<Checkbox