diff --git a/client/src/components/QuickReplies.jsx b/client/src/components/QuickReplies.jsx index 204aea76..eda760ab 100644 --- a/client/src/components/QuickReplies.jsx +++ b/client/src/components/QuickReplies.jsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from "react"; import { Plus, X, Check, Pencil, GripVertical, ChevronLeft, ChevronRight } from "lucide-react"; -const CATEGORIES = ["General", "Social", "Needs", "Urgent"]; +const DEFAULT_CATEGORIES = ["General", "Social", "Needs", "Urgent"]; +const CATEGORIES_KEY = "vf_quick_reply_categories"; const generateId = () => Math.random().toString(36).substr(2, 9); @@ -18,6 +19,20 @@ const DEFAULT_QUICK_REPLIES = [ const STORAGE_KEY = "vf_quick_replies"; export function QuickReplies({ onSelect, showToast }) { + const [categories, setCategories] = useState(() => { + try { + const saved = localStorage.getItem(CATEGORIES_KEY); + if (saved === null) return DEFAULT_CATEGORIES; + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.every((c) => typeof c === "string")) { + return parsed; + } + return DEFAULT_CATEGORIES; + } catch { + return DEFAULT_CATEGORIES; + } + }); + const [replies, setReplies] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); @@ -27,11 +42,19 @@ export function QuickReplies({ onSelect, showToast }) { Array.isArray(parsed) && parsed.every((item) => item && typeof item.phrase === "string" && typeof item.label === "string") ) { - return parsed.map((item, idx) => ({ + // Load initial categories to validate + let cats = DEFAULT_CATEGORIES; + try { + const savedCats = localStorage.getItem(CATEGORIES_KEY); + if (savedCats) { + const parsedCats = JSON.parse(savedCats); + if (Array.isArray(parsedCats)) cats = parsedCats; + } + } catch {} + return parsed.map((item) => ({ ...item, id: item.id || generateId(), - hotkey: item.hotkey || String(idx + 1), - category: item.category && CATEGORIES.includes(item.category) ? item.category : "General", + category: item.category && cats.includes(item.category) ? item.category : "General", })); } return DEFAULT_QUICK_REPLIES; @@ -47,7 +70,9 @@ export function QuickReplies({ onSelect, showToast }) { const [newPhrase, setNewPhrase] = useState(""); const [selectedCategoryTab, setSelectedCategoryTab] = useState("All"); const [newCategory, setNewCategory] = useState("General"); - const [draggedId, setDraggedId] = useState(null); + const [isAddingCategory, setIsAddingCategory] = useState(false); + const [newCategoryInput, setNewCategoryInput] = useState(""); + const tablistRef = React.useRef(null); useEffect(() => { try { @@ -57,45 +82,107 @@ export function QuickReplies({ onSelect, showToast }) { } }, [replies]); - const handleDragStart = (e, id) => { - setDraggedId(id); - e.dataTransfer.effectAllowed = "move"; - }; + useEffect(() => { + try { + localStorage.setItem(CATEGORIES_KEY, JSON.stringify(categories)); + } catch { + console.error('Failed to persist categories to localStorage'); + } + }, [categories]); - const handleDragOver = (e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - }; + useEffect(() => { + function handleSync() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) { + setReplies(parsed); + } + } + } catch (err) { + console.error("Failed to sync quick replies:", err); + } + + try { + const savedCategories = localStorage.getItem(CATEGORIES_KEY); + if (savedCategories) { + const parsed = JSON.parse(savedCategories); + if (Array.isArray(parsed)) { + setCategories(parsed); + } + } + } catch (err) { + console.error("Failed to sync categories:", err); + } + } + + window.addEventListener("storage", handleSync); + window.addEventListener("voiceforge:quickRepliesChanged", handleSync); + window.addEventListener("voiceforge:quickReplyCategoriesChanged", handleSync); - const handleDrop = (e, targetId) => { + return () => { + window.removeEventListener("storage", handleSync); + window.removeEventListener("voiceforge:quickRepliesChanged", handleSync); + window.removeEventListener("voiceforge:quickReplyCategoriesChanged", handleSync); + }; + }, []); + + const handleAddCategory = (e) => { e.preventDefault(); - if (!draggedId || draggedId === targetId) return; - - setReplies((prev) => { - const fromIndex = prev.findIndex((r) => r.id === draggedId); - const toIndex = prev.findIndex((r) => r.id === targetId); - if (fromIndex === -1 || toIndex === -1) return prev; - - const updated = [...prev]; - const [movedItem] = updated.splice(fromIndex, 1); - updated.splice(toIndex, 0, movedItem); - return updated; - }); - setDraggedId(null); + const cleanCat = newCategoryInput.trim(); + + if (!cleanCat) { + showToast("Category name cannot be empty", "error"); + return; + } + + if (cleanCat.length > 20) { + showToast("Category name is too long (max 20 characters)", "error"); + return; + } + + const isDuplicate = categories.some( + (c) => c.toLowerCase() === cleanCat.toLowerCase() + ); + + if (isDuplicate || cleanCat.toLowerCase() === "all") { + showToast("Category already exists", "error"); + return; + } + + const nextCats = [...categories, cleanCat]; + setCategories(nextCats); + setNewCategoryInput(""); + setIsAddingCategory(false); + showToast("Category added", "success"); + window.dispatchEvent(new Event("voiceforge:quickReplyCategoriesChanged")); }; - const handleMove = (id, direction) => { - setReplies((prev) => { - const index = prev.findIndex((r) => r.id === id); - if (index === -1) return prev; - const newIndex = direction === "left" ? index - 1 : index + 1; - if (newIndex < 0 || newIndex >= prev.length) return prev; - - const updated = [...prev]; - const [movedItem] = updated.splice(index, 1); - updated.splice(newIndex, 0, movedItem); - return updated; - }); + const handleDeleteCategory = (catToDelete) => { + const confirmDelete = window.confirm( + `Are you sure you want to delete the category "${catToDelete}"? Existing replies in this category will be moved to "General".` + ); + if (!confirmDelete) return; + + const nextCats = categories.filter((c) => c !== catToDelete); + setCategories(nextCats); + + setReplies((prev) => + prev.map((r) => { + if (r.category === catToDelete) { + return { ...r, category: "General" }; + } + return r; + }) + ); + + if (selectedCategoryTab === catToDelete) { + setSelectedCategoryTab("All"); + } + + showToast("Category deleted and phrases moved to General", "success"); + window.dispatchEvent(new Event("voiceforge:quickReplyCategoriesChanged")); }; const handleAdd = (e) => { @@ -250,30 +337,89 @@ export function QuickReplies({ onSelect, showToast }) { {/* Category Tabs */}
- {allCats.map((cat) => ( - - ))} + {["All", ...categories].map((cat) => { + const isDefault = DEFAULT_CATEGORIES.includes(cat); + return ( +
+ + {isEditing && !isDefault && cat !== "All" && ( + + )} +
+ ); + })} + + {isEditing && ( +
+ {isAddingCategory ? ( +
+ setNewCategoryInput(e.target.value)} + placeholder="Category..." + maxLength={20} + className="rounded-md border border-blue-400 bg-white px-2 py-0.5 text-[11px] text-neutral-800 focus:outline-none dark:border-blue-500 dark:bg-black dark:text-neutral-100" + autoFocus + /> + + +
+ ) : ( + + )} +
+ )}
@@ -302,7 +448,7 @@ export function QuickReplies({ onSelect, showToast }) { aria-label="Category" className="bg-transparent text-xs text-neutral-500 dark:text-neutral-400 focus:outline-none border-l border-neutral-200 dark:border-neutral-700 pl-1.5 mr-1 cursor-pointer" > - {CATEGORIES.map((cat) => ( + {categories.map((cat) => ( @@ -429,7 +575,7 @@ export function QuickReplies({ onSelect, showToast }) { aria-label="Category" className="bg-transparent text-xs text-neutral-500 dark:text-neutral-400 focus:outline-none border-l border-neutral-200 dark:border-neutral-700 pl-1.5 mr-1 cursor-pointer" > - {CATEGORIES.map((cat) => ( + {categories.map((cat) => ( diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx index 9413b492..215d0295 100644 --- a/client/src/pages/Settings.jsx +++ b/client/src/pages/Settings.jsx @@ -134,12 +134,14 @@ export default function Settings() { history: localStorage.getItem("vf_history"), favorites: localStorage.getItem("vf_favorites"), quick_replies: localStorage.getItem("vf_quick_replies"), + quick_reply_categories: localStorage.getItem("vf_quick_reply_categories"), voiceSettings: localStorage.getItem("voiceforge:voiceSettings"), accessibilitySettings: localStorage.getItem(ACCESSIBILITY_SETTINGS_KEY), language: localStorage.getItem(LANGUAGE_STORAGE_KEY), calibrationXOffset: localStorage.getItem("voiceforge:calibrationXOffset"), calibrationYOffset: localStorage.getItem("voiceforge:calibrationYOffset"), calibrationScale: localStorage.getItem("voiceforge:calibrationScale"), + historyRetention: localStorage.getItem("vf_history_retention"), }; const rawProfiles = await getSavedProfiles(); @@ -250,12 +252,14 @@ export default function Settings() { history: "vf_history", favorites: "vf_favorites", quick_replies: "vf_quick_replies", + quick_reply_categories: "vf_quick_reply_categories", voiceSettings: "voiceforge:voiceSettings", accessibilitySettings: ACCESSIBILITY_SETTINGS_KEY, language: LANGUAGE_STORAGE_KEY, calibrationXOffset: "voiceforge:calibrationXOffset", calibrationYOffset: "voiceforge:calibrationYOffset", calibrationScale: "voiceforge:calibrationScale", + historyRetention: "vf_history_retention", }; for (const [backupKey, storageKey] of Object.entries(keysMap)) {