Fix @mutex decorator, text input, modal docs, group management polish
Add user groups feature, simplify flash alerts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,11 +9,16 @@
|
||||
* @mutex('operation_name')
|
||||
* async my_method() { ... }
|
||||
*
|
||||
* Uses the 2023-11 decorator proposal (Stage 3).
|
||||
* Decorator receives (value, context) where:
|
||||
* - value: the original method function
|
||||
* - context: { kind, name, static, private, access, addInitializer }
|
||||
*
|
||||
* @decorator
|
||||
* @param {string} [global_id] - Optional global mutex ID for cross-instance locking
|
||||
*/
|
||||
function mutex(global_id) {
|
||||
// Storage (using IIFEs to keep WeakMap/Map in closure scope)
|
||||
function mutex(arg) {
|
||||
// Storage for mutex locks
|
||||
const instance_mutexes = (function() {
|
||||
if (!mutex._instance_storage) {
|
||||
mutex._instance_storage = new WeakMap();
|
||||
@@ -89,39 +94,48 @@ function mutex(global_id) {
|
||||
});
|
||||
}
|
||||
|
||||
// If called with an ID argument: @mutex('id')
|
||||
if (typeof global_id === 'string') {
|
||||
return function(target, key, descriptor) {
|
||||
const original_method = descriptor.value;
|
||||
|
||||
if (typeof original_method !== 'function') {
|
||||
throw new Error(`@mutex can only be applied to methods (tried to apply to ${key})`);
|
||||
/**
|
||||
* Create the wrapper method for a given original method
|
||||
* @param {Function} original_method - The original method
|
||||
* @param {string} method_name - The method name
|
||||
* @param {string|null} global_id - Global mutex ID (null for instance-level)
|
||||
*/
|
||||
function create_mutex_wrapper(original_method, method_name, global_id) {
|
||||
return function(...args) {
|
||||
let lock_state;
|
||||
if (global_id) {
|
||||
lock_state = get_global_mutex(global_id);
|
||||
} else {
|
||||
lock_state = get_instance_mutex(this, method_name);
|
||||
}
|
||||
|
||||
descriptor.value = function(...args) {
|
||||
const lock_state = get_global_mutex(global_id);
|
||||
return acquire_lock(lock_state, () => original_method.apply(this, args));
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
return acquire_lock(lock_state, () => original_method.apply(this, args));
|
||||
};
|
||||
}
|
||||
|
||||
// If called without arguments: @mutex (target is the first argument)
|
||||
const target = global_id; // In this case, first arg is target
|
||||
const key = arguments[1];
|
||||
const descriptor = arguments[2];
|
||||
// 2023-11 decorator spec: decorators receive (value, context)
|
||||
// If called with a string argument: @mutex('id') - returns decorator function
|
||||
// If called directly on method: @mutex - arg is the method function, second arg is context
|
||||
|
||||
const original_method = descriptor.value;
|
||||
|
||||
if (typeof original_method !== 'function') {
|
||||
throw new Error(`@mutex can only be applied to methods (tried to apply to ${key})`);
|
||||
if (typeof arg === 'string') {
|
||||
// Called with ID: @mutex('operation_name')
|
||||
// Returns a decorator function
|
||||
const global_id = arg;
|
||||
return function(value, context) {
|
||||
if (context.kind !== 'method') {
|
||||
throw new Error(`@mutex can only be applied to methods, not ${context.kind}`);
|
||||
}
|
||||
return create_mutex_wrapper(value, context.name, global_id);
|
||||
};
|
||||
}
|
||||
|
||||
descriptor.value = function(...args) {
|
||||
const lock_state = get_instance_mutex(this, key);
|
||||
return acquire_lock(lock_state, () => original_method.apply(this, args));
|
||||
};
|
||||
// Called without arguments: @mutex
|
||||
// In 2023-11 spec, arg is the method function, second argument is context
|
||||
const value = arg;
|
||||
const context = arguments[1];
|
||||
|
||||
return descriptor;
|
||||
if (!context || context.kind !== 'method') {
|
||||
throw new Error(`@mutex can only be applied to methods`);
|
||||
}
|
||||
|
||||
return create_mutex_wrapper(value, context.name, null);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* Features:
|
||||
* - Queue system prevents alert spam (2.5s minimum between alerts)
|
||||
* - Auto-dismiss after timeout (success: 4s, others: 6s)
|
||||
* - Click to dismiss immediately
|
||||
* - Smooth fade in/out animations
|
||||
* - Bootstrap alert styling with icons
|
||||
* - Persistent queue across page navigations (sessionStorage, tab-specific)
|
||||
@@ -99,7 +98,7 @@ class Flash_Alert {
|
||||
// Process each visible alert
|
||||
this._container.find('.alert-wrapper').each((index, wrapper) => {
|
||||
const $wrapper = $(wrapper);
|
||||
const message_text = $wrapper.find('.alert').text().trim().replace(/×$/, '').trim();
|
||||
const message_text = $wrapper.find('.alert').text().trim();
|
||||
|
||||
// Find this message in persistence queue to check fade_in_complete
|
||||
const msg = this._persistence_queue.find(m => m.message === message_text);
|
||||
@@ -476,9 +475,7 @@ class Flash_Alert {
|
||||
// Check if an alert with the same message is already displayed
|
||||
let duplicate_found = false;
|
||||
this._container.find('.alert-wrapper').each(function () {
|
||||
const existing_text = $(this).find('.alert').text().trim();
|
||||
// Remove the close button text (×) for comparison
|
||||
const existing_message = existing_text.replace(/×$/, '').trim();
|
||||
const existing_message = $(this).find('.alert').text().trim();
|
||||
if (existing_message === message) {
|
||||
duplicate_found = true;
|
||||
return false; // Break loop
|
||||
@@ -491,8 +488,8 @@ class Flash_Alert {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create alert element
|
||||
const $alert = $(`<div class="alert alert-${level} alert-dismissible fade show" role="alert">`);
|
||||
// Create alert element (no close button - auto-fades, click anywhere to dismiss)
|
||||
const $alert = $(`<div class="alert alert-${level} fade show" role="alert">`);
|
||||
|
||||
// Add icon based on level
|
||||
if (level === 'danger') {
|
||||
@@ -505,9 +502,7 @@ class Flash_Alert {
|
||||
$alert.append('<i class="bi bi-exclamation-triangle-fill me-2"></i>');
|
||||
}
|
||||
|
||||
// Add close button
|
||||
const $close_button = $('<button type="button" class="btn-close" aria-label="Close"></button>');
|
||||
$alert.append($close_button).append(message);
|
||||
$alert.append(message);
|
||||
|
||||
// Wrap in container for animation
|
||||
const $alert_container = $('<div class="alert-wrapper">').append($alert);
|
||||
@@ -578,12 +573,5 @@ class Flash_Alert {
|
||||
close_alert(1000);
|
||||
}, time_until_fadeout);
|
||||
}
|
||||
|
||||
// Click anywhere on alert to dismiss
|
||||
$alert.click(() => {
|
||||
// Remove from persistence queue when manually dismissed
|
||||
this._remove_from_persistence_queue(message, level);
|
||||
close_alert(300);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,24 +28,12 @@
|
||||
|
||||
.alert {
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// Icon spacing
|
||||
i {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Close button styling
|
||||
.btn-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,11 @@
|
||||
"created_at": "2025-12-11T05:06:10+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_login_history_table"
|
||||
},
|
||||
"2025_12_11_061551_create_groups_and_group_users_tables.php": {
|
||||
"created_at": "2025-12-11T06:15:51+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_groups_and_group_users_tables"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// Create user_groups table
|
||||
DB::statement("
|
||||
CREATE TABLE user_groups (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT NULL,
|
||||
deletion_protection TINYINT(1) NOT NULL DEFAULT 0,
|
||||
deleted_at TIMESTAMP(3) NULL,
|
||||
deleted_by BIGINT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NULL,
|
||||
updated_by BIGINT NULL,
|
||||
|
||||
INDEX user_groups_site_id_idx (site_id),
|
||||
INDEX user_groups_name_idx (name),
|
||||
INDEX user_groups_deleted_at_idx (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
// Create user_group_members pivot table (many-to-many)
|
||||
DB::statement("
|
||||
CREATE TABLE user_group_members (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_group_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NULL,
|
||||
updated_by BIGINT NULL,
|
||||
|
||||
INDEX user_group_members_group_id_idx (user_group_id),
|
||||
INDEX user_group_members_user_id_idx (user_id),
|
||||
UNIQUE INDEX user_group_members_unique (user_group_id, user_id),
|
||||
CONSTRAINT user_group_members_group_fk FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_group_members_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user