feat: Add Toast context and provider for notification management

This commit is contained in:
2025-12-30 11:20:40 +00:00
parent ede65e3694
commit 690c5fec98
6 changed files with 508 additions and 133 deletions

View File

@@ -0,0 +1,235 @@
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import { Snackbar, SnackbarContent } from '../feedback/Snackbar'
export type ToastSeverity = 'success' | 'error' | 'warning' | 'info'
export interface ToastOptions {
/** Toast message content */
message: string
/** Severity level for styling */
severity?: ToastSeverity
/** Auto hide duration in ms (null to disable) */
autoHideDuration?: number | null
/** Action button content */
action?: React.ReactNode
/** Custom key for deduplication */
key?: string
/** Callback when toast closes */
onClose?: () => void
/** Anchor position */
anchorOrigin?: {
vertical: 'top' | 'bottom'
horizontal: 'left' | 'center' | 'right'
}
}
interface Toast extends Required<Pick<ToastOptions, 'message' | 'severity' | 'autoHideDuration'>> {
id: string
action?: React.ReactNode
onClose?: () => void
anchorOrigin: NonNullable<ToastOptions['anchorOrigin']>
}
interface ToastContextValue {
/** Show a toast notification */
toast: (options: ToastOptions | string) => string
/** Show a success toast */
success: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Show an error toast */
error: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Show a warning toast */
warning: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Show an info toast */
info: (message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => string
/** Close a specific toast by ID */
close: (id: string) => void
/** Close all toasts */
closeAll: () => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
let toastIdCounter = 0
const generateId = () => `toast-${++toastIdCounter}`
export interface ToastProviderProps {
children: React.ReactNode
/** Default auto hide duration in ms */
defaultAutoHideDuration?: number
/** Maximum number of toasts to show at once */
maxToasts?: number
/** Default anchor position */
defaultAnchorOrigin?: ToastOptions['anchorOrigin']
}
export const ToastProvider: React.FC<ToastProviderProps> = ({
children,
defaultAutoHideDuration = 5000,
maxToasts = 3,
defaultAnchorOrigin = { vertical: 'bottom', horizontal: 'left' },
}) => {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
// Clear timer when toast is removed
const clearTimer = useCallback((id: string) => {
const timer = timersRef.current.get(id)
if (timer) {
clearTimeout(timer)
timersRef.current.delete(id)
}
}, [])
// Close a specific toast
const close = useCallback((id: string) => {
clearTimer(id)
setToasts(prev => {
const toast = prev.find(t => t.id === id)
if (toast?.onClose) {
toast.onClose()
}
return prev.filter(t => t.id !== id)
})
}, [clearTimer])
// Close all toasts
const closeAll = useCallback(() => {
timersRef.current.forEach((_, id) => clearTimer(id))
setToasts([])
}, [clearTimer])
// Main toast function
const toast = useCallback((options: ToastOptions | string): string => {
const opts: ToastOptions = typeof options === 'string' ? { message: options } : options
const id = opts.key || generateId()
// Check if toast with same key already exists
setToasts(prev => {
const existingIndex = prev.findIndex(t => t.id === id)
const newToast: Toast = {
id,
message: opts.message,
severity: opts.severity || 'info',
autoHideDuration: opts.autoHideDuration ?? defaultAutoHideDuration,
action: opts.action,
onClose: opts.onClose,
anchorOrigin: opts.anchorOrigin || defaultAnchorOrigin,
}
let newToasts: Toast[]
if (existingIndex >= 0) {
// Update existing toast
newToasts = [...prev]
newToasts[existingIndex] = newToast
} else {
// Add new toast (respecting maxToasts)
newToasts = [...prev, newToast]
if (newToasts.length > maxToasts) {
const removed = newToasts.shift()
if (removed) clearTimer(removed.id)
}
}
return newToasts
})
// Set up auto-hide timer
const duration = opts.autoHideDuration ?? defaultAutoHideDuration
if (duration !== null && duration > 0) {
clearTimer(id) // Clear existing timer if updating
const timer = setTimeout(() => close(id), duration)
timersRef.current.set(id, timer)
}
return id
}, [defaultAutoHideDuration, defaultAnchorOrigin, maxToasts, clearTimer, close])
// Helper methods for each severity
const success = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'success' })
}, [toast])
const error = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'error' })
}, [toast])
const warning = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'warning' })
}, [toast])
const info = useCallback((message: string, options?: Omit<ToastOptions, 'message' | 'severity'>) => {
return toast({ ...options, message, severity: 'info' })
}, [toast])
// Cleanup timers on unmount
useEffect(() => {
return () => {
timersRef.current.forEach(timer => clearTimeout(timer))
}
}, [])
const contextValue: ToastContextValue = {
toast,
success,
error,
warning,
info,
close,
closeAll,
}
return (
<ToastContext.Provider value={contextValue}>
{children}
{/* Render toasts */}
{toasts.map(t => (
<Snackbar
key={t.id}
open={true}
autoHideDuration={null} // Handled by context
onClose={() => close(t.id)}
anchorOrigin={t.anchorOrigin}
>
<SnackbarContent
message={t.message}
severity={t.severity}
action={t.action}
onClose={() => close(t.id)}
/>
</Snackbar>
))}
</ToastContext.Provider>
)
}
/**
* Hook to access toast notifications
*
* @example
* ```tsx
* const { toast, success, error } = useToast()
*
* // Simple usage
* toast('Hello world')
*
* // With severity helpers
* success('Operation completed!')
* error('Something went wrong')
*
* // With options
* toast({
* message: 'Custom toast',
* severity: 'warning',
* autoHideDuration: 3000,
* action: <button onClick={() => {}}>Undo</button>
* })
* ```
*/
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
export default ToastProvider

View File

@@ -1,22 +1,105 @@
--- Media pane logic facade for quick guides
--- Re-exports media functions for backward compatibility
---@module media
-- Media pane logic for quick guides
local is_valid_url = require("is_valid_url")
local is_image_url = require("is_image_url")
local is_video_url = require("is_video_url")
local prepare_media_state = require("prepare_media_state")
local handle_thumbnail_change = require("handle_thumbnail_change")
local handle_video_change = require("handle_video_change")
---@class MediaState
---@field thumbnailUrl string Thumbnail URL
---@field videoUrl string Video URL
---@field thumbnailValid boolean Whether thumbnail URL is valid
---@field videoValid boolean Whether video URL is valid
---@field thumbnailIsImage boolean Whether thumbnail is an image URL
---@field videoIsVideo boolean Whether video is a video URL
---@class MediaProps
---@field thumbnailUrl? string Initial thumbnail URL
---@field videoUrl? string Initial video URL
---@class MediaModule
local M = {}
M.isValidUrl = is_valid_url
M.isImageUrl = is_image_url
M.isVideoUrl = is_video_url
M.prepareMediaState = prepare_media_state
M.handleThumbnailChange = handle_thumbnail_change
M.handleVideoChange = handle_video_change
---Validate a URL (basic check)
---@param url? string URL to validate
---@return boolean Whether URL is valid
function M.isValidUrl(url)
if not url or url == "" then
return false
end
return string.match(url, "^https?://") ~= nil
end
---Check if URL is an image
---@param url? string URL to check
---@return boolean Whether URL points to an image
function M.isImageUrl(url)
if not M.isValidUrl(url) then
return false
end
local patterns = { "%.png$", "%.jpg$", "%.jpeg$", "%.gif$", "%.webp$", "%.svg$" }
for _, pattern in ipairs(patterns) do
if string.match(url:lower(), pattern) then
return true
end
end
return false
end
---Check if URL is a video embed
---@param url? string URL to check
---@return boolean Whether URL points to a video
function M.isVideoUrl(url)
if not M.isValidUrl(url) then
return false
end
local patterns = { "youtube%.com", "vimeo%.com", "%.mp4$", "%.webm$" }
for _, pattern in ipairs(patterns) do
if string.match(url:lower(), pattern) then
return true
end
end
return false
end
---Prepare media state from props
---@param props? MediaProps Input props
---@return MediaState Initial media state
function M.prepareMediaState(props)
props = props or {}
return {
thumbnailUrl = props.thumbnailUrl or "",
videoUrl = props.videoUrl or "",
thumbnailValid = M.isValidUrl(props.thumbnailUrl),
videoValid = M.isValidUrl(props.videoUrl),
thumbnailIsImage = M.isImageUrl(props.thumbnailUrl),
videoIsVideo = M.isVideoUrl(props.videoUrl)
}
end
---Handle thumbnail URL change
---@param state MediaState Current state
---@param newUrl string New thumbnail URL
---@return MediaState Updated state
function M.handleThumbnailChange(state, newUrl)
return {
thumbnailUrl = newUrl,
videoUrl = state.videoUrl,
thumbnailValid = M.isValidUrl(newUrl),
videoValid = state.videoValid,
thumbnailIsImage = M.isImageUrl(newUrl),
videoIsVideo = state.videoIsVideo
}
end
---Handle video URL change
---@param state MediaState Current state
---@param newUrl string New video URL
---@return MediaState Updated state
function M.handleVideoChange(state, newUrl)
return {
thumbnailUrl = state.thumbnailUrl,
videoUrl = newUrl,
thumbnailValid = state.thumbnailValid,
videoValid = M.isValidUrl(newUrl),
thumbnailIsImage = state.thumbnailIsImage,
videoIsVideo = M.isVideoUrl(newUrl)
}
end
return M

View File

@@ -1,26 +1,146 @@
--- Steps editor logic facade for quick guides
--- Re-exports step functions for backward compatibility
---@module steps
-- Steps editor logic for quick guides
local generate_step_id = require("generate_step_id")
local create_step = require("create_step")
local update_step = require("update_step")
local remove_step = require("remove_step")
local add_step = require("add_step")
local reset_ordering = require("reset_ordering")
local validate_step = require("validate_step")
local validate_all_steps = require("validate_all_steps")
---@class Step
---@field id string Unique step identifier
---@field title string Step title
---@field description string Step description
---@field duration string Estimated duration
---@field mediaUrl? string Optional media URL
---@class StepValidationErrors
---@field title? string Title error message
---@field description? string Description error message
---@class StepValidationResult
---@field valid boolean Whether step is valid
---@field errors StepValidationErrors Validation errors
---@class AllStepsValidationResult
---@field valid boolean Whether all steps are valid
---@field errors table<string, StepValidationErrors> Errors by step ID
---@class StepsModule
local M = {}
M.generateStepId = generate_step_id
M.createStep = create_step
M.updateStep = update_step
M.removeStep = remove_step
M.addStep = add_step
M.resetOrdering = reset_ordering
M.validateStep = validate_step
M.validateAllSteps = validate_all_steps
---Generate a unique step ID
---@return string Unique step identifier
function M.generateStepId()
return "step_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
end
---Create a new empty step
---@return Step New step with default values
function M.createStep()
return {
id = M.generateStepId(),
title = "New step",
description = "Describe what happens in this step.",
duration = "1-2 min",
mediaUrl = nil
}
end
---Update a step in the list
---@param steps Step[] Array of steps
---@param stepId string ID of step to update
---@param updates table Partial step updates
---@return Step[] Updated steps array
function M.updateStep(steps, stepId, updates)
local result = {}
for i, step in ipairs(steps) do
if step.id == stepId then
local updatedStep = {}
for k, v in pairs(step) do
updatedStep[k] = v
end
for k, v in pairs(updates) do
updatedStep[k] = v
end
result[i] = updatedStep
else
result[i] = step
end
end
return result
end
---Remove a step from the list
---@param steps Step[] Array of steps
---@param stepId string ID of step to remove
---@return Step[] Updated steps array
function M.removeStep(steps, stepId)
local result = {}
for _, step in ipairs(steps) do
if step.id ~= stepId then
result[#result + 1] = step
end
end
return result
end
---Add a new step to the list
---@param steps Step[] Array of steps
---@return Step[] Updated steps array
---@return Step New step that was added
function M.addStep(steps)
local newStep = M.createStep()
local result = {}
for i, step in ipairs(steps) do
result[i] = step
end
result[#result + 1] = newStep
return result, newStep
end
---Reset step IDs to sequential order
---@param steps Step[] Array of steps
---@return Step[] Steps with reset IDs
function M.resetOrdering(steps)
local result = {}
for i, step in ipairs(steps) do
local resetStep = {}
for k, v in pairs(step) do
resetStep[k] = v
end
resetStep.id = "step_" .. tostring(i)
result[i] = resetStep
end
return result
end
---Validate a single step
---@param step Step Step to validate
---@return StepValidationResult Validation result
function M.validateStep(step)
local errors = {}
if not step.title or step.title == "" then
errors.title = "Title is required"
end
if not step.description or step.description == "" then
errors.description = "Description is required"
end
return { valid = next(errors) == nil, errors = errors }
end
---Validate all steps
---@param steps Step[] Array of steps to validate
---@return AllStepsValidationResult Validation result for all steps
function M.validateAllSteps(steps)
local allErrors = {}
local valid = true
for i, step in ipairs(steps) do
local result = M.validateStep(step)
if not result.valid then
valid = false
allErrors[step.id] = result.errors
end
end
return { valid = valid, errors = allErrors }
end
return M

View File

@@ -1,70 +1,20 @@
-- User management actions
--- User management actions facade
--- Re-exports user action functions for backward compatibility
---@module actions
---@class UserAction
---@field action string
---@field user_id string?
---@field data table?
---@field confirm boolean?
---@field level number?
---@field active boolean?
local create_user = require("create_user")
local update_user = require("update_user")
local delete_user = require("delete_user")
local change_level = require("change_level")
local toggle_active = require("toggle_active")
---@class ActionsModule
local M = {}
---Create a new user
---@param data table
---@return UserAction
function M.create(data)
return {
action = "create_user",
data = data
}
end
---Update an existing user
---@param user_id string
---@param data table
---@return UserAction
function M.update(user_id, data)
return {
action = "update_user",
user_id = user_id,
data = data
}
end
---Delete a user
---@param user_id string
---@return UserAction
function M.delete(user_id)
return {
action = "delete_user",
user_id = user_id,
confirm = true
}
end
---Change user access level
---@param user_id string
---@param new_level number
---@return UserAction
function M.change_level(user_id, new_level)
return {
action = "change_level",
user_id = user_id,
level = new_level
}
end
---Toggle user active status
---@param user_id string
---@param active boolean
---@return UserAction
function M.toggle_active(user_id, active)
return {
action = "toggle_active",
user_id = user_id,
active = active
}
end
M.create = create_user
M.update = update_user
M.delete = delete_user
M.change_level = change_level
M.toggle_active = toggle_active
return M

View File

@@ -1,9 +1,18 @@
-- User Manager initialization
--- User Manager initialization
--- Package entry point for user management utilities
---@module init
---@class UserManagerModule
---@field name string Package name
---@field version string Package version
---@field init fun(): table Initialize the module
local M = {}
M.name = "user_manager"
M.version = "1.0.0"
--- Initialize the user manager module
---@return table Module info
function M.init()
return {
name = M.name,

View File

@@ -1,38 +1,16 @@
-- User list rendering
--- User list rendering facade
--- Re-exports user list functions for backward compatibility
---@module list
local get_columns = require("get_columns")
local render_row = require("render_row")
local render_users = require("render_users")
---@class ListModule
local M = {}
function M.columns()
return {
{ id = "username", label = "Username", sortable = true },
{ id = "email", label = "Email", sortable = true },
{ id = "role", label = "Role", sortable = true },
{ id = "level", label = "Level", sortable = true },
{ id = "active", label = "Status", type = "badge" },
{ id = "actions", label = "", type = "actions" }
}
end
function M.render_row(user)
return {
username = user.username,
email = user.email,
role = user.role,
level = user.level,
active = user.active and "Active" or "Inactive",
actions = { "edit", "delete" }
}
end
function M.render(users)
local rows = {}
for _, user in ipairs(users) do
table.insert(rows, M.render_row(user))
end
return {
type = "data_table",
columns = M.columns(),
rows = rows
}
end
M.columns = get_columns
M.render_row = render_row
M.render = render_users
return M