gui work
This commit is contained in:
parent
da906be65d
commit
ee537fc4a0
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
|
// 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>
|
||||||
|
|
|
||||||
|
|
@ -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,53 +31,88 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Determine hover border color based on theme
|
if (fullWidth) {
|
||||||
const hoverBorderClass = {
|
baseClasses.push("flex-1");
|
||||||
red: "hover:border-red-500",
|
}
|
||||||
blue: "hover:border-blue-500",
|
|
||||||
purple: "hover:border-purple-500",
|
|
||||||
green: "hover:border-green-500",
|
|
||||||
charcoal: "hover:border-slate-500",
|
|
||||||
}[themeClasses.currentTheme] || "hover:border-gray-500";
|
|
||||||
|
|
||||||
if (isActive) {
|
// Variant-specific styling
|
||||||
// Active tab: just show primary text, no border
|
if (variant === "underline") {
|
||||||
baseClasses.push(themeClasses.textPrimary);
|
// 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 {
|
} else {
|
||||||
// Inactive tab: secondary text with hover effect
|
// Default variant - original behavior
|
||||||
baseClasses.push(
|
baseClasses.push("border-b-4", "border-transparent");
|
||||||
themeClasses.textSecondary,
|
|
||||||
themeClasses.hoverTextPrimary,
|
// Determine hover border color based on theme
|
||||||
hoverBorderClass
|
const hoverBorderClass = {
|
||||||
);
|
red: "hover:border-red-500",
|
||||||
|
blue: "hover:border-blue-500",
|
||||||
|
purple: "hover:border-purple-500",
|
||||||
|
green: "hover:border-green-500",
|
||||||
|
charcoal: "hover:border-slate-500",
|
||||||
|
}[themeClasses.currentTheme] || "hover:border-gray-500";
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
baseClasses.push(themeClasses.textPrimary);
|
||||||
|
} else {
|
||||||
|
baseClasses.push(
|
||||||
|
themeClasses.textSecondary,
|
||||||
|
themeClasses.hoverTextPrimary,
|
||||||
|
hoverBorderClass
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseClasses.filter(Boolean).join(" ");
|
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
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,133 +50,309 @@ 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`}>
|
||||||
{label}
|
<span className="flex items-center">
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
<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>
|
</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 && (
|
||||||
key={tag.id}
|
<div className="flex flex-wrap gap-2">
|
||||||
tag={tag}
|
{selectedTags.slice(0, 3).map(tag => (
|
||||||
onRemove={!disabled ? handleRemoveTag : undefined}
|
<span
|
||||||
removable={!disabled}
|
key={tag.id}
|
||||||
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium`}
|
||||||
|
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full mr-1.5"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{selectedCount > 3 && (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getThemeClasses("bg-muted")} ${getThemeClasses("text-secondary")}`}>
|
||||||
|
+{selectedCount - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
>
|
||||||
))}
|
{selectedCount > 0 ? `Change Tags (${selectedCount})` : "Choose Tags"}
|
||||||
|
</Button>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Full mode: card with icon preview - matches Icon selector
|
||||||
|
<div className={`flex items-center p-4 rounded-lg border ${error ? 'border-red-500' : getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} flex-1`}>
|
||||||
|
{/* Tag preview */}
|
||||||
|
<div className={`w-12 h-12 rounded-lg flex items-center justify-center mr-4 flex-shrink-0 ${getThemeClasses("bg-card")} border ${getThemeClasses("border-secondary")}`}>
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<span className={`text-lg font-semibold ${getThemeClasses("text-accent")}`}>
|
||||||
|
{selectedCount}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<TagIcon className={`h-6 w-6 ${getThemeClasses("text-muted")}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-primary")} font-medium mb-1`}>
|
||||||
|
{selectedCount > 0
|
||||||
|
? `${selectedCount} tag${selectedCount !== 1 ? 's' : ''} selected`
|
||||||
|
: "No tags selected"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${getThemeClasses("text-secondary")} mb-3 truncate`}>
|
||||||
|
{selectedCount > 0
|
||||||
|
? selectedTagsDisplay
|
||||||
|
: "Add tags to organize your folder"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{selectedCount > 0 ? "Change Tags" : "Choose Tags"}
|
||||||
|
</Button>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dropdown trigger button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`
|
|
||||||
w-full px-4 py-2 border rounded-lg text-left flex items-center justify-between
|
|
||||||
${disabled
|
|
||||||
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-white hover:bg-gray-50 text-gray-900'
|
|
||||||
}
|
|
||||||
${error ? 'border-red-500' : 'border-gray-300'}
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<TagIcon className="h-4 w-4 mr-2 text-gray-500" />
|
|
||||||
<span className="text-sm">
|
|
||||||
{selectedTags.length > 0
|
|
||||||
? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected`
|
|
||||||
: placeholder
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={`h-4 w-4 text-gray-500 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error 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
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Close tag picker"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</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")}`} />
|
||||||
</div>
|
<p>No tags available</p>
|
||||||
) : (
|
<p className="text-sm mt-1">Create tags in your profile settings</p>
|
||||||
filteredTags.map(tag => (
|
</div>
|
||||||
<label
|
) : (
|
||||||
key={tag.id}
|
<div className="flex flex-wrap gap-3">
|
||||||
className="flex items-center px-4 py-2.5 hover:bg-gray-50 cursor-pointer transition-colors"
|
{availableTags.map(tag => {
|
||||||
|
const isSelected = pendingSelection.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleTag(tag.id)}
|
||||||
|
variant="ghost"
|
||||||
|
className={`px-4 py-2 rounded-full border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? `border-current ${getThemeClasses("bg-accent-light")}`
|
||||||
|
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
|
||||||
|
}`}
|
||||||
|
style={isSelected ? { borderColor: tag.color, color: tag.color } : {}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full mr-2 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<span className={isSelected ? "" : getThemeClasses("text-primary")}>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`flex items-center justify-between p-4 border-t ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} rounded-b-xl`}>
|
||||||
|
<Button
|
||||||
|
onClick={handleReset}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={TagIcon}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<input
|
Cancel
|
||||||
type="checkbox"
|
</Button>
|
||||||
checked={value.includes(tag.id)}
|
<Button
|
||||||
onChange={() => handleToggleTag(tag.id)}
|
onClick={handleConfirm}
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer"
|
variant="primary"
|
||||||
/>
|
size="sm"
|
||||||
<span
|
>
|
||||||
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0"
|
Confirm ({pendingSelection.length})
|
||||||
style={{ backgroundColor: tag.color }}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
<span className="text-sm text-gray-900 truncate">{tag.name}</span>
|
</div>
|
||||||
</label>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +155,9 @@ 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;
|
||||||
console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('[CollectionCreate] Sending tag_ids to backend:', selectedTagIds.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createCollectionManager.createCollection(
|
const result = await createCollectionManager.createCollection(
|
||||||
|
|
@ -278,7 +250,6 @@ const CollectionCreate = () => {
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
collectionName,
|
collectionName,
|
||||||
collectionType,
|
|
||||||
customIcon,
|
customIcon,
|
||||||
parentCollectionId,
|
parentCollectionId,
|
||||||
selectedTagIds,
|
selectedTagIds,
|
||||||
|
|
@ -403,130 +374,71 @@ 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`}>
|
{/* Custom Icon Section */}
|
||||||
Folder Type
|
<div className="flex flex-col">
|
||||||
</label>
|
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<span className="flex items-center">
|
||||||
{collectionTypeOptions.map((option) => {
|
<SparklesIcon className="h-4 w-4 mr-2" />
|
||||||
const Icon = option.icon;
|
Customize Icon
|
||||||
const isSelected = collectionType === option.value;
|
<span className={`ml-2 text-xs font-normal ${getThemeClasses("text-secondary")}`}>
|
||||||
|
(Optional)
|
||||||
return (
|
</span>
|
||||||
<label
|
|
||||||
key={option.value}
|
|
||||||
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${
|
|
||||||
isSelected
|
|
||||||
? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}`
|
|
||||||
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="type"
|
|
||||||
value={option.value}
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => handleTypeChange(option.value)}
|
|
||||||
className="sr-only"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Icon
|
|
||||||
className={`h-5 w-5 mr-3 ${
|
|
||||||
isSelected
|
|
||||||
? getThemeClasses("text-primary")
|
|
||||||
: getThemeClasses("text-muted")
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className={`block font-medium ${getThemeClasses("text-primary")}`}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
|
||||||
{option.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isSelected && (
|
|
||||||
<CheckIcon className={`absolute right-3 top-3 h-5 w-5 ${getThemeClasses("text-primary")}`} />
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{errors.collection_type && (
|
|
||||||
<p className={`mt-1 text-sm ${getThemeClasses("text-error")}`}>
|
|
||||||
{errors.collection_type}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Icon Section */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<SparklesIcon className="h-4 w-4 mr-2" />
|
|
||||||
Customize Icon
|
|
||||||
<span className={`ml-2 text-xs font-normal ${getThemeClasses("text-secondary")}`}>
|
|
||||||
(Optional)
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</label>
|
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")} flex-1`}>
|
||||||
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
<CollectionIconPreview
|
||||||
<CollectionIconPreview
|
customIcon={customIcon}
|
||||||
customIcon={customIcon}
|
collectionType={collectionType}
|
||||||
collectionType={collectionType}
|
size="xl"
|
||||||
size="xl"
|
className="mr-4"
|
||||||
className="mr-4"
|
/>
|
||||||
/>
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<p className={`text-sm ${getThemeClasses("text-primary")} font-medium mb-1`}>
|
||||||
<p className={`text-sm ${getThemeClasses("text-primary")} font-medium mb-1`}>
|
{customIcon ? "Custom Icon" : "Default Icon"}
|
||||||
{customIcon ? "Custom Icon" : "Default Icon"}
|
</p>
|
||||||
</p>
|
<p className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}>
|
||||||
<p className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}>
|
{customIcon
|
||||||
{customIcon
|
? "Click to change or reset to default"
|
||||||
? "Click to change or reset to default"
|
: "Add a custom emoji or icon to personalize your folder"}
|
||||||
: "Add a custom emoji or icon to personalize your folder"}
|
</p>
|
||||||
</p>
|
<div className="flex space-x-2">
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpenIconPicker}
|
|
||||||
disabled={isLoading}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{customIcon ? "Change Icon" : "Choose Icon"}
|
|
||||||
</Button>
|
|
||||||
{customIcon && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCustomIcon("")}
|
onClick={handleOpenIconPicker}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Reset
|
{customIcon ? "Change Icon" : "Choose Icon"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{customIcon && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomIcon("")}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +189,9 @@ const CollectionShare = () => {
|
||||||
setOwnerEmail(currentEmail);
|
setOwnerEmail(currentEmail);
|
||||||
}
|
}
|
||||||
} catch (lookupErr) {
|
} catch (lookupErr) {
|
||||||
console.warn("Could not determine owner email:", lookupErr);
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn("Could not determine owner email:", lookupErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,9 +209,9 @@ const CollectionShare = () => {
|
||||||
setError("Could not load folder details. Please try again.");
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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,75 +850,71 @@ const FileUpload = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drop Zone */}
|
{/* Drop Zone and Files List - Side by Side */}
|
||||||
<div
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
role="button"
|
{/* Drop Zone */}
|
||||||
tabIndex={0}
|
|
||||||
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
|
|
||||||
aria-disabled={isUploading}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => !isUploading && fileInputRef.current?.click()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
|
|
||||||
e.preventDefault();
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${
|
|
||||||
isDragging
|
|
||||||
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
|
|
||||||
: `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md`
|
|
||||||
} ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`h-16 w-16 rounded-2xl mx-auto mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
|
||||||
|
aria-disabled={isUploading}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !isUploading && fileInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-8 text-center cursor-pointer transition-all duration-300 flex flex-col items-center justify-center ${
|
||||||
isDragging
|
isDragging
|
||||||
? getThemeClasses("bg-gradient-secondary") +
|
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
|
||||||
" scale-110"
|
: `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md`
|
||||||
: getThemeClasses("bg-gradient-secondary")
|
} ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<CloudArrowUpIcon className="h-8 w-8 text-white" />
|
<div
|
||||||
|
className={`h-16 w-16 rounded-2xl mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${
|
||||||
|
isDragging
|
||||||
|
? getThemeClasses("bg-gradient-secondary") +
|
||||||
|
" scale-110"
|
||||||
|
: getThemeClasses("bg-gradient-secondary")
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CloudArrowUpIcon className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
{isDragging ? "Drop files here" : "Upload your files"}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xs ${getThemeClasses("text-muted")}`}>
|
||||||
|
Maximum file size: 5GB • All file types supported
|
||||||
|
</p>
|
||||||
|
{preSelectedCollectionId &&
|
||||||
|
preSelectedCollectionInfo?.name && (
|
||||||
|
<p
|
||||||
|
className={`text-sm font-bold mt-3 ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
Uploading to: {preSelectedCollectionInfo.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label="Select files to upload"
|
||||||
|
id="file-upload-input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3
|
|
||||||
className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
|
||||||
>
|
|
||||||
{isDragging ? "Drop files here" : "Upload your files"}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}
|
|
||||||
>
|
|
||||||
Drag and drop or click to browse
|
|
||||||
</p>
|
|
||||||
<p className={`text-xs ${getThemeClasses("text-muted")}`}>
|
|
||||||
Maximum file size: 5GB • All file types supported
|
|
||||||
</p>
|
|
||||||
{preSelectedCollectionId &&
|
|
||||||
preSelectedCollectionInfo?.name && (
|
|
||||||
<p
|
|
||||||
className={`text-sm font-bold mt-3 ${getThemeClasses("text-primary")}`}
|
|
||||||
>
|
|
||||||
Uploading to: {preSelectedCollectionInfo.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="sr-only"
|
|
||||||
aria-label="Select files to upload"
|
|
||||||
id="file-upload-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Files List */}
|
{/* Files 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,123 +923,139 @@ 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>
|
||||||
<span
|
{files.length > 0 && (
|
||||||
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
<span
|
||||||
>
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
||||||
Total: {formatFileSize(totalSize)}
|
>
|
||||||
</span>
|
Total: {formatFileSize(totalSize)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{files.length > 0 ? (
|
||||||
role="list"
|
<div
|
||||||
aria-label="Selected files for upload"
|
role="list"
|
||||||
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
|
aria-label="Selected files for upload"
|
||||||
>
|
className={`divide-y ${getThemeClasses("border-secondary")} max-h-72 overflow-y-auto flex-1`}
|
||||||
{files.map((file) => (
|
>
|
||||||
<div
|
{files.map((file) => (
|
||||||
key={file.id}
|
<div
|
||||||
role="listitem"
|
key={file.id}
|
||||||
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
|
role="listitem"
|
||||||
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
|
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
|
||||||
>
|
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
|
||||||
<div className="flex items-center space-x-4">
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-center space-x-4">
|
||||||
<div
|
<div className="flex-shrink-0">
|
||||||
className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-muted")} flex items-center justify-center`}
|
<div
|
||||||
>
|
className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-muted")} flex items-center justify-center`}
|
||||||
{getFileIcon(file)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<h4
|
|
||||||
className={`text-sm font-medium truncate pr-2 ${getThemeClasses("text-primary")}`}
|
|
||||||
>
|
>
|
||||||
{file.name}
|
{getFileIcon(file)}
|
||||||
</h4>
|
</div>
|
||||||
<span
|
|
||||||
className={`text-xs ${getThemeClasses("text-muted")} flex-shrink-0`}
|
|
||||||
>
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar for uploading files */}
|
<div className="flex-1 min-w-0">
|
||||||
{file.status === "uploading" &&
|
<div className="flex items-center justify-between mb-1">
|
||||||
uploadProgress[file.id] !== undefined && (
|
<h4
|
||||||
<div className="mt-2">
|
className={`text-sm font-medium truncate pr-2 ${getThemeClasses("text-primary")}`}
|
||||||
<div
|
>
|
||||||
className={`w-full ${getThemeClasses("bg-muted")} rounded-full h-2 overflow-hidden`}
|
{file.name}
|
||||||
>
|
</h4>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${getThemeClasses("text-muted")} flex-shrink-0`}
|
||||||
|
>
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar for uploading files */}
|
||||||
|
{file.status === "uploading" &&
|
||||||
|
uploadProgress[file.id] !== undefined && (
|
||||||
|
<div className="mt-2">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${getThemeClasses("bg-gradient-secondary")} transition-all duration-300`}
|
className={`w-full ${getThemeClasses("bg-muted")} rounded-full h-2 overflow-hidden`}
|
||||||
style={{
|
>
|
||||||
width: `${uploadProgress[file.id]}%`,
|
<div
|
||||||
}}
|
className={`h-full ${getThemeClasses("bg-gradient-secondary")} transition-all duration-300`}
|
||||||
></div>
|
style={{
|
||||||
|
width: `${uploadProgress[file.id]}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-xs ${getThemeClasses("text-info")} mt-1`}
|
||||||
|
>
|
||||||
|
{uploadProgress[file.id]}% uploaded
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
)}
|
||||||
className={`text-xs ${getThemeClasses("text-info")} mt-1`}
|
{file.status === "error" && file.error && (
|
||||||
>
|
<p
|
||||||
{uploadProgress[file.id]}% uploaded
|
className={`text-xs ${getThemeClasses("text-error")} mt-1 truncate`}
|
||||||
</p>
|
>
|
||||||
|
{file.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{file.status === "pending" && (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeFile(file.id);
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
className={`${getThemeClasses("hover:text-error")}`}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{file.status === "uploading" && (
|
||||||
|
<div className="p-2">
|
||||||
|
<div
|
||||||
|
className={`animate-spin rounded-full h-5 w-5 border-2 ${getThemeClasses("border-primary")} border-t-transparent`}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{file.status === "error" && file.error && (
|
{file.status === "complete" && (
|
||||||
<p
|
<div className="p-2">
|
||||||
className={`text-xs ${getThemeClasses("text-error")} mt-1 truncate`}
|
<CheckCircleIcon
|
||||||
>
|
className={`h-5 w-5 ${getThemeClasses("text-success")}`}
|
||||||
{file.error}
|
/>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{file.status === "error" && (
|
||||||
<div className="flex-shrink-0">
|
<div className="p-2">
|
||||||
{file.status === "pending" && (
|
<ExclamationTriangleIcon
|
||||||
<Button
|
className={`h-5 w-5 ${getThemeClasses("text-error")}`}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation();
|
</div>
|
||||||
removeFile(file.id);
|
)}
|
||||||
}}
|
</div>
|
||||||
disabled={isUploading}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
className={`${getThemeClasses("hover:text-error")}`}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{file.status === "uploading" && (
|
|
||||||
<div className="p-2">
|
|
||||||
<div
|
|
||||||
className={`animate-spin rounded-full h-5 w-5 border-2 ${getThemeClasses("border-primary")} border-t-transparent`}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{file.status === "complete" && (
|
|
||||||
<div className="p-2">
|
|
||||||
<CheckCircleIcon
|
|
||||||
className={`h-5 w-5 ${getThemeClasses("text-success")}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{file.status === "error" && (
|
|
||||||
<div className="p-2">
|
|
||||||
<ExclamationTriangleIcon
|
|
||||||
className={`h-5 w-5 ${getThemeClasses("text-error")}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`flex-1 flex items-center justify-center p-8 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<DocumentIcon className={`h-12 w-12 mx-auto mb-3 ${getThemeClasses("text-muted")}`} />
|
||||||
|
<p className="text-sm">No files selected yet</p>
|
||||||
|
<p className={`text-xs mt-1 ${getThemeClasses("text-muted")}`}>
|
||||||
|
Select files from the upload area
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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 */}
|
||||||
|
|
@ -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,20 +1338,22 @@ const FileUpload = () => {
|
||||||
>
|
>
|
||||||
Tags will be applied to all uploaded files
|
Tags will be applied to all uploaded files
|
||||||
</p>
|
</p>
|
||||||
<TagSelector
|
<div className="flex-1 flex flex-col justify-end">
|
||||||
value={selectedTagIds}
|
<TagSelector
|
||||||
onChange={setSelectedTagIds}
|
value={selectedTagIds}
|
||||||
disabled={isUploading}
|
onChange={setSelectedTagIds}
|
||||||
label=""
|
disabled={isUploading}
|
||||||
placeholder="Select tags..."
|
label=""
|
||||||
/>
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue