diff --git a/src/core/Store.luau b/src/core/Store.luau index 63e78e1..3600ff7 100644 --- a/src/core/Store.luau +++ b/src/core/Store.luau @@ -20,6 +20,7 @@ type ConnectionInfo = Types.ConnectionInfo type KeyData = Types.KeyData type HistoryRecord = Types.HistoryRecord type Settings = Types.Settings +type KeyTemplate = Types.KeyTemplate type Toast = Types.Toast type UndoAction = Types.UndoAction type JSONValue = Types.JSONValue @@ -27,6 +28,177 @@ type DiffViewState = Types.DiffViewState local MAX_UNDO_STACK = 64 local MAX_HISTORY_RECORDS = 32 +local MAX_KEY_TEMPLATES = 24 +local MAX_TEMPLATE_CACHE_RECORDS = 64 + +local DEFAULT_KEY_TEMPLATES: { KeyTemplate } = { + { + id = "userid", + label = "UserId", + template = "{userid}", + }, + { + id = "player_userid", + label = "Player", + template = "Player_{userid}", + }, +} + +local function trim(value: string): string + return string.match(value, "^%s*(.-)%s*$") or "" +end + +local function buildTemplateId(label: string, existingIds: { [string]: boolean }): string + local base = string.lower(label) + base = string.gsub(base, "[^%w]+", "_") + base = string.gsub(base, "^_+", "") + base = string.gsub(base, "_+$", "") + + if base == "" then + base = "template" + end + + local id = base + local suffix = 2 + while existingIds[id] do + id = `{base}_{suffix}` + suffix += 1 + end + + return id +end + +local function renderTemplate(template: string, userId: number): (string?, string?) + local rendered = template + local userIdText = tostring(userId) + + rendered = string.gsub(rendered, "{userid}", userIdText) + rendered = string.gsub(rendered, "{userId}", userIdText) + rendered = string.gsub(rendered, "{UserId}", userIdText) + + for token in string.gmatch(rendered, "{[^}]*}") do + return nil, "Unknown placeholder \"" .. token .. "\". Only {userid} is supported." + end + + if string.find(rendered, "{", 1, true) or string.find(rendered, "}", 1, true) then + return nil, "Malformed placeholder. Use {userid}." + end + + local valid, errorMessage = Operations.validateKey(rendered) + if not valid then + return nil, errorMessage + end + + return rendered, nil +end + +local function validateTemplateFields(label: string, template: string): (boolean, string?) + local cleanLabel = trim(label) + local cleanTemplate = trim(template) + + if cleanLabel == "" then + return false, "Template name cannot be empty" + end + if #cleanLabel > 32 then + return false, "Template name cannot exceed 32 characters" + end + if cleanTemplate == "" then + return false, "Template cannot be empty" + end + if #cleanTemplate > 50 then + return false, "Template cannot exceed 50 characters" + end + if not string.find(cleanTemplate, "{userid}", 1, true) + and not string.find(cleanTemplate, "{userId}", 1, true) + and not string.find(cleanTemplate, "{UserId}", 1, true) + then + return false, "Template must include {userid}" + end + + local _, errorMessage = renderTemplate(cleanTemplate, 123456789) + if errorMessage then + return false, errorMessage + end + + return true, nil +end + +local function sanitizeTemplates(value: any): { KeyTemplate } + local templates: { KeyTemplate } = {} + local existingIds: { [string]: boolean } = {} + + if type(value) == "table" then + for _, candidate in value :: any do + if #templates >= MAX_KEY_TEMPLATES then + break + end + + if type(candidate) == "table" then + local label = if type(candidate.label) == "string" then trim(candidate.label) else "" + local template = if type(candidate.template) == "string" then trim(candidate.template) else "" + local valid = validateTemplateFields(label, template) + + if valid then + local id = if type(candidate.id) == "string" and #candidate.id > 0 and #candidate.id <= 64 + then candidate.id + else buildTemplateId(label, existingIds) + + if existingIds[id] then + id = buildTemplateId(label, existingIds) + end + + existingIds[id] = true + table.insert(templates, { + id = id, + label = label, + template = template, + }) + end + end + end + end + + if #templates == 0 then + return Functional.deepClone(DEFAULT_KEY_TEMPLATES) + end + + return templates +end + +local function hasTemplateId(templates: { KeyTemplate }, id: string): boolean + for _, template in templates do + if template.id == id then + return true + end + end + return false +end + +local function getTemplateById(templates: { KeyTemplate }, id: string): KeyTemplate? + for _, template in templates do + if template.id == id then + return template + end + end + return nil +end + +local function encodeCachePart(value: string): string + return `{#value}:{value}` +end + +local function getConnectionTemplateCacheKey(info: ConnectionInfo?): string? + if not info then + return nil + end + + return table.concat({ + info.datastoreType, + if info.allScopes then "all" else "scoped", + encodeCachePart(info.name), + encodeCachePart(info.scope or ""), + }, "|") +end -- Default settings local defaultSettings: Settings = { @@ -40,6 +212,9 @@ local defaultSettings: Settings = { clickToOpen = false, viewerMode = "Tree", bufferViewerMode = "Hex", + keyTemplates = Functional.deepClone(DEFAULT_KEY_TEMPLATES), + selectedKeyTemplateId = "userid", + storeKeyTemplateSelections = {}, } local Store = {} @@ -130,6 +305,7 @@ local queryCache: { [string]: { keyData: Types.KeyData, versions: { any }?, fetc -- History state Store.history = source({} :: { HistoryRecord }) Store.onHistoryChanged = nil :: (() -> ())? +Store.onSettingsChanged = nil :: (() -> ())? -- Settings state Store.settings = source(Functional.deepClone(defaultSettings)) @@ -705,6 +881,12 @@ local function notifyHistoryChanged() end end +local function notifySettingsChanged() + if Store.onSettingsChanged then + Store.onSettingsChanged() + end +end + function Store.addHistoryRecord(connectionInfo: ConnectionInfo) local historyVal = Store.history() local newHistory = Functional.filter(historyVal, function(record) @@ -790,11 +972,227 @@ function Store.updateSetting(key: string, value: T) local settingsVal = Store.settings() local newSettings = Functional.deepClone(settingsVal) ;(newSettings :: any)[key] = value - Store.settings(newSettings) + Store.settings(Store.normalizeSettings(newSettings)) + notifySettingsChanged() end function Store.resetSettings() Store.settings(Functional.deepClone(defaultSettings)) + notifySettingsChanged() +end + +function Store.normalizeSettings(settings: any): Settings + local normalized = Functional.deepClone(defaultSettings) + + if type(settings) ~= "table" then + return normalized + end + + if settings.themePreset == "Studio" or settings.themePreset == "Dark" or settings.themePreset == "Light" then + normalized.themePreset = settings.themePreset + end + if type(settings.themeAccent) == "string" then + normalized.themeAccent = settings.themeAccent + end + if type(settings.hideGameName) == "boolean" then + normalized.hideGameName = settings.hideGameName + end + if settings.highlightColors == "Default" or settings.highlightColors == "ScriptEditor" then + normalized.highlightColors = settings.highlightColors + end + if type(settings.useAlternatingKeyColors) == "boolean" then + normalized.useAlternatingKeyColors = settings.useAlternatingKeyColors + end + if type(settings.listDeletedKeys) == "boolean" then + normalized.listDeletedKeys = settings.listDeletedKeys + end + if type(settings.automaticallyList) == "boolean" then + normalized.automaticallyList = settings.automaticallyList + end + if type(settings.clickToOpen) == "boolean" then + normalized.clickToOpen = settings.clickToOpen + end + if settings.viewerMode == "Tree" or settings.viewerMode == "Code" then + normalized.viewerMode = settings.viewerMode + end + if settings.bufferViewerMode == "Hex" or settings.bufferViewerMode == "Deserializer" then + normalized.bufferViewerMode = settings.bufferViewerMode + end + + normalized.keyTemplates = sanitizeTemplates(settings.keyTemplates) + + local selectedId = if type(settings.selectedKeyTemplateId) == "string" then settings.selectedKeyTemplateId else "" + if hasTemplateId(normalized.keyTemplates, selectedId) then + normalized.selectedKeyTemplateId = selectedId + else + normalized.selectedKeyTemplateId = normalized.keyTemplates[1].id + end + + normalized.storeKeyTemplateSelections = {} + if type(settings.storeKeyTemplateSelections) == "table" then + local count = 0 + for cacheKey, templateId in settings.storeKeyTemplateSelections :: any do + if count >= MAX_TEMPLATE_CACHE_RECORDS then + break + end + + if type(cacheKey) == "string" + and type(templateId) == "string" + and #cacheKey <= 200 + and hasTemplateId(normalized.keyTemplates, templateId) + then + normalized.storeKeyTemplateSelections[cacheKey] = templateId + count += 1 + end + end + end + + return normalized +end + +function Store.validateKeyTemplate(label: string, template: string): (boolean, string?) + return validateTemplateFields(label, template) +end + +function Store.getSelectedKeyTemplateId(): string + local settingsVal = Store.normalizeSettings(Store.settings()) + local cacheKey = getConnectionTemplateCacheKey(Store.connection()) + if cacheKey then + local cachedId = settingsVal.storeKeyTemplateSelections[cacheKey] + if cachedId and hasTemplateId(settingsVal.keyTemplates, cachedId) then + return cachedId + end + end + + return settingsVal.selectedKeyTemplateId +end + +function Store.getSelectedKeyTemplate(): KeyTemplate + local settingsVal = Store.normalizeSettings(Store.settings()) + local selectedId = Store.getSelectedKeyTemplateId() + return getTemplateById(settingsVal.keyTemplates, selectedId) or settingsVal.keyTemplates[1] +end + +function Store.selectKeyTemplateForCurrentStore(templateId: string): (boolean, string?) + local settingsVal = Store.normalizeSettings(Store.settings()) + if not hasTemplateId(settingsVal.keyTemplates, templateId) then + return false, "Unknown key template" + end + + local newSettings = Functional.deepClone(settingsVal) + newSettings.selectedKeyTemplateId = templateId + + local cacheKey = getConnectionTemplateCacheKey(Store.connection()) + if cacheKey then + newSettings.storeKeyTemplateSelections[cacheKey] = templateId + end + + Store.settings(Store.normalizeSettings(newSettings)) + notifySettingsChanged() + return true, nil +end + +function Store.renderSelectedKeyTemplate(userId: number): (string?, string?) + local selected = Store.getSelectedKeyTemplate() + return renderTemplate(selected.template, userId) +end + +function Store.addKeyTemplate(label: string, template: string): (boolean, string?) + local cleanLabel = trim(label) + local cleanTemplate = trim(template) + local valid, errorMessage = validateTemplateFields(cleanLabel, cleanTemplate) + if not valid then + return false, errorMessage + end + + local settingsVal = Store.normalizeSettings(Store.settings()) + if #settingsVal.keyTemplates >= MAX_KEY_TEMPLATES then + return false, `Key template limit reached ({MAX_KEY_TEMPLATES})` + end + + local existingIds: { [string]: boolean } = {} + for _, existing in settingsVal.keyTemplates do + existingIds[existing.id] = true + end + + local newSettings = Functional.deepClone(settingsVal) + table.insert(newSettings.keyTemplates, { + id = buildTemplateId(cleanLabel, existingIds), + label = cleanLabel, + template = cleanTemplate, + }) + + Store.settings(Store.normalizeSettings(newSettings)) + notifySettingsChanged() + return true, nil +end + +function Store.updateKeyTemplate(id: string, label: string, template: string): (boolean, string?) + local cleanLabel = trim(label) + local cleanTemplate = trim(template) + local valid, errorMessage = validateTemplateFields(cleanLabel, cleanTemplate) + if not valid then + return false, errorMessage + end + + local settingsVal = Store.normalizeSettings(Store.settings()) + local newSettings = Functional.deepClone(settingsVal) + local found = false + + for index, existing in newSettings.keyTemplates do + if existing.id == id then + newSettings.keyTemplates[index] = { + id = id, + label = cleanLabel, + template = cleanTemplate, + } + found = true + break + end + end + + if not found then + return false, "Unknown key template" + end + + Store.settings(Store.normalizeSettings(newSettings)) + notifySettingsChanged() + return true, nil +end + +function Store.removeKeyTemplate(id: string): (boolean, string?) + local settingsVal = Store.normalizeSettings(Store.settings()) + if #settingsVal.keyTemplates <= 1 then + return false, "Keep at least one key template" + end + + local newTemplates: { KeyTemplate } = {} + for _, existing in settingsVal.keyTemplates do + if existing.id ~= id then + table.insert(newTemplates, existing) + end + end + + if #newTemplates == #settingsVal.keyTemplates then + return false, "Unknown key template" + end + + local newSettings = Functional.deepClone(settingsVal) + newSettings.keyTemplates = newTemplates + + if not hasTemplateId(newTemplates, newSettings.selectedKeyTemplateId) then + newSettings.selectedKeyTemplateId = newTemplates[1].id + end + + for cacheKey, templateId in newSettings.storeKeyTemplateSelections do + if templateId == id then + newSettings.storeKeyTemplateSelections[cacheKey] = nil + end + end + + Store.settings(Store.normalizeSettings(newSettings)) + notifySettingsChanged() + return true, nil end -- Toast functions diff --git a/src/core/Types.luau b/src/core/Types.luau index ec5821d..0e6ad49 100644 --- a/src/core/Types.luau +++ b/src/core/Types.luau @@ -49,6 +49,12 @@ export type HistoryRecord = { pinned: boolean, } +export type KeyTemplate = { + id: string, + label: string, + template: string, +} + export type ViewMode = "Tree" | "Code" export type BufferViewMode = "Hex" | "Deserializer" export type ThemePreset = "Studio" | "Light" | "Dark" @@ -65,6 +71,9 @@ export type Settings = { clickToOpen: boolean, viewerMode: ViewMode, bufferViewerMode: BufferViewMode, + keyTemplates: { KeyTemplate }, + selectedKeyTemplateId: string, + storeKeyTemplateSelections: { [string]: string }, } -- Hook types for compression/decompression diff --git a/src/main.server.luau b/src/main.server.luau index 5e79b3d..e143c86 100644 --- a/src/main.server.luau +++ b/src/main.server.luau @@ -77,6 +77,9 @@ local function initialize() -- Save just history (called on every history mutation) Settings.saveHistory(plugin) end + Store.onSettingsChanged = function() + Settings.save(plugin) + end -- Register built-in hooks BuiltInHooks.registerAll() @@ -134,4 +137,3 @@ end -- Cleanup on unload plugin.Unloading:Connect(cleanup) - diff --git a/src/settings/Settings.luau b/src/settings/Settings.luau index 51b4aff..5510863 100644 --- a/src/settings/Settings.luau +++ b/src/settings/Settings.luau @@ -37,7 +37,7 @@ function Settings.load(plugin: Plugin) local savedSettings = decodeSaved(rawSettings) if savedSettings then local defaults = Store.getDefaultSettings() - local merged = Functional.merge(defaults, savedSettings) + local merged = Store.normalizeSettings(Functional.merge(defaults, savedSettings)) Store.settings(merged) end @@ -51,7 +51,8 @@ end -- Save all settings to plugin storage function Settings.save(plugin: Plugin) - local settings = Store.settings() + local settings = Store.normalizeSettings(Store.settings()) + Store.settings(settings) pcall(function() plugin:SetSetting(SETTINGS_KEY, HttpService:JSONEncode(settings)) end) @@ -74,10 +75,18 @@ end -- Clear all saved data function Settings.clear(plugin: Plugin) + local previousSettingsChanged = Store.onSettingsChanged + local previousHistoryChanged = Store.onHistoryChanged + Store.onSettingsChanged = nil + Store.onHistoryChanged = nil + plugin:SetSetting(SETTINGS_KEY, nil) plugin:SetSetting(HISTORY_KEY, nil) Store.resetSettings() Store.clearHistory() + + Store.onSettingsChanged = previousSettingsChanged + Store.onHistoryChanged = previousHistoryChanged end return Settings diff --git a/src/ui/components/Select.luau b/src/ui/components/Select.luau index 2f5fc58..d03a347 100644 --- a/src/ui/components/Select.luau +++ b/src/ui/components/Select.luau @@ -101,6 +101,7 @@ local function Select(props: SelectProps): Frame BorderSizePixel = 0, Text = "", AutoButtonColor = false, + ZIndex = 101, Activated = function() props.onChange(option.value) @@ -123,6 +124,7 @@ local function Select(props: SelectProps): Frame create "Frame" { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, + ZIndex = 102, create "UIListLayout" { FillDirection = Enum.FillDirection.Horizontal, @@ -136,6 +138,7 @@ local function Select(props: SelectProps): Frame Size = UDim2.fromOffset(16, 16), BackgroundTransparency = 1, Image = option.icon, + ZIndex = 103, ImageColor3 = function() local c = theme.colors() if isSelected() then @@ -150,6 +153,7 @@ local function Select(props: SelectProps): Frame LayoutOrder = 2, Size = UDim2.new(1, -24, 1, 0), BackgroundTransparency = 1, + ZIndex = 102, create "UIListLayout" { SortOrder = Enum.SortOrder.LayoutOrder, @@ -161,6 +165,7 @@ local function Select(props: SelectProps): Frame Size = UDim2.new(1, 0, 0, 18), BackgroundTransparency = 1, Text = option.label, + ZIndex = 103, TextColor3 = function() local c = theme.colors() if isSelected() then @@ -179,6 +184,7 @@ local function Select(props: SelectProps): Frame Size = UDim2.new(1, 0, 0, 14), BackgroundTransparency = 1, Text = option.description, + ZIndex = 103, TextColor3 = function() local c = theme.colors() if isSelected() then @@ -200,6 +206,7 @@ local function Select(props: SelectProps): Frame BackgroundTransparency = 1, Image = theme.icons.check, ImageColor3 = Color3.new(1, 1, 1), + ZIndex = 103, Visible = isSelected, }, }, @@ -211,6 +218,9 @@ local function Select(props: SelectProps): Frame AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1, ClipsDescendants = false, + ZIndex = function() + return if isOpen() then 100 else 1 + end, create "UIListLayout" { SortOrder = Enum.SortOrder.LayoutOrder, @@ -247,6 +257,9 @@ local function Select(props: SelectProps): Frame BorderSizePixel = 0, Text = "", AutoButtonColor = false, + ZIndex = function() + return if isOpen() then 100 else 1 + end, Activated = function() if not disabled() then @@ -281,6 +294,9 @@ local function Select(props: SelectProps): Frame create "Frame" { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, + ZIndex = function() + return if isOpen() then 101 else 2 + end, create "UIListLayout" { FillDirection = Enum.FillDirection.Horizontal, @@ -292,6 +308,9 @@ local function Select(props: SelectProps): Frame Size = UDim2.new(1, -24, 1, 0), BackgroundTransparency = 1, Text = displayText, + ZIndex = function() + return if isOpen() then 102 else 3 + end, TextColor3 = function() local c = theme.colors() local selected = selectedOption() @@ -309,6 +328,9 @@ local function Select(props: SelectProps): Frame Size = UDim2.fromOffset(16, 16), BackgroundTransparency = 1, Image = theme.icons.chevronDown, + ZIndex = function() + return if isOpen() then 102 else 3 + end, ImageColor3 = function() return theme.colors().foregroundMuted end, @@ -325,7 +347,10 @@ local function Select(props: SelectProps): Frame Position = UDim2.new(0, 0, 0, if props.label then 62 else 40), Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, - BackgroundTransparency = 1, + BackgroundColor3 = function() + return theme.colors().backgroundSecondary + end, + BackgroundTransparency = 0, BorderSizePixel = 0, Visible = isOpen, ZIndex = 100, @@ -349,7 +374,10 @@ local function Select(props: SelectProps): Frame create "ScrollingFrame" { Size = UDim2.new(1, 0, 0, math.min(#props.options * 48, 240)), - BackgroundTransparency = 1, + BackgroundColor3 = function() + return theme.colors().backgroundSecondary + end, + BackgroundTransparency = 0, ScrollBarThickness = 4, ScrollBarImageColor3 = function() return theme.colors().scrollbar diff --git a/src/ui/components/TextInput.luau b/src/ui/components/TextInput.luau index 13aaa08..46d0c01 100644 --- a/src/ui/components/TextInput.luau +++ b/src/ui/components/TextInput.luau @@ -21,6 +21,9 @@ export type TextInputProps = { validate: ((value: string) -> (boolean, string?))?, multiline: boolean?, password: boolean?, + liveUpdate: boolean?, + onFocus: (() -> ())?, + onBlur: (() -> ())?, label: (string | () -> string)?, hint: string?, prefix: string?, @@ -203,6 +206,9 @@ local function TextInput(props: TextInputProps): Frame FocusLost = function() isFocused(false) + if props.onBlur then + props.onBlur() + end if textBoxRef then handleChange(textBoxRef.Text) end @@ -210,6 +216,9 @@ local function TextInput(props: TextInputProps): Frame Focused = function() isFocused(true) + if props.onFocus then + props.onFocus() + end end, Changed = function(property: string) @@ -245,6 +254,9 @@ local function TextInput(props: TextInputProps): Frame FocusLost = function(enterPressed: boolean) isFocused(false) + if props.onBlur then + props.onBlur() + end if textBoxRef then local currentText = textBoxRef.Text handleChange(currentText) @@ -256,6 +268,15 @@ local function TextInput(props: TextInputProps): Frame Focused = function() isFocused(true) + if props.onFocus then + props.onFocus() + end + end, + + Changed = function(property: string) + if props.liveUpdate and property == "Text" and textBoxRef then + handleChange(textBoxRef.Text) + end end, }, diff --git a/src/ui/views/BrowseView.luau b/src/ui/views/BrowseView.luau index 8577daf..5cdfdd1 100644 --- a/src/ui/views/BrowseView.luau +++ b/src/ui/views/BrowseView.luau @@ -14,6 +14,7 @@ local Functional = require(script.Parent.Parent.Parent.utils.Functional) local Players = game:GetService("Players") local Button = require(script.Parent.Parent.components.Button) +local Select = require(script.Parent.Parent.components.Select) local TextInput = require(script.Parent.Parent.components.TextInput) local VirtualizedList = require(script.Parent.Parent.components.VirtualizedList) local Tooltip = require(script.Parent.Parent.components.Tooltip) @@ -38,6 +39,18 @@ local function BrowseView(): Frame return localPlayer ~= nil and localPlayer.UserId > 0 end) + local keyTemplateOptions = derive(function(): { any } + local options: { any } = {} + for _, template in Store.settings().keyTemplates do + table.insert(options, { + value = template.id, + label = template.label, + description = template.template, + }) + end + return options + end) + -- Auto-lookup state for keys not in the loaded page local lookupStatus = source("idle" :: "idle" | "loading" | "found" | "not_found" | "error") local lookupKey = source(nil :: KeyInfo?) @@ -221,12 +234,17 @@ local function BrowseView(): Frame return end - local userIdKey = tostring(localPlayer.UserId) - keyFilter(userIdKey) - triggerAutoLookup(userIdKey) + local userKey, errorMessage = Store.renderSelectedKeyTemplate(localPlayer.UserId) + if not userKey then + Store.showToast("warning", errorMessage or "Selected key template is invalid") + return + end + + keyFilter(userKey) + triggerAutoLookup(userKey) quickLoginLoading(true) - loadKey(userIdKey, true) + loadKey(userKey, true) quickLoginLoading(false) end @@ -585,8 +603,9 @@ local function BrowseView(): Frame -- Filter bar create "Frame" { LayoutOrder = 2, - Size = UDim2.new(1, 0, 0, 56), + Size = UDim2.new(1, 0, 0, 96), BackgroundTransparency = 1, + ZIndex = 50, create "UIPadding" { PaddingLeft = UDim.new(0, 16), @@ -598,16 +617,16 @@ local function BrowseView(): Frame create "Frame" { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, + ZIndex = 50, create "UIListLayout" { - FillDirection = Enum.FillDirection.Horizontal, - VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, Padding = UDim.new(0, 8), }, create "Frame" { LayoutOrder = 1, - Size = UDim2.new(1, -180, 1, 0), + Size = UDim2.new(1, 0, 0, 36), BackgroundTransparency = 1, TextInput({ @@ -622,28 +641,62 @@ local function BrowseView(): Frame create "Frame" { LayoutOrder = 2, - Size = UDim2.fromOffset(172, 36), + Size = UDim2.new(1, 0, 0, 36), BackgroundTransparency = 1, + ZIndex = 60, - Button({ - text = function() - if quickLoginLoading() then - return "Opening..." - end - if not canQuickLogin() then - return "No LocalPlayer" - end - return "Open My UserId" - end, - onClick = quickLoginIntoUserKey, - variant = "secondary", - icon = theme.icons.key, - disabled = function() - return quickLoginLoading() or not canQuickLogin() - end, - tooltip = "Quick-login to key named LocalPlayer.UserId", - fullWidth = true, - }), + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }, + + create "Frame" { + LayoutOrder = 1, + Size = UDim2.new(1, -180, 1, 0), + BackgroundTransparency = 1, + ZIndex = 60, + + Select({ + value = function() + return Store.getSelectedKeyTemplateId() + end, + options = keyTemplateOptions(), + onChange = function(value) + local success, errorMessage = Store.selectKeyTemplateForCurrentStore(value) + if not success then + Store.showToast("warning", errorMessage or "Unable to select key template") + end + end, + placeholder = "Template", + }), + }, + + create "Frame" { + LayoutOrder = 2, + Size = UDim2.fromOffset(172, 36), + BackgroundTransparency = 1, + + Button({ + text = function() + if quickLoginLoading() then + return "Opening..." + end + if not canQuickLogin() then + return "No LocalPlayer" + end + return "Open My Key" + end, + onClick = quickLoginIntoUserKey, + variant = "secondary", + icon = theme.icons.key, + disabled = function() + return quickLoginLoading() or not canQuickLogin() + end, + tooltip = "Open the selected LocalPlayer key template", + fullWidth = true, + }), + }, }, }, }, @@ -651,8 +704,9 @@ local function BrowseView(): Frame -- Key list create "Frame" { LayoutOrder = 3, - Size = UDim2.new(1, 0, 1, -120), + Size = UDim2.new(1, 0, 1, -160), BackgroundTransparency = 1, + ZIndex = 1, -- Loading state show(Store.keyListLoading, function() diff --git a/src/ui/views/ConnectView.luau b/src/ui/views/ConnectView.luau index ddd5241..6c30496 100644 --- a/src/ui/views/ConnectView.luau +++ b/src/ui/views/ConnectView.luau @@ -41,9 +41,41 @@ local function ConnectView(): Frame -- Form state (pre-populate from last used) local datastoreType = source("Normal" :: DataStoreType) local datastoreName = source(getLastDatastoreName()) + local datastoreNameFocused = source(false) local scope = source("") local allScopes = source(false) + local loadDataStores = function() + Store.datastoreListLoading(true) + Store.datastoreListError(nil) + + local result = Operations.listDataStores(nil, nil) + + if result.success then + Store.datastoreList(result.value.datastores) + Store.hasMoreDatastores(result.value.cursor ~= nil) + else + Store.datastoreListError(result.error) + Store.showToast("error", `Failed to list DataStores: {result.error}`) + end + + Store.datastoreListLoading(false) + end + + local suggestedDataStoreNames = derive(function(): { string } + local query = string.lower(datastoreName()) + local suggestions: { string } = {} + + for _, name in Store.datastoreList() do + if query == "" or string.find(string.lower(name), query, 1, true) then + table.insert(suggestions, name) + end + end + + table.sort(suggestions) + return suggestions + end) + -- Validation local nameError = derive(function(): string? local name = datastoreName() @@ -266,7 +298,7 @@ local function ConnectView(): Frame } end - -- Tabs for connect form vs history + -- Tabs for connect form and history local tabs = { { id = "connect", label = "Connect" }, { id = "history", label = "History" }, @@ -280,6 +312,17 @@ local function ConnectView(): Frame Store.setTab("connect") end + local selectDataStoreName = function(name: string) + datastoreName(name) + Store.setTab("connect") + end + + task.defer(function() + if Store.settings().automaticallyList and #Store.datastoreList() == 0 and not Store.datastoreListLoading() then + loadDataStores() + end + end) + return create "Frame" { Size = UDim2.fromScale(1, 1), BackgroundColor3 = function() @@ -381,21 +424,30 @@ local function ConnectView(): Frame -- DataStore Name create "Frame" { LayoutOrder = 2, - Size = UDim2.new(1, formRowWidthOffset, 0, 0), - AutomaticSize = Enum.AutomaticSize.Y, + Size = UDim2.new(1, formRowWidthOffset, 0, 62), BackgroundTransparency = 1, + ZIndex = 50, TextInput({ value = datastoreName, onChange = function(value) datastoreName(value) end, + onFocus = function() + datastoreNameFocused(true) + end, + onBlur = function() + task.delay(0.15, function() + datastoreNameFocused(false) + end) + end, onSubmit = function(value) datastoreName(value) handleConnect() end, placeholder = "Enter DataStore name...", label = "DataStore Name", + liveUpdate = true, validate = function(value) if #value == 0 then return true, nil @@ -403,6 +455,154 @@ local function ConnectView(): Frame return Operations.validateDataStoreName(value) end, }), + + create "Frame" { + Position = UDim2.fromOffset(0, 68), + Size = function() + if Store.datastoreListLoading() or Store.datastoreListError() ~= nil then + return UDim2.new(1, 0, 0, 44) + end + + return UDim2.new(1, 0, 0, math.min(#suggestedDataStoreNames(), 5) * 36) + end, + BackgroundColor3 = function() + return theme.colors().backgroundSecondary + end, + BorderSizePixel = 0, + ClipsDescendants = true, + Visible = function() + return Store.settings().automaticallyList + and datastoreNameFocused() + and ( + Store.datastoreListLoading() + or Store.datastoreListError() ~= nil + or #suggestedDataStoreNames() > 0 + ) + end, + ZIndex = 200, + + create "UICorner" { + CornerRadius = UDim.new(0, theme.radius.md), + }, + + create "UIStroke" { + Color = function() + return theme.colors().border + end, + Thickness = 1, + }, + + create "ScrollingFrame" { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + ScrollBarThickness = 4, + ScrollBarImageColor3 = function() + return theme.colors().scrollbar + end, + CanvasSize = UDim2.new(0, 0, 0, 0), + AutomaticCanvasSize = Enum.AutomaticSize.Y, + BorderSizePixel = 0, + ZIndex = 201, + + create "UIListLayout" { + SortOrder = Enum.SortOrder.LayoutOrder, + }, + + indexes(suggestedDataStoreNames, function(name, index) + local isHovered = source(false) + + return create "TextButton" { + LayoutOrder = index, + Size = UDim2.new(1, 0, 0, 36), + BackgroundColor3 = function() + local c = theme.colors() + return if isHovered() then c.backgroundTertiary else c.backgroundSecondary + end, + BorderSizePixel = 0, + Text = "", + AutoButtonColor = false, + ZIndex = 202, + + Activated = function() + selectDataStoreName(name()) + datastoreNameFocused(false) + end, + + MouseEnter = function() + isHovered(true) + end, + + MouseLeave = function() + isHovered(false) + end, + + create "UIPadding" { + PaddingLeft = UDim.new(0, 12), + PaddingRight = UDim.new(0, 12), + }, + + create "Frame" { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + ZIndex = 203, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 10), + }, + + create "ImageLabel" { + LayoutOrder = 1, + Size = UDim2.fromOffset(16, 16), + BackgroundTransparency = 1, + Image = theme.icons.database, + ImageColor3 = function() + return theme.colors().foregroundSecondary + end, + ZIndex = 204, + }, + + create "TextLabel" { + LayoutOrder = 2, + Size = UDim2.new(1, -26, 1, 0), + BackgroundTransparency = 1, + Text = name, + TextColor3 = function() + return theme.colors().foreground + end, + TextSize = theme.fontSize.normal, + TextXAlignment = Enum.TextXAlignment.Left, + TextTruncate = Enum.TextTruncate.AtEnd, + FontFace = theme.fontMono, + ZIndex = 204, + }, + }, + } + end), + + create "TextLabel" { + Size = UDim2.new(1, 0, 0, 44), + BackgroundTransparency = 1, + Text = function() + if Store.datastoreListLoading() then + return "Loading DataStores..." + end + return Store.datastoreListError() or "" + end, + TextColor3 = function() + return if Store.datastoreListError() then theme.colors().error else theme.colors().foregroundMuted + end, + TextSize = theme.fontSize.normal, + TextWrapped = true, + FontFace = theme.font, + Visible = function() + return Store.datastoreListLoading() or Store.datastoreListError() ~= nil + end, + ZIndex = 202, + }, + }, + }, }, -- Scope (optional for Normal, required for Ordered) diff --git a/src/ui/views/SettingsView.luau b/src/ui/views/SettingsView.luau index bf7bfa3..5500952 100644 --- a/src/ui/views/SettingsView.luau +++ b/src/ui/views/SettingsView.luau @@ -10,13 +10,17 @@ local Store = require(script.Parent.Parent.Parent.core.Store) local Button = require(script.Parent.Parent.components.Button) local Select = require(script.Parent.Parent.components.Select) +local TextInput = require(script.Parent.Parent.components.TextInput) local create = Vide.create local source = Vide.source local derive = Vide.derive +local indexes = Vide.indexes local function SettingsView(): Frame local theme = Theme.get() + local newTemplateLabel = source("") + local newTemplateValue = source("Player_{userid}") -- Theme preset options local themeOptions = { @@ -49,6 +53,21 @@ local function SettingsView(): Frame { value = "Code", label = "Code View", description = "JSON code editor" }, } + local keyTemplates = derive(function(): { any } + return Store.settings().keyTemplates + end) + + local addTemplate = function() + local success, errorMessage = Store.addKeyTemplate(newTemplateLabel(), newTemplateValue()) + if success then + newTemplateLabel("") + newTemplateValue("Player_{userid}") + Store.showToast("success", "Key template saved") + else + Store.showToast("warning", errorMessage or "Unable to save key template") + end + end + -- Toggle setting component local ToggleSetting = function(props: { label: string, @@ -181,6 +200,183 @@ local function SettingsView(): Frame } end + local KeyTemplateRow = function(template: () -> any, index: number): Frame + local isSelected = derive(function() + local current = template() + return current ~= nil and Store.getSelectedKeyTemplateId() == current.id + end) + + return create "Frame" { + LayoutOrder = index, + Size = UDim2.new(1, 0, 0, 138), + BackgroundColor3 = function() + local c = theme.colors() + return if isSelected() then c.backgroundTertiary else c.backgroundSecondary + end, + BorderSizePixel = 0, + ClipsDescendants = true, + + create "UICorner" { + CornerRadius = UDim.new(0, theme.radius.md), + }, + + create "UIPadding" { + PaddingLeft = UDim.new(0, 12), + PaddingRight = UDim.new(0, 12), + PaddingTop = UDim.new(0, 10), + PaddingBottom = UDim.new(0, 10), + }, + + create "UIListLayout" { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 8), + }, + + create "Frame" { + LayoutOrder = 1, + Size = UDim2.new(1, 0, 0, 68), + BackgroundTransparency = 1, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }, + + create "Frame" { + LayoutOrder = 1, + Size = UDim2.new(0.35, -4, 1, 0), + BackgroundTransparency = 1, + + TextInput({ + value = function() + local current = template() + return if current then current.label else "" + end, + onChange = function(value) + local current = template() + if not current then + return + end + + local success, errorMessage = Store.updateKeyTemplate(current.id, value, current.template) + if not success then + Store.showToast("warning", errorMessage or "Unable to update key template") + end + end, + label = "Name", + placeholder = "Player", + }), + }, + + create "Frame" { + LayoutOrder = 2, + Size = UDim2.new(0.65, -4, 1, 0), + BackgroundTransparency = 1, + + TextInput({ + value = function() + local current = template() + return if current then current.template else "" + end, + onChange = function(value) + local current = template() + if not current then + return + end + + local success, errorMessage = Store.updateKeyTemplate(current.id, current.label, value) + if not success then + Store.showToast("warning", errorMessage or "Unable to update key template") + end + end, + label = "Template", + placeholder = "Player_{userid}", + validate = function(value) + local current = template() + if not current then + return true, nil + end + return Store.validateKeyTemplate(current.label, value) + end, + }), + }, + }, + + create "Frame" { + LayoutOrder = 2, + Size = UDim2.new(1, 0, 0, 32), + BackgroundTransparency = 1, + ClipsDescendants = true, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }, + + create "Frame" { + LayoutOrder = 1, + Size = UDim2.new(1, -216, 1, 0), + BackgroundTransparency = 1, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill, + }, + }, + + create "Frame" { + LayoutOrder = 2, + Size = UDim2.fromOffset(112, 32), + BackgroundTransparency = 1, + + Button({ + text = function() + return if isSelected() then "Selected" else "Use" + end, + onClick = function() + local current = template() + if current then + local success, errorMessage = Store.selectKeyTemplateForCurrentStore(current.id) + if not success then + Store.showToast("warning", errorMessage or "Unable to select key template") + end + end + end, + variant = if isSelected() then "primary" else "secondary", + size = "small", + fullWidth = true, + }), + }, + + create "Frame" { + LayoutOrder = 3, + Size = UDim2.fromOffset(96, 32), + BackgroundTransparency = 1, + + Button({ + text = "Delete", + onClick = function() + local current = template() + if current then + local success, errorMessage = Store.removeKeyTemplate(current.id) + if not success then + Store.showToast("warning", errorMessage or "Unable to delete key template") + end + end + end, + variant = "danger", + size = "small", + fullWidth = true, + disabled = function() + return #Store.settings().keyTemplates <= 1 + end, + }), + }, + }, + } + end + -- Section header component local SectionHeader = function(title: string): TextLabel return create "TextLabel" { @@ -307,8 +503,8 @@ local function SettingsView(): Frame BorderSizePixel = 0, create "UIPadding" { - PaddingLeft = UDim.new(0, 16), - PaddingRight = UDim.new(0, 16), + PaddingLeft = UDim.new(0.02, 16), + PaddingRight = UDim.new(0.04, 24), PaddingTop = UDim.new(0, 16), PaddingBottom = UDim.new(0, 16), }, @@ -526,18 +722,144 @@ local function SettingsView(): Frame }), }, - -- About section + -- Key templates section create "Frame" { LayoutOrder = 30, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1, + SectionHeader("KEY TEMPLATES"), + }, + + create "Frame" { + LayoutOrder = 31, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + + create "UIListLayout" { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 8), + }, + + indexes(keyTemplates, function(template, index) + return KeyTemplateRow(template, index) + end), + }, + + create "Frame" { + LayoutOrder = 32, + Size = UDim2.new(1, 0, 0, 138), + BackgroundColor3 = function() + return theme.colors().backgroundSecondary + end, + BorderSizePixel = 0, + + create "UICorner" { + CornerRadius = UDim.new(0, theme.radius.md), + }, + + create "UIPadding" { + PaddingLeft = UDim.new(0, 12), + PaddingRight = UDim.new(0, 12), + PaddingTop = UDim.new(0, 10), + PaddingBottom = UDim.new(0, 10), + }, + + create "UIListLayout" { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 8), + }, + + create "Frame" { + LayoutOrder = 1, + Size = UDim2.new(1, 0, 0, 68), + BackgroundTransparency = 1, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }, + + create "Frame" { + LayoutOrder = 1, + Size = UDim2.new(0.35, -4, 1, 0), + BackgroundTransparency = 1, + + TextInput({ + value = newTemplateLabel, + onChange = function(value) + newTemplateLabel(value) + end, + onSubmit = function(value) + newTemplateLabel(value) + addTemplate() + end, + label = "Name", + placeholder = "Player", + }), + }, + + create "Frame" { + LayoutOrder = 2, + Size = UDim2.new(0.65, -4, 1, 0), + BackgroundTransparency = 1, + + TextInput({ + value = newTemplateValue, + onChange = function(value) + newTemplateValue(value) + end, + onSubmit = function(value) + newTemplateValue(value) + addTemplate() + end, + label = "Template", + placeholder = "Player_{userid}", + validate = function(value) + return Store.validateKeyTemplate(newTemplateLabel(), value) + end, + }), + }, + }, + + create "Frame" { + LayoutOrder = 2, + Size = UDim2.new(1, 0, 0, 32), + BackgroundTransparency = 1, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + VerticalAlignment = Enum.VerticalAlignment.Center, + }, + + Button({ + text = "Add Template", + onClick = addTemplate, + variant = "primary", + size = "small", + disabled = function() + return newTemplateLabel() == "" or newTemplateValue() == "" + end, + }), + }, + }, + + -- About section + create "Frame" { + LayoutOrder = 40, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + SectionHeader("ABOUT"), }, create "TextLabel" { - LayoutOrder = 31, + LayoutOrder = 41, Size = UDim2.new(1, 0, 0, 60), BackgroundTransparency = 1, Text = "DataScope\nA clean rewrite with Vide\n\nInspired by the original DataScope by pinehappi", diff --git a/wally.lock b/wally.lock index f066fb7..68e0042 100644 --- a/wally.lock +++ b/wally.lock @@ -9,5 +9,5 @@ dependencies = [] [[package]] name = "pyseph/datascope" -version = "1.1.0" +version = "1.3.0" dependencies = [["Vide", "centau/vide@0.4.0"]]