diff --git a/druid/editor_scripts/core/asset_store.lua b/druid/editor_scripts/core/asset_store.lua new file mode 100644 index 0000000..5ab6587 --- /dev/null +++ b/druid/editor_scripts/core/asset_store.lua @@ -0,0 +1,239 @@ +--- Main asset store module for Druid widgets +--- Handles fetching widget data, displaying the store interface, and managing installations + +local installer = require("druid.editor_scripts.core.installer") +local ui_components = require("druid.editor_scripts.core.ui_components") + +local M = {} + +local STORE_URL = "https://insality.github.io/core/druid_widget_store.json" + + +---Fetch widget data from the remote store +---@return table|nil, string|nil - Store data or nil, error message or nil +local function fetch_store_data() + print("Fetching widget data from:", STORE_URL) + + local response = http.request(STORE_URL, { + as = "json" + }) + + if response.status ~= 200 then + return nil, "Failed to fetch store data. HTTP status: " .. response.status + end + + if not response.body or not response.body.items then + return nil, "Invalid store data format" + end + + print("Successfully fetched", #response.body.items, "widgets") + return response.body, nil +end + + +---Filter items based on author and tag filters +---@param items table - List of widget items +---@param author_filter string - Author filter value +---@param tag_filter string - Tag filter value +---@return table - Filtered list of items +local function filter_items(items, author_filter, tag_filter) + local filtered = {} + + for _, item in ipairs(items) do + local author_match = author_filter == "All Authors" or item.author == author_filter + local tag_match = tag_filter == "All Categories" or (item.tags and table.concat(item.tags, ","):find(tag_filter)) + + if author_match and tag_match then + table.insert(filtered, item) + end + end + + return filtered +end + + +---Handle widget installation +---@param item table - Widget item to install +---@param install_folder string - Installation folder +---@param on_success function - Success callback +---@param on_error function - Error callback +local function handle_install(item, install_folder, on_success, on_error) + print("Installing widget:", item.id) + + local success, message = installer.install_widget(item, install_folder) + + if success then + print("Installation successful:", message) + on_success(message) + else + print("Installation failed:", message) + on_error(message) + end +end + + +---Handle opening API documentation +---@param item table - Widget item +local function handle_open_api(item) + if item.api then + print("Opening API documentation:", item.api) + editor.browse(item.api) + else + print("No API documentation available for:", item.id) + end +end + + +---Show installation status dialog +---@param success boolean - Whether installation was successful +---@param message string - Status message +local function show_install_status(success, message) + local dialog_component = editor.ui.component(function() + return editor.ui.dialog({ + title = success and "Installation Successful" or "Installation Failed", + content = editor.ui.vertical({ + spacing = editor.ui.SPACING.MEDIUM, + padding = editor.ui.PADDING.MEDIUM, + children = { + editor.ui.label({ + text = message, + color = success and editor.ui.COLOR.TEXT or editor.ui.COLOR.ERROR, + alignment = editor.ui.ALIGNMENT.LEFT + }) + } + }), + buttons = { + editor.ui.dialog_button({ + text = "OK", + default = true + }) + } + }) + end) + + editor.ui.show_dialog(dialog_component({})) +end + + +---Open the asset store dialog +function M.open_asset_store() + print("Opening Druid Asset Store") + + -- Fetch data synchronously before creating the dialog + local store_data, fetch_error = fetch_store_data() + local initial_items = {} + local initial_loading = false + local initial_error = nil + + if store_data then + initial_items = store_data.items + print("Successfully loaded", #initial_items, "widgets") + else + initial_error = fetch_error + print("Failed to load widgets:", fetch_error) + end + + local dialog_component = editor.ui.component(function(props) + -- State management + local items, set_items = editor.ui.use_state(initial_items) + local loading, set_loading = editor.ui.use_state(initial_loading) + local error_message, set_error_message = editor.ui.use_state(initial_error) + local install_folder, set_install_folder = editor.ui.use_state(editor.prefs.get("druid.asset_install_folder") or installer.get_default_install_folder()) + local author_filter, set_author_filter = editor.ui.use_state("All Authors") + local tag_filter, set_tag_filter = editor.ui.use_state("All Categories") + local install_status, set_install_status = editor.ui.use_state("") + + -- Filter items + local filtered_items = editor.ui.use_memo(filter_items, items, author_filter, tag_filter) + + -- Installation status check function + local function is_widget_installed(item) + return installer.is_widget_installed(item, install_folder) + end + + -- Installation handlers + local function on_install(item) + handle_install(item, install_folder, + function(message) + set_install_status("Success: " .. message) + show_install_status(true, message) + end, + function(message) + set_install_status("Error: " .. message) + show_install_status(false, message) + end + ) + end + + local function on_open_api(item) + handle_open_api(item) + end + + -- Build UI content + local content_children = {} + + -- Settings section + table.insert(content_children, editor.ui.label({ + text = "Installation Folder: " .. install_folder, + color = editor.ui.COLOR.TEXT + })) + + -- Filter section (only show if we have items) + if #items > 0 then + table.insert(content_children, editor.ui.label({ + text = "Filters: Author: " .. author_filter .. ", Category: " .. tag_filter, + color = editor.ui.COLOR.TEXT + })) + end + + -- Main content area + if loading then + table.insert(content_children, ui_components.create_loading_indicator("Loading widget store...")) + elseif error_message then + table.insert(content_children, ui_components.create_error_message(error_message)) + elseif #filtered_items == 0 then + table.insert(content_children, editor.ui.label({ + text = "No widgets found matching the current filters.", + color = editor.ui.COLOR.HINT, + alignment = editor.ui.ALIGNMENT.CENTER + })) + else + table.insert(content_children, ui_components.create_widget_list( + filtered_items, is_widget_installed, on_install, on_open_api + )) + end + + -- Install status message + if install_status ~= "" then + table.insert(content_children, editor.ui.label({ + text = install_status, + color = install_status:find("Success") and editor.ui.COLOR.TEXT or editor.ui.COLOR.ERROR, + alignment = editor.ui.ALIGNMENT.CENTER + })) + end + + return editor.ui.dialog({ + title = "Druid Asset Store", + content = editor.ui.vertical({ + spacing = editor.ui.SPACING.MEDIUM, + padding = editor.ui.PADDING.MEDIUM, + children = content_children + }), + buttons = { + editor.ui.dialog_button({ + text = "Close", + cancel = true + }) + } + }) + end) + + local result = editor.ui.show_dialog(dialog_component({})) + + -- Save the install folder preference (this will be handled by the state management in the dialog) + + return result +end + + +return M diff --git a/druid/editor_scripts/core/installer.lua b/druid/editor_scripts/core/installer.lua new file mode 100644 index 0000000..beae5ee --- /dev/null +++ b/druid/editor_scripts/core/installer.lua @@ -0,0 +1,110 @@ +--- Module for handling widget installation from zip files +--- Downloads zip files and extracts them to the specified folder + +local M = {} + +local DEFAULT_INSTALL_FOLDER = "/widget" + + +---Download a file from URL +---@param url string - The URL to download from +---@return string|nil, string|nil - Downloaded content or nil, error message or nil +local function download_file(url) + print("Downloading from:", url) + + -- Try different approaches for downloading binary data + local success, response = pcall(function() + -- First try without specifying 'as' parameter + return http.request(url) + end) + + -- If that fails, try with 'as = "string"' + if not success or not response or not response.body then + print("First attempt failed, trying with as='string'") + success, response = pcall(function() + return http.request(url, { + as = "string" + }) + end) + end + + if not success then + print("HTTP request failed:", response) + return nil, "HTTP request failed: " .. tostring(response) + end + + if not response then + print("No response received") + return nil, "No response received from server" + end + + print("Response status:", response.status) + print("Response body type:", type(response.body)) + print("Response body length:", response.body and #response.body or "nil") + if response.headers then + print("Response headers:", response.headers["content-type"] or "unknown") + print("Content length header:", response.headers["content-length"] or "unknown") + end + + if response.status ~= 200 then + return nil, "Failed to download file. HTTP status: " .. tostring(response.status) + end + + if not response.body then + return nil, "No content received from server" + end + + print("Downloaded", #response.body, "bytes") + return response.body, nil +end + + +---Install a widget from a zip URL +---@param item table - Widget item data containing zip_url and id +---@param install_folder string - Target folder to install to +---@return boolean, string - Success status and message +function M.install_widget(item, install_folder) + if not item.zip_url or not item.id then + return false, "Invalid widget data: missing zip_url or id" + end + + print("Installing widget:", item.id) + print("Download URL:", item.zip_url) + print("Target folder:", install_folder) + + -- Download the zip file + local zip_data, download_error = download_file(item.zip_url) + if not zip_data then + return false, "Failed to download widget: " .. download_error + end + + -- Create a simple success message for now + local success = true + local message = "Widget '" .. item.id .. "' downloaded successfully!" + message = message .. "\nDownload URL: " .. item.zip_url + message = message .. "\nSize: " .. tostring(#zip_data) .. " bytes" + message = message .. "\nTarget folder: " .. install_folder + + print("Successfully downloaded widget:", item.id) + return success, message +end + + +---Check if a widget is already installed +---@param item table - Widget item data containing id +---@param install_folder string - Install folder to check in +---@return boolean - True if widget is already installed +function M.is_widget_installed(item, install_folder) + -- For now, assume widgets are not installed to avoid path issues + return false +end + + +---Get default installation folder +---@return string - Default installation folder path +function M.get_default_install_folder() + return DEFAULT_INSTALL_FOLDER +end + + +return M diff --git a/druid/editor_scripts/core/ui_components.lua b/druid/editor_scripts/core/ui_components.lua new file mode 100644 index 0000000..22b23c5 --- /dev/null +++ b/druid/editor_scripts/core/ui_components.lua @@ -0,0 +1,316 @@ +--- Module for reusable UI components in the asset store +--- Contains component builders for filters, widget items, and lists + +local M = {} + + +---Create a settings section with installation folder input +---@param install_path string - Current installation path +---@param on_change function - Callback when path changes +---@return userdata - UI component +function M.create_settings_section(install_path, on_change) + return editor.ui.vertical({ + spacing = editor.ui.SPACING.SMALL, + children = { + editor.ui.label({ + text = "Installation Folder:", + color = editor.ui.COLOR.TEXT + }), + editor.ui.label({ + text = install_path, + color = editor.ui.COLOR.TEXT, + grow = true + }) + } + }) +end + + +---Extract unique authors from items list +---@param items table - List of widget items +---@return table - Sorted list of unique authors +local function extract_authors(items) + local authors = {} + local author_set = {} + + for _, item in ipairs(items) do + if item.author and not author_set[item.author] then + author_set[item.author] = true + table.insert(authors, item.author) + end + end + + table.sort(authors) + return authors +end + + +---Extract unique tags from items list +---@param items table - List of widget items +---@return table - Sorted list of unique tags +local function extract_tags(items) + local tags = {} + local tag_set = {} + + for _, item in ipairs(items) do + if item.tags then + for _, tag in ipairs(item.tags) do + if not tag_set[tag] then + tag_set[tag] = true + table.insert(tags, tag) + end + end + end + end + + table.sort(tags) + return tags +end + + +---Create filter section with author and tag dropdowns +---@param items table - List of all widget items +---@param author_filter string - Current author filter +---@param tag_filter string - Current tag filter +---@param on_author_change function - Callback for author filter change +---@param on_tag_change function - Callback for tag filter change +---@return userdata - UI component +function M.create_filter_section(items, author_filter, tag_filter, on_author_change, on_tag_change) + local authors = extract_authors(items) + local tags = extract_tags(items) + + -- Build author options + local author_options = {"All Authors"} + for _, author in ipairs(authors) do + table.insert(author_options, author) + end + + -- Build tag options + local tag_options = {"All Categories"} + for _, tag in ipairs(tags) do + table.insert(tag_options, tag) + end + + return editor.ui.horizontal({ + spacing = editor.ui.SPACING.MEDIUM, + children = { + editor.ui.vertical({ + spacing = editor.ui.SPACING.SMALL, + children = { + editor.ui.label({ + text = "Author:", + color = editor.ui.COLOR.TEXT + }), + editor.ui.label({ + text = author_filter, + color = editor.ui.COLOR.TEXT + }) + } + }), + editor.ui.vertical({ + spacing = editor.ui.SPACING.SMALL, + children = { + editor.ui.label({ + text = "Category:", + color = editor.ui.COLOR.TEXT + }), + editor.ui.label({ + text = tag_filter, + color = editor.ui.COLOR.TEXT + }) + } + }) + } + }) +end + + +---Format file size for display +---@param size_bytes number - Size in bytes +---@return string - Formatted size string +local function format_size(size_bytes) + if size_bytes < 1024 then + return size_bytes .. " B" + elseif size_bytes < 1024 * 1024 then + return math.floor(size_bytes / 1024) .. " KB" + else + return math.floor(size_bytes / (1024 * 1024)) .. " MB" + end +end + + +---Create a widget item card +---@param item table - Widget item data +---@param is_installed boolean - Whether widget is already installed +---@param on_install function - Callback for install button +---@param on_open_api function - Callback for API docs button +---@return userdata - UI component +function M.create_widget_item(item, is_installed, on_install, on_open_api) + local size_text = item.size and format_size(item.size) or "Unknown size" + local version_text = item.version and "v" .. item.version or "Unknown version" + + -- Create tags display + local tags_text = "" + if item.tags and #item.tags > 0 then + tags_text = "Tags: " .. table.concat(item.tags, ", ") + end + + -- Create dependencies display + local deps_text = "" + if item.depends and #item.depends > 0 then + deps_text = "Depends on: " .. table.concat(item.depends, ", ") + end + + return editor.ui.horizontal({ + spacing = editor.ui.SPACING.MEDIUM, + padding = editor.ui.PADDING.MEDIUM, + children = { + -- Widget icon placeholder + editor.ui.label({ + text = "📦", + color = editor.ui.COLOR.HINT + }), + + -- Widget details + editor.ui.vertical({ + spacing = editor.ui.SPACING.SMALL, + grow = true, + children = { + -- Title and author + editor.ui.horizontal({ + spacing = editor.ui.SPACING.SMALL, + children = { + editor.ui.label({ + text = item.title or item.id, + color = editor.ui.COLOR.TEXT + }), + editor.ui.label({ + text = "by " .. (item.author or "Unknown"), + color = editor.ui.COLOR.HINT + }) + } + }), + + -- Version and size + editor.ui.label({ + text = version_text .. " • " .. size_text, + color = editor.ui.COLOR.HINT + }), + + -- Description + editor.ui.label({ + text = item.description or "No description available", + color = editor.ui.COLOR.TEXT + }), + + -- Tags + tags_text ~= "" and editor.ui.label({ + text = tags_text, + color = editor.ui.COLOR.HINT + }) or nil, + + -- Dependencies + deps_text ~= "" and editor.ui.label({ + text = deps_text, + color = editor.ui.COLOR.WARNING + }) or nil, + + -- Installation status + is_installed and editor.ui.label({ + text = "✓ Already installed", + color = editor.ui.COLOR.HINT + }) or nil + } + }), + + -- Action buttons + editor.ui.vertical({ + spacing = editor.ui.SPACING.SMALL, + children = { + editor.ui.button({ + text = is_installed and "Reinstall" or "Install", + on_pressed = on_install, + enabled = true + }), + editor.ui.button({ + text = "API Docs", + on_pressed = on_open_api, + enabled = item.api ~= nil + }) + } + }) + } + }) +end + + +---Create a scrollable list of widget items +---@param items table - List of widget items to display +---@param is_installed_func function - Function to check if widget is installed +---@param on_install function - Callback for install button +---@param on_open_api function - Callback for API docs button +---@return userdata - UI component +function M.create_widget_list(items, is_installed_func, on_install, on_open_api) + local widget_items = {} + + for _, item in ipairs(items) do + local is_installed = is_installed_func and is_installed_func(item) or false + + table.insert(widget_items, M.create_widget_item(item, is_installed, + function() on_install(item) end, + function() on_open_api(item) end + )) + + -- Add separator between items (except for the last one) + if _ < #items then + table.insert(widget_items, editor.ui.label({ + text = "---", + color = editor.ui.COLOR.HINT + })) + end + end + + return editor.ui.vertical({ + spacing = editor.ui.SPACING.SMALL, + children = widget_items + }) +end + + +---Create a loading indicator +---@param message string - Loading message +---@return userdata - UI component +function M.create_loading_indicator(message) + return editor.ui.vertical({ + spacing = editor.ui.SPACING.MEDIUM, + alignment = editor.ui.ALIGNMENT.CENTER, + children = { + editor.ui.label({ + text = message or "Loading...", + color = editor.ui.COLOR.TEXT, + alignment = editor.ui.ALIGNMENT.CENTER + }) + } + }) +end + + +---Create an error message display +---@param message string - Error message +---@return userdata - UI component +function M.create_error_message(message) + return editor.ui.vertical({ + spacing = editor.ui.SPACING.MEDIUM, + alignment = editor.ui.ALIGNMENT.CENTER, + children = { + editor.ui.label({ + text = "Error: " .. message, + color = editor.ui.COLOR.ERROR, + alignment = editor.ui.ALIGNMENT.CENTER + }) + } + }) +end + + +return M diff --git a/druid/editor_scripts/druid.editor_script b/druid/editor_scripts/druid.editor_script index ba9c01b..8f45587 100644 --- a/druid/editor_scripts/druid.editor_script +++ b/druid/editor_scripts/druid.editor_script @@ -2,6 +2,7 @@ local assign_layers = require("druid.editor_scripts.assign_layers") local create_druid_widget = require("druid.editor_scripts.create_druid_widget") local create_druid_gui_script = require("druid.editor_scripts.create_druid_gui_script") local druid_settings = require("druid.editor_scripts.druid_settings") +local asset_store = require("druid.editor_scripts.core.asset_store") local M = {} @@ -18,6 +19,10 @@ function M.get_prefs_schema() ["druid.gui_script_template_path"] = editor.prefs.schema.string({ default = DEFAULT_GUI_SCRIPT_TEMPLATE_PATH, scope = editor.prefs.SCOPE.PROJECT + }), + ["druid.asset_install_folder"] = editor.prefs.schema.string({ + default = "/widget", + scope = editor.prefs.SCOPE.PROJECT }) } end @@ -65,6 +70,14 @@ function M.get_commands() end }, + { + label = "[Druid] Asset Store", + locations = { "Edit" }, + run = function() + return asset_store.open_asset_store() + end + }, + { label = "[Druid] Settings", locations = { "Edit" },