-
Notifications
You must be signed in to change notification settings - Fork 98
feat: Add Custom Category Management to Quick Replies #1163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| }); | ||
|
Comment on lines
+22
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Enforce one category invariant at every storage boundary. An empty array,
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
|
Kritika200520 marked this conversation as resolved.
|
||
| } | ||
|
Kritika200520 marked this conversation as resolved.
|
||
| } | ||
| } 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); | ||
|
Kritika200520 marked this conversation as resolved.
|
||
| setNewCategoryInput(""); | ||
| setIsAddingCategory(false); | ||
| showToast("Category added", "success"); | ||
| window.dispatchEvent(new Event("voiceforge:quickReplyCategoriesChanged")); | ||
|
Kritika200520 marked this conversation as resolved.
|
||
| }; | ||
|
Comment on lines
+154
to
+160
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Persist category changes before emitting the category-change event.
🧰 Tools🪛 ast-grep (0.45.0)[warning] 154-154: Avoid using the initial state variable in setState (setstate-same-var) 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
|
|
||
| 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; | ||
| }) | ||
| ); | ||
|
Comment on lines
+168
to
+178
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Reset open reply forms when deleting their selected category. If 🧰 Tools🪛 ast-grep (0.45.0)[warning] 168-168: Avoid using the initial state variable in setState (setstate-same-var) 🤖 Prompt for AI Agents |
||
|
|
||
| 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 */} | ||
| <div | ||
| ref={tablistRef} | ||
| className="mb-3 flex overflow-x-auto gap-1.5 pb-1 no-scrollbar" | ||
| className="mb-3 flex overflow-x-auto gap-1.5 pb-1 no-scrollbar items-center" | ||
| role="tablist" | ||
| aria-label="Quick replies categories" | ||
| onKeyDown={handleTabKeyDown} | ||
| > | ||
| {allCats.map((cat) => ( | ||
| <button | ||
| key={cat} | ||
| role="tab" | ||
| aria-selected={selectedCategoryTab === cat} | ||
| aria-controls={`tabpanel-${cat}`} | ||
| tabIndex={selectedCategoryTab === cat ? 0 : -1} | ||
| onClick={() => setSelectedCategoryTab(cat)} | ||
| className={[ | ||
| "rounded-md px-2.5 py-1 text-xs font-semibold transition-colors duration-150 shrink-0", | ||
| "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-black", | ||
| selectedCategoryTab === cat | ||
| ? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300" | ||
| : "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-surface dark:hover:text-neutral-300", | ||
| ].join(" ")} | ||
| > | ||
| {cat} | ||
| </button> | ||
| ))} | ||
| {["All", ...categories].map((cat) => { | ||
| const isDefault = DEFAULT_CATEGORIES.includes(cat); | ||
| return ( | ||
| <div key={cat} className="flex items-center gap-0.5 shrink-0"> | ||
| <button | ||
| role="tab" | ||
| aria-selected={selectedCategoryTab === cat} | ||
| aria-controls={`tabpanel-${cat}`} | ||
| tabIndex={selectedCategoryTab === cat ? 0 : -1} | ||
| onClick={() => setSelectedCategoryTab(cat)} | ||
| className={[ | ||
| "rounded-md px-2.5 py-1 text-xs font-semibold transition-colors duration-150 shrink-0", | ||
| "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-black", | ||
| selectedCategoryTab === cat | ||
| ? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300" | ||
| : "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-surface dark:hover:text-neutral-300", | ||
| ].join(" ")} | ||
| > | ||
| {cat} | ||
| </button> | ||
| {isEditing && !isDefault && cat !== "All" && ( | ||
| <button | ||
| type="button" | ||
| onClick={() => handleDeleteCategory(cat)} | ||
| aria-label={`Delete category ${cat}`} | ||
| className="p-0.5 text-neutral-400 hover:text-red-500 transition-colors" | ||
| > | ||
| <X size={10} aria-hidden="true" /> | ||
| </button> | ||
| )} | ||
| </div> | ||
| ); | ||
| })} | ||
|
|
||
| {isEditing && ( | ||
|
Kritika200520 marked this conversation as resolved.
|
||
| <div className="flex items-center gap-1 shrink-0 ml-1"> | ||
| {isAddingCategory ? ( | ||
| <form onSubmit={handleAddCategory} className="flex items-center gap-1"> | ||
| <input | ||
| type="text" | ||
| value={newCategoryInput} | ||
| onChange={(e) => 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 | ||
| /> | ||
| <button | ||
| type="submit" | ||
| aria-label="Save category" | ||
| className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors" | ||
| > | ||
| <Check size={10} aria-hidden="true" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setIsAddingCategory(false); | ||
| setNewCategoryInput(""); | ||
| }} | ||
| aria-label="Cancel" | ||
| className="flex h-5 w-5 items-center justify-center rounded-full text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-200 transition-colors" | ||
| > | ||
| <X size={10} aria-hidden="true" /> | ||
| </button> | ||
| </form> | ||
| ) : ( | ||
| <button | ||
| type="button" | ||
| onClick={() => setIsAddingCategory(true)} | ||
| className="flex items-center gap-0.5 rounded-md border border-dashed border-neutral-300 px-2 py-0.5 text-[11px] font-semibold text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-neutral-700 dark:text-neutral-400 dark:hover:border-neutral-600 dark:hover:text-neutral-300 transition-colors" | ||
| > | ||
| <Plus size={10} aria-hidden="true" /> | ||
| <span>Category</span> | ||
| </button> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Quick reply phrases"> | ||
|
|
@@ -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) => ( | ||
| <option key={cat} value={cat} className="dark:bg-neutral-900 dark:text-neutral-100"> | ||
| {cat} | ||
| </option> | ||
|
|
@@ -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) => ( | ||
| <option key={cat} value={cat} className="dark:bg-neutral-900 dark:text-neutral-100"> | ||
| {cat} | ||
| </option> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Replace the stale
CATEGORIESreference.Line 270 expands
CATEGORIES, but this change renamed that constant toDEFAULT_CATEGORIES.CATEGORIESis undeclared, so everyQuickRepliesrender throws before the tabs display. BuildallCatsfromcategoriesso keyboard navigation also includes custom categories.🤖 Prompt for AI Agents