Merged latest code by @rodolfomartinez to code by @bartmika.

This commit is contained in:
Bartlomiej Mika 2025-12-05 15:44:29 -05:00
commit 3bf89fe2fa
7 changed files with 828 additions and 664 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View file

@ -1,6 +1,7 @@
// File: src/components/UIX/IconPicker/IconPicker.jsx // File: src/components/UIX/IconPicker/IconPicker.jsx
// Icon picker component for selecting emojis or predefined icons for collections // UIX version - Icon picker component for selecting emojis or predefined icons
import React, { useState } from "react"; // Theme-aware with proper UIX component usage
import React, { useState, useCallback, useMemo } from "react";
import { import {
XMarkIcon, XMarkIcon,
FolderIcon, FolderIcon,
@ -41,6 +42,9 @@ import {
TrophyIcon, TrophyIcon,
UserGroupIcon, UserGroupIcon,
} from "@heroicons/react/24/outline"; } 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 // Predefined icons with their identifiers and labels
const PREDEFINED_ICONS = [ const PREDEFINED_ICONS = [
@ -84,103 +88,58 @@ const PREDEFINED_ICONS = [
// Comprehensive emoji collection organized by logical categories // Comprehensive emoji collection organized by logical categories
const EMOJI_CATEGORIES = { const EMOJI_CATEGORIES = {
// Most commonly used for file organization "Folders & Work": [
"Files & Folders": [
"📁", "📂", "🗂️", "📑", "📄", "📃", "📋", "📝", "✏️", "🖊️", "📁", "📂", "🗂️", "📑", "📄", "📃", "📋", "📝", "✏️", "🖊️",
"📎", "📌", "🔖", "🏷️", "📰", "🗃️", "🗄️", "📦", "📥", "📤", "📎", "📌", "🔖", "🏷️", "📰", "🗃️", "🗄️", "📦", "📥", "📤",
],
// Work, business, and professional
"Work & Business": [
"💼", "🏢", "🏛️", "🏦", "💰", "💵", "💳", "🧾", "📊", "📈", "💼", "🏢", "🏛️", "🏦", "💰", "💵", "💳", "🧾", "📊", "📈",
"📉", "💹", "🗓️", "📅", "⏰", "⌚", "🖥️", "💻", "⌨️", "🖨️", "📉", "💹", "🗓️", "📅", "⏰", "⌚", "🖥️", "💻", "⌨️", "🖨️",
], ],
"Tech & Media": [
// Technology, electronics, and connectivity
"Tech & Devices": [
"📱", "📲", "☎️", "📞", "📟", "📠", "🔌", "🔋", "💾", "💿", "📱", "📲", "☎️", "📞", "📟", "📠", "🔌", "🔋", "💾", "💿",
"📀", "🖱️", "🖲️", "🎮", "🕹️", "🛜", "📡", "📺", "📻", "🎙️", "📀", "🖱️", "🖲️", "🎮", "🕹️", "🛜", "📡", "📺", "📻", "🎙️",
],
// Media, entertainment, and creativity
"Media & Creative": [
"📷", "📸", "📹", "🎥", "🎬", "🎞️", "📽️", "🎵", "🎶", "🎤", "📷", "📸", "📹", "🎥", "🎬", "🎞️", "📽️", "🎵", "🎶", "🎤",
"🎧", "🎼", "🎹", "🎸", "🥁", "🎨", "🖼️", "🎭", "🎪", "🎠", "🎧", "🎼", "🎹", "🎸", "🥁", "🎨", "🖼️", "🎭", "🎪", "🎠",
"🔒", "🔓", "🔐", "🔑", "🗝️", "🛡️", "⚔️", "🔫", "🚨", "🚔",
"👮", "🕵️", "🦺", "🧯", "🪖", "⛑️", "🔏", "👁️‍🗨️", "🛂",
"💬", "💭", "🗨️", "🗯️", "📧", "📨", "📩", "📮", "📪", "📫",
"📬", "📭", "✉️", "💌", "📯", "🔔", "🔕", "📢", "📣", "🗣️",
], ],
// Education, learning, and research
"Education & Science": [ "Education & Science": [
"📚", "📖", "📕", "📗", "📘", "📙", "🎓", "🏫", "✍️", "📐", "📚", "📖", "📕", "📗", "📘", "📙", "🎓", "🏫", "✍️", "📐",
"📏", "🔬", "🔭", "🧪", "🧫", "🧬", "🔍", "🔎", "💡", "📡", "📏", "🔬", "🔭", "🧪", "🧫", "🧬", "🔍", "🔎", "💡", "📡",
], ],
// Communication and social
"Communication": [
"💬", "💭", "🗨️", "🗯️", "📧", "📨", "📩", "📮", "📪", "📫",
"📬", "📭", "✉️", "💌", "📯", "🔔", "🔕", "📢", "📣", "🗣️",
],
// Home, family, and personal life
"Home & Life": [ "Home & Life": [
"🏠", "🏡", "🏘️", "🛏️", "🛋️", "🪑", "🚿", "🛁", "🧹", "🧺", "🏠", "🏡", "🏘️", "🛏️", "🛋️", "🪑", "🚿", "🛁", "🧹", "🧺",
"👨‍👩‍👧‍👦", "👪", "❤️", "💕", "💝", "💖", "🧸", "🎁", "🎀", "🎈", "👨‍👩‍👧‍👦", "👪", "❤️", "💕", "💝", "💖", "🧸", "🎁", "🎀", "🎈",
], ],
"Health & Food": [
// Health, fitness, and wellness
"Health & Wellness": [
"🏥", "💊", "💉", "🩺", "🩹", "🩼", "♿", "🧘", "🏃", "🚴", "🏥", "💊", "💉", "🩺", "🩹", "🩼", "♿", "🧘", "🏃", "🚴",
"🏋️", "🤸", "⚕️", "🩸", "🧠", "👁️", "🦷", "💪", "🧬", "🍎", "🏋️", "🤸", "⚕️", "🩸", "🧠", "👁️", "🦷", "💪", "🧬", "🍎",
],
// Food and beverages
"Food & Drinks": [
"🍕", "🍔", "🍟", "🌮", "🌯", "🍜", "🍝", "🍣", "🍱", "🥗", "🍕", "🍔", "🍟", "🌮", "🌯", "🍜", "🍝", "🍣", "🍱", "🥗",
"🍰", "🎂", "🧁", "🍩", "🍪", "☕", "🍵", "🥤", "🍷", "🍺", "🍰", "🎂", "🧁", "🍩", "🍪", "☕", "🍵", "🥤", "🍷", "🍺",
], ],
// Travel, transportation, and places
"Travel & Places": [ "Travel & Places": [
"✈️", "🚀", "🛸", "🚁", "🚂", "🚗", "🚕", "🚌", "🚢", "⛵", "✈️", "🚀", "🛸", "🚁", "🚂", "🚗", "🚕", "🚌", "🚢", "⛵",
"🗺️", "🧭", "🏖️", "🏔️", "🏕️", "🗽", "🗼", "🏰", "⛺", "🌍", "🗺️", "🧭", "🏖️", "🏔️", "🏕️", "🗽", "🗼", "🏰", "⛺", "🌍",
], ],
// Sports, games, and hobbies
"Sports & Hobbies": [ "Sports & Hobbies": [
"⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏓", "🏸", "🎯", "🎱", "⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏓", "🏸", "🎯", "🎱",
"🎳", "🏆", "🥇", "🥈", "🥉", "🎲", "♟️", "🧩", "🎰", "🎮", "🎳", "🏆", "🥇", "🥈", "🥉", "🎲", "♟️", "🧩", "🎰", "🎮",
], ],
"Nature & Animals": [
// Nature, weather, and environment
"Nature & Weather": [
"🌸", "🌺", "🌻", "🌹", "🌷", "🌴", "🌲", "🍀", "🌿", "🍃", "🌸", "🌺", "🌻", "🌹", "🌷", "🌴", "🌲", "🍀", "🌿", "🍃",
"☀️", "🌙", "⭐", "🌈", "☁️", "🌧️", "❄️", "🔥", "💧", "🌊", "☀️", "🌙", "⭐", "🌈", "☁️", "🌧️", "❄️", "🔥", "💧", "🌊",
],
// Animals and pets
"Animals": [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🦁", "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🦁",
"🐯", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦋", "🐝", "🐯", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦋", "🐝",
], ],
// Symbols, signs, and indicators
"Symbols & Status": [ "Symbols & Status": [
"✅", "❌", "⭕", "❗", "❓", "💯", "🔴", "🟠", "🟡", "🟢", "✅", "❌", "⭕", "❗", "❓", "💯", "🔴", "🟠", "🟡", "🟢",
"🔵", "🟣", "⚫", "⚪", "🔶", "🔷", "💠", "🔘", "🏁", "🚩", "🔵", "🟣", "⚫", "⚪", "🔶", "🔷", "💠", "🔘", "🏁", "🚩",
], ],
// Security and protection
"Security": [
"🔒", "🔓", "🔐", "🔑", "🗝️", "🛡️", "⚔️", "🔫", "🚨", "🚔",
"👮", "🕵️", "🦺", "🧯", "🪖", "⛑️", "🔏", "🔒", "👁️‍🗨️", "🛂",
],
// Time and scheduling
"Time & Planning": [ "Time & Planning": [
"⏰", "⏱️", "⏲️", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "⏰", "⏱️", "⏲️", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖",
"📅", "📆", "🗓️", "⌛", "⏳", "🔜", "🔙", "🔚", "🔛", "🔝", "📅", "📆", "🗓️", "⌛", "⏳", "🔜", "🔙", "🔚", "🔛", "🔝",
], ],
// Celebration and events
"Celebration": [ "Celebration": [
"🎉", "🎊", "🎂", "🎁", "🎀", "🎈", "🎄", "🎃", "🎆", "🎇", "🎉", "🎊", "🎂", "🎁", "🎀", "🎈", "🎄", "🎃", "🎆", "🎇",
"✨", "💫", "🌟", "⭐", "🏅", "🎖️", "🏆", "🥳", "🎯", "🎪", "✨", "💫", "🌟", "⭐", "🏅", "🎖️", "🏆", "🥳", "🎯", "🎪",
@ -188,146 +147,181 @@ const EMOJI_CATEGORIES = {
}; };
const IconPicker = ({ value, onChange, onClose, isOpen }) => { const IconPicker = ({ value, onChange, onClose, isOpen }) => {
const { getThemeClasses } = useUIXTheme();
const [activeTab, setActiveTab] = useState("emoji"); 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; if (!isOpen) return null;
const handleSelect = (iconValue) => { // Check if current value is an icon or emoji (with type safety)
onChange(iconValue); const safeValue = typeof value === 'string' ? value : '';
onClose(); const isIconSelected = safeValue.startsWith("icon:");
}; const selectedIconId = isIconSelected ? safeValue.replace("icon:", "") : null;
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);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
<div className="bg-white rounded-xl shadow-xl max-w-2xl lg:max-w-3xl w-full max-h-[85vh] flex flex-col"> 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 */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200"> <div className={`flex items-center justify-between p-4 border-b ${getThemeClasses("border-secondary")}`}>
<h3 className="text-lg font-semibold text-gray-900">Choose Icon</h3> <h3 id="icon-picker-title" className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>
<button Choose Icon
</h3>
<Button
onClick={onClose} 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" /> <XMarkIcon className="h-5 w-5" />
</button> </Button>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-gray-200"> <Tabs
<button tabs={tabsConfig}
onClick={() => setActiveTab("emoji")} activeTab={activeTab}
className={`flex-1 py-3 text-sm font-medium transition-colors ${ onTabChange={handleTabChange}
activeTab === "emoji" variant="underline"
? "text-red-800 border-b-2 border-red-800" size="sm"
: "text-gray-500 hover:text-gray-700" fullWidth
}`} />
>
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>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{activeTab === "emoji" ? ( {activeTab === "emoji" ? (
<div className="flex gap-4"> <div className="flex gap-4">
{/* Category Sidebar */} {/* 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"> <div className="sticky top-0 space-y-1 max-h-[60vh] overflow-y-auto">
{categoryKeys.map((category) => ( {categoryKeys.map((category) => (
<button <Button
key={category} key={category}
onClick={() => setActiveEmojiCategory(category)} onClick={() => handleCategoryChange(category)}
className={`w-full text-left px-3 py-2 text-xs font-medium rounded-lg transition-colors ${ variant={activeEmojiCategory === category ? "ghost" : "ghost"}
size="sm"
className={`w-full justify-start text-xs ${
activeEmojiCategory === category activeEmojiCategory === category
? "bg-red-100 text-red-800" ? `${getThemeClasses("bg-accent-light")} ${getThemeClasses("text-accent")}`
: "text-gray-600 hover:bg-gray-100" : ""
}`} }`}
> >
{category} {category}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
{/* Emoji Grid */} {/* Emoji Grid */}
<div className="flex-1"> <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} {activeEmojiCategory}
</h4> </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) => ( {EMOJI_CATEGORIES[activeEmojiCategory].map((emoji, index) => (
<button <Button
key={`${emoji}-${index}`} key={`${emoji}-${index}`}
onClick={() => handleSelect(emoji)} onClick={() => handleSelect(emoji)}
className={`p-2 text-2xl rounded-lg hover:bg-gray-100 transition-colors ${ variant="ghost"
value === emoji ? "bg-red-100 ring-2 ring-red-500" : "" size="sm"
className={`p-2 text-2xl ${
safeValue === emoji ? `${getThemeClasses("bg-accent-light")} ring-2 ${getThemeClasses("ring-accent")}` : ""
}`} }`}
title={emoji} title={emoji}
> >
{emoji} {emoji}
</button> </Button>
))} ))}
</div> </div>
</div> </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 }) => ( {PREDEFINED_ICONS.map(({ id, Icon, label }) => (
<button <Button
key={id} key={id}
onClick={() => handleSelect(`icon:${id}`)} onClick={() => handleSelect(`icon:${id}`)}
className={`flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 transition-colors ${ variant="ghost"
selectedIconId === id ? "bg-red-100 ring-2 ring-red-500" : "" className={`flex flex-col items-center p-3 h-auto ${
selectedIconId === id ? `${getThemeClasses("bg-accent-light")} ring-2 ${getThemeClasses("ring-accent")}` : ""
}`} }`}
title={label} title={label}
> >
<Icon className="h-6 w-6 text-gray-700" /> <Icon className={`h-6 w-6 ${getThemeClasses("text-primary")}`} />
<span className="text-xs text-gray-500 mt-1 truncate w-full text-center"> <span className={`text-xs ${getThemeClasses("text-secondary")} mt-1 truncate w-full text-center`}>
{label} {label}
</span> </span>
</button> </Button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50 rounded-b-xl"> <div className={`flex items-center justify-between p-4 border-t ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} rounded-b-xl`}>
<button <Button
onClick={handleReset} 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 Reset to Default
</button> </Button>
<button <Button
onClick={onClose} 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 Cancel
</button> </Button>
</div> </div>
</div> </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 {function} onTabChange - Tab change handler for callback mode
* @param {string} className - Additional CSS classes * @param {string} className - Additional CSS classes
* @param {string} mode - 'callback' for onClick behavior, 'routing' for href navigation (default: 'callback') * @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 // Tab Item Component - Separated for performance
const TabItem = memo( 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 // Memoize click handler
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
// Use tab's own onClick if provided, otherwise use parent onTabChange // Use tab's own onClick if provided, otherwise use parent onTabChange
@ -28,30 +31,66 @@ const TabItem = memo(
} }
}, [tab, onTabChange]); }, [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 // Memoize icon element
const IconElement = useMemo(() => { const IconElement = useMemo(() => {
if (!tab.icon) return null; if (!tab.icon) return null;
return <tab.icon className="w-4 sm:w-5 h-4 sm:h-5 ml-1" />; const iconSize = size === "sm" ? "w-4 h-4" : size === "lg" ? "w-5 h-5" : "w-4 sm:w-5 h-4 sm:h-5";
}, [tab.icon]); 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 tabClasses = useMemo(() => {
const baseClasses = [ const baseClasses = [
"border-b-4",
"border-transparent",
"py-3",
"sm:py-4",
"px-1",
"text-base",
"sm:text-lg",
"font-medium", "font-medium",
"whitespace-nowrap", "whitespace-nowrap",
"inline-flex", "inline-flex",
"items-center", "items-center",
"justify-center",
"transition-colors", "transition-colors",
"duration-200", "duration-200",
sizeClasses[size] || sizeClasses.md,
]; ];
if (fullWidth) {
baseClasses.push("flex-1");
}
// 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 {
// Default variant - original behavior
baseClasses.push("border-b-4", "border-transparent");
// Determine hover border color based on theme // Determine hover border color based on theme
const hoverBorderClass = { const hoverBorderClass = {
red: "hover:border-red-500", red: "hover:border-red-500",
@ -62,19 +101,18 @@ const TabItem = memo(
}[themeClasses.currentTheme] || "hover:border-gray-500"; }[themeClasses.currentTheme] || "hover:border-gray-500";
if (isActive) { if (isActive) {
// Active tab: just show primary text, no border
baseClasses.push(themeClasses.textPrimary); baseClasses.push(themeClasses.textPrimary);
} else { } else {
// Inactive tab: secondary text with hover effect
baseClasses.push( baseClasses.push(
themeClasses.textSecondary, themeClasses.textSecondary,
themeClasses.hoverTextPrimary, themeClasses.hoverTextPrimary,
hoverBorderClass hoverBorderClass
); );
} }
}
return baseClasses.filter(Boolean).join(" "); return baseClasses.filter(Boolean).join(" ");
}, [isActive, themeClasses]); }, [isActive, themeClasses, variant, size, fullWidth]);
// Routing mode with active state (non-interactive) // Routing mode with active state (non-interactive)
if (mode === "routing" && isActive) { if (mode === "routing" && isActive) {
@ -130,7 +168,10 @@ const TabItem = memo(
prevProps.isActive === nextProps.isActive && prevProps.isActive === nextProps.isActive &&
prevProps.mode === nextProps.mode && prevProps.mode === nextProps.mode &&
prevProps.onTabChange === nextProps.onTabChange && 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, onTabChange,
className = "", className = "",
mode = "callback", mode = "callback",
variant = "default",
size = "md",
fullWidth = false,
}) { }) {
const { getThemeClasses, currentTheme } = useUIXTheme(); const { getThemeClasses, currentTheme } = useUIXTheme();
@ -153,7 +197,12 @@ const Tabs = memo(
() => ({ () => ({
textPrimary: getThemeClasses("text-primary"), textPrimary: getThemeClasses("text-primary"),
textSecondary: getThemeClasses("text-secondary"), textSecondary: getThemeClasses("text-secondary"),
textAccent: getThemeClasses("text-accent"),
hoverTextPrimary: getThemeClasses("hover:text-primary"), 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, currentTheme: currentTheme,
}), }),
[getThemeClasses, currentTheme], [getThemeClasses, currentTheme],
@ -168,11 +217,28 @@ const Tabs = memo(
return classes.join(" "); return classes.join(" ");
}, [className]); }, [className]);
// Memoize nav classes - add border bottom for separator // Memoize nav classes based on variant
const navClasses = useMemo( const navClasses = useMemo(() => {
() => `px-4 sm:px-6 border-b border-gray-200 dark:border-gray-700`, 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 // Memoize tab items
const TabItems = useMemo(() => { const TabItems = useMemo(() => {
@ -189,16 +255,19 @@ const Tabs = memo(
mode={mode} mode={mode}
onTabChange={onTabChange} onTabChange={onTabChange}
themeClasses={themeClasses} themeClasses={themeClasses}
variant={variant}
size={size}
fullWidth={fullWidth}
/> />
); );
}); });
}, [tabs, mode, activeTab, onTabChange, themeClasses]); }, [tabs, mode, activeTab, onTabChange, themeClasses, variant, size, fullWidth]);
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
<div className={navClasses}> <div className={navClasses}>
<nav <nav
className="-mb-px flex space-x-4 sm:space-x-8 overflow-x-auto scrollbar-hide" className={innerNavClasses}
aria-label="Tabs" aria-label="Tabs"
> >
{TabItems} {TabItems}
@ -216,7 +285,10 @@ const Tabs = memo(
prevProps.activeTab === nextProps.activeTab && prevProps.activeTab === nextProps.activeTab &&
prevProps.onTabChange === nextProps.onTabChange && prevProps.onTabChange === nextProps.onTabChange &&
prevProps.className === nextProps.className && 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 { useTags } from "../../../services/Services";
import { TagIcon, ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { TagIcon, XMarkIcon } from "@heroicons/react/24/outline";
import TagBadge from "../TagBadge/TagBadge"; 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 * 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 {Object} props
* @param {Array} props.value - Array of selected tag IDs * @param {Array} props.value - Array of selected tag IDs
* @param {Function} props.onChange - Callback (tagIds) => void * @param {Function} props.onChange - Callback (tagIds) => void
* @param {boolean} props.disabled - Whether the selector is disabled * @param {boolean} props.disabled - Whether the selector is disabled
* @param {string} props.label - Label for the selector (default: "Tags") * @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 {boolean} props.required - Whether tag selection is required
* @param {string} props.error - Error message to display * @param {string} props.error - Error message to display
*/ */
@ -22,36 +29,17 @@ export default function TagSelector({
onChange, onChange,
disabled = false, disabled = false,
label = "Tags", label = "Tags",
placeholder = "Select tags...",
required = false, required = false,
error = null error = null
}) { }) {
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags(); const { tagManager } = useTags();
const [availableTags, setAvailableTags] = useState([]); const [availableTags, setAvailableTags] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef(null); const [isModalOpen, setIsModalOpen] = useState(false);
const [pendingSelection, setPendingSelection] = useState([]);
useEffect(() => { const loadTags = useCallback(async () => {
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 () => {
try { try {
setIsLoading(true); setIsLoading(true);
const tags = await tagManager.listTags(); const tags = await tagManager.listTags();
@ -62,134 +50,310 @@ export default function TagSelector({
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, [tagManager]);
const handleToggleTag = (tagId) => { useEffect(() => {
if (value.includes(tagId)) { loadTags();
onChange(value.filter(id => id !== tagId)); }, [loadTags]);
} else {
onChange([...value, tagId]);
}
};
const handleRemoveTag = (tag) => { // Validate and filter incoming value prop
onChange(value.filter(id => id !== tag.id)); 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 selectedTags = useMemo(() =>
availableTags.filter(tag => validatedValue.includes(tag.id)),
const filteredTags = availableTags.filter(tag => [availableTags, validatedValue]
tag.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
// 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 ( return (
<div className="w-full" ref={dropdownRef}> <div className="w-full h-full flex flex-col">
{/* Label */} {/* Label - only show when label is provided */}
{label && ( {label && (
<label className="block text-sm font-medium text-gray-700 mb-2"> <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} {label}
{required && <span className="text-red-500 ml-1">*</span>} {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> </label>
)} )}
{/* Selected tags display */} {/* Card container - compact mode has simpler layout */}
{selectedTags.length > 0 && ( {isCompactMode ? (
<div className="flex flex-wrap gap-2 mb-2"> // Compact mode: simple button with selected count
{selectedTags.map(tag => ( <div className="flex flex-col space-y-3">
<TagBadge {selectedCount > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.slice(0, 3).map(tag => (
<span
key={tag.id} key={tag.id}
tag={tag} className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium`}
onRemove={!disabled ? handleRemoveTag : undefined} style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
removable={!disabled} >
size="sm" <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>
)} )}
<div className="flex space-x-2">
{/* Dropdown trigger button */} <Button
<button
type="button" type="button"
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={handleOpenModal}
disabled={disabled} disabled={disabled || isLoading}
className={` variant="secondary"
w-full px-4 py-2 border rounded-lg text-left flex items-center justify-between size="sm"
${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"> {selectedCount > 0 ? `Change Tags (${selectedCount})` : "Choose Tags"}
<TagIcon className="h-4 w-4 mr-2 text-gray-500" /> </Button>
<span className="text-sm"> {selectedCount > 0 && (
{selectedTags.length > 0 <Button
? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected` type="button"
: placeholder 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"
} }
</span> </p>
</span> <p className={`text-xs ${getThemeClasses("text-secondary")} mb-3 truncate`}>
<ChevronDownIcon {selectedCount > 0
className={`h-4 w-4 text-gray-500 transition-transform ${isOpen ? 'transform rotate-180' : ''}`} ? selectedTagsDisplay
/> : "Add tags to organize your folder"
</button> }
</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>
)}
{/* Error message */} {/* Error message */}
{error && ( {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 */} {/* Modal */}
{isOpen && ( {isModalOpen && (
<div className="absolute z-50 mt-2 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-hidden"> <div
{/* Search input */} className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
<div className="p-3 border-b border-gray-200 sticky top-0 bg-white"> onClick={handleBackdropClick}
<div className="relative"> onKeyDown={handleKeyDown}
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> role="dialog"
<input aria-modal="true"
type="text" aria-labelledby="tag-picker-title"
placeholder="Search tags..." >
value={searchTerm} <div className={`${getThemeClasses("bg-card")} rounded-xl shadow-xl w-full max-w-lg max-h-[85vh] flex flex-col`}>
onChange={(e) => setSearchTerm(e.target.value)} {/* Header */}
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" <div className={`flex items-center justify-between p-4 border-b ${getThemeClasses("border-secondary")}`}>
onClick={(e) => e.stopPropagation()} <h3 id="tag-picker-title" className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>
/> Choose Tags
</div> </h3>
<Button
onClick={handleCloseModal}
variant="ghost"
size="sm"
aria-label="Close tag picker"
>
<XMarkIcon className="h-5 w-5" />
</Button>
</div> </div>
{/* Tag list */} {/* Content */}
<div className="max-h-60 overflow-y-auto"> <div className="flex-1 overflow-y-auto p-4">
{isLoading ? ( {isLoading ? (
<div className="p-4 text-center text-sm text-gray-500"> <div className={`p-8 text-center ${getThemeClasses("text-secondary")}`}>
Loading tags... Loading tags...
</div> </div>
) : filteredTags.length === 0 ? ( ) : availableTags.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500"> <div className={`p-8 text-center ${getThemeClasses("text-secondary")}`}>
{searchTerm ? 'No tags found' : 'No tags available'} <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>
) : ( ) : (
filteredTags.map(tag => ( <div className="flex flex-wrap gap-3">
<label {availableTags.map(tag => {
const isSelected = pendingSelection.includes(tag.id);
return (
<Button
key={tag.id} key={tag.id}
className="flex items-center px-4 py-2.5 hover:bg-gray-50 cursor-pointer transition-colors" 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 } : {}}
> >
<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 <span
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0" className="w-3 h-3 rounded-full mr-2 flex-shrink-0"
style={{ backgroundColor: tag.color }} style={{ backgroundColor: tag.color }}
/> />
<span className="text-sm text-gray-900 truncate">{tag.name}</span> <span className={isSelected ? "" : getThemeClasses("text-primary")}>
</label> {tag.name}
)) </span>
</Button>
);
})}
</div>
)} )}
</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"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
variant="primary"
size="sm"
>
Confirm ({pendingSelection.length})
</Button>
</div>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -37,8 +37,6 @@ import {
} from "../../../../components/UIX"; } from "../../../../components/UIX";
import { import {
FolderIcon, FolderIcon,
PhotoIcon,
CheckIcon,
HomeIcon, HomeIcon,
PlusIcon, PlusIcon,
SparklesIcon, SparklesIcon,
@ -59,29 +57,12 @@ const CollectionCreate = () => {
const [generalError, setGeneralError] = useState(""); const [generalError, setGeneralError] = useState("");
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [collectionName, setCollectionName] = useState(""); const [collectionName, setCollectionName] = useState("");
const [collectionType, setCollectionType] = useState("folder");
const [customIcon, setCustomIcon] = useState(""); const [customIcon, setCustomIcon] = useState("");
const [showIconPicker, setShowIconPicker] = useState(false); const [showIconPicker, setShowIconPicker] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState([]); const [selectedTagIds, setSelectedTagIds] = useState([]);
// Memoize collection type options to prevent recreation // Default collection type - always "folder" (Documents)
const collectionTypeOptions = useMemo( const collectionType = "folder";
() => [
{
value: "folder",
label: "Documents",
description: "For files and documents",
icon: FolderIcon,
},
{
value: "album",
label: "Photos",
description: "For images and photos",
icon: PhotoIcon,
},
],
[],
);
// Event handlers with useCallback to prevent recreation // Event handlers with useCallback to prevent recreation
// NOTE: UIX Input component passes value directly, not event object // NOTE: UIX Input component passes value directly, not event object
@ -98,17 +79,6 @@ const CollectionCreate = () => {
setGeneralError(""); 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) => { const handleIconChange = useCallback((value) => {
setCustomIcon(value); setCustomIcon(value);
}, []); }, []);
@ -185,8 +155,10 @@ const CollectionCreate = () => {
// Pass tag IDs to backend - it will look up and embed the full tag data // Pass tag IDs to backend - it will look up and embed the full tag data
if (selectedTagIds.length > 0) { if (selectedTagIds.length > 0) {
collectionData.tag_ids = selectedTagIds; collectionData.tag_ids = selectedTagIds;
if (import.meta.env.DEV) {
console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length); console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length);
} }
}
const result = await createCollectionManager.createCollection( const result = await createCollectionManager.createCollection(
collectionData, collectionData,
@ -278,7 +250,6 @@ const CollectionCreate = () => {
}, },
[ [
collectionName, collectionName,
collectionType,
customIcon, customIcon,
parentCollectionId, parentCollectionId,
selectedTagIds, selectedTagIds,
@ -403,67 +374,10 @@ const CollectionCreate = () => {
)} )}
</div> </div>
{/* Type Selection */} {/* Custom Icon and Tags - Two Column Layout */}
<div className="mb-6"> <div className="mb-6 grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">
<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 */} {/* Custom Icon Section */}
<div className="mb-6"> <div className="flex flex-col">
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}> <label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
<span className="flex items-center"> <span className="flex items-center">
<SparklesIcon className="h-4 w-4 mr-2" /> <SparklesIcon className="h-4 w-4 mr-2" />
@ -473,7 +387,7 @@ const CollectionCreate = () => {
</span> </span>
</span> </span>
</label> </label>
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}> <div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} flex-1`}>
<CollectionIconPreview <CollectionIconPreview
customIcon={customIcon} customIcon={customIcon}
collectionType={collectionType} collectionType={collectionType}
@ -516,17 +430,15 @@ const CollectionCreate = () => {
</div> </div>
{/* Tag Selection */} {/* Tag Selection */}
<div className="mb-6"> <div className="flex flex-col">
<TagSelector <TagSelector
value={selectedTagIds} value={selectedTagIds}
onChange={handleTagChange} onChange={handleTagChange}
disabled={isLoading} disabled={isLoading}
label="Tags" label="Tags"
placeholder="Select tags to organize this folder..." placeholder="Search tags..."
/> />
<p className={`mt-1 text-xs ${getThemeClasses("text-secondary")}`}> </div>
Tags help you organize and find your folders easily
</p>
</div> </div>
{/* Submit Button */} {/* Submit Button */}

View file

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

View file

@ -609,16 +609,21 @@ const FileUpload = () => {
}); });
}, 1500); }, 1500);
} else { } else {
// No pre-selected collection, but user selected one from dropdown
// Navigate to the selected collection folder
setTimeout(async () => { setTimeout(async () => {
await clearAllRelevantCaches(); await clearAllRelevantCaches();
triggerDashboardRefresh(); triggerDashboardRefresh();
navigate("/dashboard", { navigate(`/file-manager/collections/${selectedCollection}`, {
state: { state: {
refreshDashboard: true, refresh: true,
uploadCompleted: true, refreshFiles: true,
forceFileRefresh: true,
uploadedFileCount: successCount, uploadedFileCount: successCount,
uploadTimestamp: Date.now(), uploadTimestamp: Date.now(),
cacheCleared: true,
refreshDashboard: true,
}, },
replace: false, replace: false,
}); });
@ -802,7 +807,6 @@ const FileUpload = () => {
{!preSelectedCollectionId && ( {!preSelectedCollectionId && (
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<Select <Select
label="Select destination folder"
value={selectedCollection} value={selectedCollection}
onChange={setSelectedCollection} onChange={setSelectedCollection}
options={collectionOptions} options={collectionOptions}
@ -846,6 +850,8 @@ const FileUpload = () => {
</div> </div>
)} )}
{/* Drop Zone and Files List - Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Drop Zone */} {/* Drop Zone */}
<div <div
role="button" role="button"
@ -862,14 +868,14 @@ const FileUpload = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
} }
}} }}
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${ 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 isDragging
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg` ? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
: `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md` : `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md`
} ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`} } ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`}
> >
<div <div
className={`h-16 w-16 rounded-2xl mx-auto mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${ className={`h-16 w-16 rounded-2xl mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${
isDragging isDragging
? getThemeClasses("bg-gradient-secondary") + ? getThemeClasses("bg-gradient-secondary") +
" scale-110" " scale-110"
@ -883,11 +889,6 @@ const FileUpload = () => {
> >
{isDragging ? "Drop files here" : "Upload your files"} {isDragging ? "Drop files here" : "Upload your files"}
</h3> </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")}`}> <p className={`text-xs ${getThemeClasses("text-muted")}`}>
Maximum file size: 5GB All file types supported Maximum file size: 5GB All file types supported
</p> </p>
@ -911,10 +912,9 @@ const FileUpload = () => {
/> />
</div> </div>
{/* Files List */} {/* Files List - Always visible */}
{files.length > 0 && (
<div <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 <div
className={`p-4 border-b ${getThemeClasses("border-secondary")}`} className={`p-4 border-b ${getThemeClasses("border-secondary")}`}
@ -923,21 +923,26 @@ const FileUpload = () => {
<h3 <h3
className={`font-semibold ${getThemeClasses("text-primary")}`} className={`font-semibold ${getThemeClasses("text-primary")}`}
> >
{files.length} file{files.length !== 1 ? "s" : ""}{" "} {files.length > 0
selected ? `${files.length} file${files.length !== 1 ? "s" : ""} selected`
: "Files Selected"
}
</h3> </h3>
{files.length > 0 && (
<span <span
className={`text-sm ${getThemeClasses("text-secondary")}`} className={`text-sm ${getThemeClasses("text-secondary")}`}
> >
Total: {formatFileSize(totalSize)} Total: {formatFileSize(totalSize)}
</span> </span>
)}
</div> </div>
</div> </div>
{files.length > 0 ? (
<div <div
role="list" role="list"
aria-label="Selected files for upload" aria-label="Selected files for upload"
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`} className={`divide-y ${getThemeClasses("border-secondary")} max-h-72 overflow-y-auto flex-1`}
> >
{files.map((file) => ( {files.map((file) => (
<div <div
@ -1038,9 +1043,20 @@ const FileUpload = () => {
</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>
</div>
{/* Sidebar - only show as separate column when collection is pre-selected */} {/* Sidebar - only show as separate column when collection is pre-selected */}
{preSelectedCollectionId && ( {preSelectedCollectionId && (
@ -1222,10 +1238,10 @@ const FileUpload = () => {
{/* Options section - show below content when no pre-selected collection */} {/* Options section - show below content when no pre-selected collection */}
{!preSelectedCollectionId && files.length > 0 && ( {!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 */} {/* Upload Status */}
<div <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 <h3
className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`} className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`}
@ -1305,7 +1321,7 @@ const FileUpload = () => {
{/* Tag Selection */} {/* Tag Selection */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div <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"> <div className="flex items-center gap-2 mb-4">
<TagIcon <TagIcon
@ -1322,6 +1338,7 @@ const FileUpload = () => {
> >
Tags will be applied to all uploaded files Tags will be applied to all uploaded files
</p> </p>
<div className="flex-1 flex flex-col justify-end">
<TagSelector <TagSelector
value={selectedTagIds} value={selectedTagIds}
onChange={setSelectedTagIds} onChange={setSelectedTagIds}
@ -1330,12 +1347,13 @@ const FileUpload = () => {
placeholder="Select tags..." placeholder="Select tags..."
/> />
</div> </div>
</div>
)} )}
{/* Consent & Security Notice */} {/* Consent & Security Notice */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div <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"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox