From 690c5fec9862f07da55de2d0c7cd1ee5fd129c2b Mon Sep 17 00:00:00 2001 From: JohnDoe6345789 Date: Tue, 30 Dec 2025 11:20:40 +0000 Subject: [PATCH] feat: Add Toast context and provider for notification management --- fakemui/fakemui/utils/ToastContext.tsx | 235 ++++++++++++++++++ packages/quick_guide/seed/scripts/media.lua | 113 +++++++-- packages/quick_guide/seed/scripts/steps.lua | 158 ++++++++++-- .../user_manager/seed/scripts/actions.lua | 78 ++---- packages/user_manager/seed/scripts/init.lua | 11 +- packages/user_manager/seed/scripts/list.lua | 46 +--- 6 files changed, 508 insertions(+), 133 deletions(-) create mode 100644 fakemui/fakemui/utils/ToastContext.tsx diff --git a/fakemui/fakemui/utils/ToastContext.tsx b/fakemui/fakemui/utils/ToastContext.tsx new file mode 100644 index 000000000..357c1105a --- /dev/null +++ b/fakemui/fakemui/utils/ToastContext.tsx @@ -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> { + id: string + action?: React.ReactNode + onClose?: () => void + anchorOrigin: NonNullable +} + +interface ToastContextValue { + /** Show a toast notification */ + toast: (options: ToastOptions | string) => string + /** Show a success toast */ + success: (message: string, options?: Omit) => string + /** Show an error toast */ + error: (message: string, options?: Omit) => string + /** Show a warning toast */ + warning: (message: string, options?: Omit) => string + /** Show an info toast */ + info: (message: string, options?: Omit) => string + /** Close a specific toast by ID */ + close: (id: string) => void + /** Close all toasts */ + closeAll: () => void +} + +const ToastContext = createContext(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 = ({ + children, + defaultAutoHideDuration = 5000, + maxToasts = 3, + defaultAnchorOrigin = { vertical: 'bottom', horizontal: 'left' }, +}) => { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>(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) => { + return toast({ ...options, message, severity: 'success' }) + }, [toast]) + + const error = useCallback((message: string, options?: Omit) => { + return toast({ ...options, message, severity: 'error' }) + }, [toast]) + + const warning = useCallback((message: string, options?: Omit) => { + return toast({ ...options, message, severity: 'warning' }) + }, [toast]) + + const info = useCallback((message: string, options?: Omit) => { + 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 ( + + {children} + {/* Render toasts */} + {toasts.map(t => ( + close(t.id)} + anchorOrigin={t.anchorOrigin} + > + close(t.id)} + /> + + ))} + + ) +} + +/** + * 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: + * }) + * ``` + */ +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 diff --git a/packages/quick_guide/seed/scripts/media.lua b/packages/quick_guide/seed/scripts/media.lua index c86e12475..78c25e41d 100644 --- a/packages/quick_guide/seed/scripts/media.lua +++ b/packages/quick_guide/seed/scripts/media.lua @@ -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 diff --git a/packages/quick_guide/seed/scripts/steps.lua b/packages/quick_guide/seed/scripts/steps.lua index 6b3fa2866..d53aa228d 100644 --- a/packages/quick_guide/seed/scripts/steps.lua +++ b/packages/quick_guide/seed/scripts/steps.lua @@ -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 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 diff --git a/packages/user_manager/seed/scripts/actions.lua b/packages/user_manager/seed/scripts/actions.lua index 3f4da9212..e718987e4 100644 --- a/packages/user_manager/seed/scripts/actions.lua +++ b/packages/user_manager/seed/scripts/actions.lua @@ -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 diff --git a/packages/user_manager/seed/scripts/init.lua b/packages/user_manager/seed/scripts/init.lua index a9357db68..c23f8a986 100644 --- a/packages/user_manager/seed/scripts/init.lua +++ b/packages/user_manager/seed/scripts/init.lua @@ -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, diff --git a/packages/user_manager/seed/scripts/list.lua b/packages/user_manager/seed/scripts/list.lua index 017c6c8d3..5c68036a6 100644 --- a/packages/user_manager/seed/scripts/list.lua +++ b/packages/user_manager/seed/scripts/list.lua @@ -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