Merged latest code by @rodolfomartinez to code by @bartmika.
This commit is contained in:
commit
3bf89fe2fa
7 changed files with 828 additions and 664 deletions
BIN
Screenshot from 2025-12-05 15-02-44.png
Normal file
BIN
Screenshot from 2025-12-05 15-02-44.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,30 +31,66 @@ 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,
|
||||
];
|
||||
|
||||
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
|
||||
const hoverBorderClass = {
|
||||
red: "hover:border-red-500",
|
||||
|
|
@ -62,19 +101,18 @@ const TabItem = memo(
|
|||
}[themeClasses.currentTheme] || "hover:border-gray-500";
|
||||
|
||||
if (isActive) {
|
||||
// Active tab: just show primary text, no border
|
||||
baseClasses.push(themeClasses.textPrimary);
|
||||
} else {
|
||||
// Inactive tab: secondary text with hover effect
|
||||
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
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,134 +50,310 @@ 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 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
|
||||
{/* 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}
|
||||
tag={tag}
|
||||
onRemove={!disabled ? handleRemoveTag : undefined}
|
||||
removable={!disabled}
|
||||
size="sm"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Dropdown trigger button */}
|
||||
<button
|
||||
<div className="flex space-x-2">
|
||||
<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
|
||||
`}
|
||||
onClick={handleOpenModal}
|
||||
disabled={disabled || isLoading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<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
|
||||
{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"
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-gray-500 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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()}
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* Tag list */}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
<div className={`p-8 text-center ${getThemeClasses("text-secondary")}`}>
|
||||
Loading tags...
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{searchTerm ? 'No tags found' : 'No tags available'}
|
||||
) : 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>
|
||||
) : (
|
||||
filteredTags.map(tag => (
|
||||
<label
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{availableTags.map(tag => {
|
||||
const isSelected = pendingSelection.includes(tag.id);
|
||||
return (
|
||||
<Button
|
||||
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
|
||||
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 }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900 truncate">{tag.name}</span>
|
||||
</label>
|
||||
))
|
||||
<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"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Confirm ({pendingSelection.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,8 +155,10 @@ 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;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createCollectionManager.createCollection(
|
||||
collectionData,
|
||||
|
|
@ -278,7 +250,6 @@ const CollectionCreate = () => {
|
|||
},
|
||||
[
|
||||
collectionName,
|
||||
collectionType,
|
||||
customIcon,
|
||||
parentCollectionId,
|
||||
selectedTagIds,
|
||||
|
|
@ -403,67 +374,10 @@ 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 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="mb-6">
|
||||
<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" />
|
||||
|
|
@ -473,7 +387,7 @@ const CollectionCreate = () => {
|
|||
</span>
|
||||
</span>
|
||||
</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
|
||||
customIcon={customIcon}
|
||||
collectionType={collectionType}
|
||||
|
|
@ -516,17 +430,15 @@ const CollectionCreate = () => {
|
|||
</div>
|
||||
|
||||
{/* Tag Selection */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col">
|
||||
<TagSelector
|
||||
value={selectedTagIds}
|
||||
onChange={handleTagChange}
|
||||
disabled={isLoading}
|
||||
label="Tags"
|
||||
placeholder="Select tags to organize this folder..."
|
||||
placeholder="Search tags..."
|
||||
/>
|
||||
<p className={`mt-1 text-xs ${getThemeClasses("text-secondary")}`}>
|
||||
Tags help you organize and find your folders easily
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
|
|
|
|||
|
|
@ -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,11 +189,13 @@ const CollectionShare = () => {
|
|||
setOwnerEmail(currentEmail);
|
||||
}
|
||||
} catch (lookupErr) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("Could not determine owner email:", lookupErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logError("Failed to load collection", err);
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,6 +850,8 @@ const FileUpload = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop Zone and Files List - Side by Side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
role="button"
|
||||
|
|
@ -862,14 +868,14 @@ const FileUpload = () => {
|
|||
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
|
||||
? `${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" : ""}`}
|
||||
>
|
||||
<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
|
||||
? getThemeClasses("bg-gradient-secondary") +
|
||||
" scale-110"
|
||||
|
|
@ -883,11 +889,6 @@ const FileUpload = () => {
|
|||
>
|
||||
{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>
|
||||
|
|
@ -911,10 +912,9 @@ const FileUpload = () => {
|
|||
/>
|
||||
</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,21 +923,26 @@ 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>
|
||||
{files.length > 0 && (
|
||||
<span
|
||||
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
||||
>
|
||||
Total: {formatFileSize(totalSize)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 ? (
|
||||
<div
|
||||
role="list"
|
||||
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) => (
|
||||
<div
|
||||
|
|
@ -1038,9 +1043,20 @@ const FileUpload = () => {
|
|||
</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>
|
||||
|
||||
{/* Sidebar - only show as separate column when collection is pre-selected */}
|
||||
{preSelectedCollectionId && (
|
||||
|
|
@ -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,6 +1338,7 @@ const FileUpload = () => {
|
|||
>
|
||||
Tags will be applied to all uploaded files
|
||||
</p>
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
<TagSelector
|
||||
value={selectedTagIds}
|
||||
onChange={setSelectedTagIds}
|
||||
|
|
@ -1330,12 +1347,13 @@ const FileUpload = () => {
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue