diff --git a/assets/pasted-20260416-000905-360246be.png b/assets/pasted-20260416-000905-360246be.png new file mode 100644 index 0000000..e5e17ef Binary files /dev/null and b/assets/pasted-20260416-000905-360246be.png differ diff --git a/db/sccharacters.php b/db/sccharacters.php index 0c012d0..9b8817b 100644 --- a/db/sccharacters.php +++ b/db/sccharacters.php @@ -56,6 +56,7 @@ function sccharacters_bootstrap(): void cl_sccharacter_notes TEXT DEFAULT NULL, cl_sccharacter_share_token VARCHAR(64) NOT NULL, cl_sccharacter_share_enabled TINYINT(1) NOT NULL DEFAULT 0, + cl_sccharacter_category_order TEXT DEFAULT NULL, cl_sccharacter_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, cl_sccharacter_updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (cl_sccharacter_id), @@ -90,6 +91,13 @@ function sccharacters_bootstrap(): void ); } + if (!sccharacters_column_exists($db, 'tbl_sccharacters', 'cl_sccharacter_category_order')) { + $db->exec( + 'ALTER TABLE tbl_sccharacters + ADD COLUMN cl_sccharacter_category_order TEXT NULL AFTER cl_sccharacter_share_enabled' + ); + } + if (!sccharacters_index_exists($db, 'tbl_sccharacters', 'idx_sccharacter_owner')) { $db->exec( 'ALTER TABLE tbl_sccharacters @@ -123,11 +131,13 @@ function sccharacters_bootstrap(): void cl_sccharacteritem_scitemcustom_id INT(11) DEFAULT NULL, cl_sccharacteritem_slot VARCHAR(120) NOT NULL DEFAULT '', cl_sccharacteritem_note TEXT DEFAULT NULL, + cl_sccharacteritem_sort_order INT UNSIGNED NOT NULL DEFAULT 0, cl_sccharacteritem_created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (cl_sccharacteritem_id), KEY idx_sccharacteritem_character (cl_sccharacteritem_character_id), KEY idx_sccharacteritem_scobjs (cl_sccharacteritem_scobjs_id), KEY idx_sccharacteritem_scitemcustom (cl_sccharacteritem_scitemcustom_id), + KEY idx_sccharacteritem_character_sort (cl_sccharacteritem_character_id, cl_sccharacteritem_sort_order, cl_sccharacteritem_id), CONSTRAINT fk_sccharacteritem_character FOREIGN KEY (cl_sccharacteritem_character_id) REFERENCES tbl_sccharacters (cl_sccharacter_id) ON DELETE CASCADE @@ -157,6 +167,13 @@ function sccharacters_bootstrap(): void ); } + if (!sccharacters_column_exists($db, 'tbl_sccharacteritems', 'cl_sccharacteritem_sort_order')) { + $db->exec( + 'ALTER TABLE tbl_sccharacteritems + ADD COLUMN cl_sccharacteritem_sort_order INT UNSIGNED NOT NULL DEFAULT 0 AFTER cl_sccharacteritem_note' + ); + } + if (!sccharacters_index_exists($db, 'tbl_sccharacteritems', 'idx_sccharacteritem_character')) { $db->exec( 'ALTER TABLE tbl_sccharacteritems @@ -178,6 +195,13 @@ function sccharacters_bootstrap(): void ); } + if (!sccharacters_index_exists($db, 'tbl_sccharacteritems', 'idx_sccharacteritem_character_sort')) { + $db->exec( + 'ALTER TABLE tbl_sccharacteritems + ADD INDEX idx_sccharacteritem_character_sort (cl_sccharacteritem_character_id, cl_sccharacteritem_sort_order, cl_sccharacteritem_id)' + ); + } + if (!sccharacters_foreign_key_exists($db, 'tbl_sccharacteritems', 'fk_sccharacteritem_character')) { $db->exec( 'ALTER TABLE tbl_sccharacteritems @@ -247,6 +271,76 @@ function sccharacters_generate_share_token(PDO $db): string return $token; } +function sccharacters_reindex_character_items(PDO $db, int $character_id): void +{ + if ($character_id <= 0) { + return; + } + + $stmt = $db->prepare( + 'SELECT cl_sccharacteritem_id + FROM tbl_sccharacteritems + WHERE cl_sccharacteritem_character_id = :character_id + ORDER BY + CASE WHEN cl_sccharacteritem_sort_order <= 0 THEN 0 ELSE 1 END, + cl_sccharacteritem_sort_order ASC, + cl_sccharacteritem_id ASC' + ); + $stmt->execute(['character_id' => $character_id]); + $item_ids = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)); + + if ($item_ids === []) { + return; + } + + $stmt_update = $db->prepare( + 'UPDATE tbl_sccharacteritems + SET cl_sccharacteritem_sort_order = :sort_order + WHERE cl_sccharacteritem_character_id = :character_id + AND cl_sccharacteritem_id = :item_id' + ); + + $db->beginTransaction(); + + try { + $position = 1; + foreach ($item_ids as $item_id) { + $stmt_update->execute([ + 'sort_order' => $position, + 'character_id' => $character_id, + 'item_id' => $item_id, + ]); + $position++; + } + + $db->commit(); + } catch (Throwable $exception) { + if ($db->inTransaction()) { + $db->rollBack(); + } + + throw $exception; + } +} + +function sccharacters_next_item_sort_order(PDO $db, int $character_id): int +{ + if ($character_id <= 0) { + return 1; + } + + sccharacters_reindex_character_items($db, $character_id); + + $stmt = $db->prepare( + 'SELECT COALESCE(MAX(cl_sccharacteritem_sort_order), 0) + FROM tbl_sccharacteritems + WHERE cl_sccharacteritem_character_id = :character_id' + ); + $stmt->execute(['character_id' => $character_id]); + + return ((int) $stmt->fetchColumn()) + 1; +} + function sccharacters_item_category_options(): array { @@ -265,6 +359,106 @@ function sccharacters_item_category_options(): array ]; } +function sccharacters_default_category_order(): array +{ + return array_keys(sccharacters_item_category_options()); +} + +function sccharacters_normalize_category_order(array $category_order): array +{ + $known_categories = sccharacters_default_category_order(); + $known_lookup = array_fill_keys($known_categories, true); + $normalized = []; + + foreach ($category_order as $category_key) { + $category_key = trim((string) $category_key); + if ($category_key === '' || !isset($known_lookup[$category_key]) || isset($normalized[$category_key])) { + continue; + } + + $normalized[$category_key] = $category_key; + } + + foreach ($known_categories as $category_key) { + if (!isset($normalized[$category_key])) { + $normalized[$category_key] = $category_key; + } + } + + return array_values($normalized); +} + +function sccharacters_parse_category_order(?string $raw_value): array +{ + $raw_value = trim((string) $raw_value); + if ($raw_value === '') { + return sccharacters_default_category_order(); + } + + $decoded = json_decode($raw_value, true); + if (!is_array($decoded)) { + return sccharacters_default_category_order(); + } + + return sccharacters_normalize_category_order($decoded); +} + +function sccharacters_encode_category_order(array $category_order): string +{ + $encoded = json_encode( + sccharacters_normalize_category_order($category_order), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + + return $encoded !== false + ? $encoded + : json_encode(sccharacters_default_category_order(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} + +function sccharacters_character_category_order(array $character_row): array +{ + return sccharacters_parse_category_order((string) ($character_row['cl_sccharacter_category_order'] ?? '')); +} + +function sccharacters_sort_items_by_category_order(array $items_by_category, array $category_order): array +{ + if ($items_by_category === []) { + return []; + } + + $sorted = []; + foreach (sccharacters_normalize_category_order($category_order) as $category_key) { + if (isset($items_by_category[$category_key])) { + $sorted[$category_key] = $items_by_category[$category_key]; + } + } + + foreach ($items_by_category as $category_key => $category_items) { + if (!isset($sorted[$category_key])) { + $sorted[$category_key] = $category_items; + } + } + + return $sorted; +} + +function sccharacters_save_character_category_order(PDO $db, int $character_id, array $category_order): void +{ + if ($character_id <= 0) { + return; + } + + $stmt = $db->prepare( + 'UPDATE tbl_sccharacters + SET cl_sccharacter_category_order = :category_order + WHERE cl_sccharacter_id = :character_id' + ); + $stmt->execute([ + 'category_order' => sccharacters_encode_category_order($category_order), + 'character_id' => $character_id, + ]); +} + function sccharacters_item_category_label(string $category): string { $options = sccharacters_item_category_options(); diff --git a/sccharacter.php b/sccharacter.php index ee6b642..cb55202 100644 --- a/sccharacter.php +++ b/sccharacter.php @@ -31,6 +31,8 @@ if ($share_token !== '') { } if ($character) { + sccharacters_reindex_character_items($db, (int) $character['cl_sccharacter_id']); + $stmt_items = $db->prepare( "SELECT ci.*, @@ -47,11 +49,7 @@ if ($character) { LEFT JOIN tbl_scitemcustom co ON co.cl_scitemcustom_id = ci.cl_sccharacteritem_scitemcustom_id LEFT JOIN tbl_scobjs oo ON oo.cl_scobjs_id = co.cl_scitemcustom_obj_id WHERE ci.cl_sccharacteritem_character_id = :character_id - ORDER BY - CASE WHEN TRIM(ci.cl_sccharacteritem_slot) = '' THEN 1 ELSE 0 END, - ci.cl_sccharacteritem_slot ASC, - COALESCE(oo.cl_scobjs_name, bo.cl_scobjs_name, 'ZZZ') ASC, - ci.cl_sccharacteritem_id ASC" + ORDER BY ci.cl_sccharacteritem_sort_order ASC, ci.cl_sccharacteritem_id ASC" ); $stmt_items->execute(['character_id' => (int) $character['cl_sccharacter_id']]); $character_items = $stmt_items->fetchAll(); @@ -93,10 +91,10 @@ if ($character) { } $item_category_options = sccharacters_item_category_options(); +$character_category_order = $character + ? sccharacters_character_category_order($character) + : sccharacters_default_category_order(); $character_items_by_category = []; -foreach (array_keys($item_category_options) as $category_key) { - $character_items_by_category[$category_key] = []; -} foreach ($character_items as $item_row) { $is_custom = ($item_row['cl_sccharacteritem_source'] ?? '') === 'custom'; $type = $is_custom @@ -115,9 +113,9 @@ foreach ($character_items as $item_row) { } $character_items_by_category[$category_key][] = $item_row; } -$character_items_by_category = array_filter( +$character_items_by_category = sccharacters_sort_items_by_category_order( $character_items_by_category, - static fn(array $items): bool => $items !== [] + $character_category_order ); ?> diff --git a/sccharacters.php b/sccharacters.php index 94115b3..d177169 100644 --- a/sccharacters.php +++ b/sccharacters.php @@ -102,8 +102,27 @@ function sccharacters_find_owned_character(PDO $db, int $character_id, int $owne return $row ?: null; } -function sccharacters_build_return_url(int $character_id = 0, string $item_source = '', string $item_search = '', bool $keep_item_panel = false): string +function sccharacters_allowed_item_per_page(): array { + return [25, 50, 100, 200]; +} + +function sccharacters_normalize_item_per_page($value): int +{ + $per_page = (int) $value; + $allowed = sccharacters_allowed_item_per_page(); + + return in_array($per_page, $allowed, true) ? $per_page : 50; +} + +function sccharacters_build_return_url( + int $character_id = 0, + string $item_source = '', + string $item_search = '', + bool $keep_item_panel = false, + int $item_page = 1, + int $item_per_page = 50 +): string { $params = []; if ($character_id > 0) { @@ -118,6 +137,16 @@ function sccharacters_build_return_url(int $character_id = 0, string $item_sourc $params['item_search'] = $item_search; } + $item_page = max(1, $item_page); + if ($item_page > 1) { + $params['item_page'] = $item_page; + } + + $item_per_page = sccharacters_normalize_item_per_page($item_per_page); + if ($item_per_page !== 50) { + $params['item_per_page'] = $item_per_page; + } + if ($keep_item_panel) { $params['item_panel'] = '1'; } @@ -125,6 +154,472 @@ function sccharacters_build_return_url(int $character_id = 0, string $item_sourc return 'sccharacters.php' . ($params !== [] ? '?' . http_build_query($params) : ''); } +function sccharacters_staged_items_session_key(int $owner_auth_id, int $character_id): string +{ + return $owner_auth_id . ':' . $character_id; +} + +function sccharacters_get_staged_items(int $owner_auth_id, int $character_id): array +{ + if ($owner_auth_id <= 0 || $character_id <= 0) { + return []; + } + + if (!isset($_SESSION['sccharacters_staged_items']) || !is_array($_SESSION['sccharacters_staged_items'])) { + $_SESSION['sccharacters_staged_items'] = []; + } + + $session_key = sccharacters_staged_items_session_key($owner_auth_id, $character_id); + $items = $_SESSION['sccharacters_staged_items'][$session_key] ?? []; + + return is_array($items) ? $items : []; +} + +function sccharacters_save_staged_items(int $owner_auth_id, int $character_id, array $items): void +{ + if (!isset($_SESSION['sccharacters_staged_items']) || !is_array($_SESSION['sccharacters_staged_items'])) { + $_SESSION['sccharacters_staged_items'] = []; + } + + $session_key = sccharacters_staged_items_session_key($owner_auth_id, $character_id); + + if ($owner_auth_id <= 0 || $character_id <= 0 || $items === []) { + unset($_SESSION['sccharacters_staged_items'][$session_key]); + return; + } + + $_SESSION['sccharacters_staged_items'][$session_key] = $items; +} + +function sccharacters_parse_item_key(string $item_key): ?array +{ + if (!preg_match('/^(base|custom):(\d+)$/', $item_key, $matches)) { + return null; + } + + return [ + 'source' => (string) $matches[1], + 'source_id' => (int) $matches[2], + ]; +} + +function sccharacters_merge_selected_items(array $staged_items, array $selected_items, array $item_slots, array $item_notes): array +{ + $selected_items = array_values(array_unique(array_map('strval', $selected_items))); + $staged_count = 0; + $error_count = 0; + + foreach ($selected_items as $item_key) { + $parsed = sccharacters_parse_item_key($item_key); + if (!$parsed) { + $error_count++; + continue; + } + + $staged_items[$item_key] = [ + 'source' => $parsed['source'], + 'source_id' => $parsed['source_id'], + 'category' => sccharacters_clean_text($item_slots[$item_key] ?? ''), + 'note' => sccharacters_clean_text($item_notes[$item_key] ?? ''), + ]; + $staged_count++; + } + + return [ + 'items' => $staged_items, + 'staged_count' => $staged_count, + 'error_count' => $error_count, + ]; +} + +function sccharacters_fetch_staged_item_details(PDO $db, int $owner_auth_id, array $staged_items): array +{ + $details = []; + $base_ids = []; + $custom_ids = []; + + foreach ($staged_items as $item_key => $item_data) { + $parsed = sccharacters_parse_item_key((string) $item_key); + if (!$parsed) { + continue; + } + + if ($parsed['source'] === 'custom') { + $custom_ids[] = $parsed['source_id']; + } else { + $base_ids[] = $parsed['source_id']; + } + } + + $base_ids = array_values(array_unique(array_filter($base_ids))); + $custom_ids = array_values(array_unique(array_filter($custom_ids))); + + if ($base_ids !== []) { + $placeholders = implode(',', array_fill(0, count($base_ids), '?')); + $stmt = $db->prepare( + "SELECT cl_scobjs_id, cl_scobjs_name, cl_scobjs_type, cl_scobjs_subtype, cl_scobjs_uuid + FROM tbl_scobjs + WHERE cl_scobjs_id IN ({$placeholders})" + ); + $stmt->execute($base_ids); + + foreach ($stmt->fetchAll() as $row) { + $item_key = 'base:' . (int) $row['cl_scobjs_id']; + $details[$item_key] = [ + 'item_key' => $item_key, + 'source' => 'base', + 'source_id' => (int) $row['cl_scobjs_id'], + 'name' => (string) ($row['cl_scobjs_name'] ?? ''), + 'type' => (string) ($row['cl_scobjs_type'] ?? ''), + 'subtype' => (string) ($row['cl_scobjs_subtype'] ?? ''), + 'uuid' => (string) ($row['cl_scobjs_uuid'] ?? ''), + 'is_custom' => false, + 'stat_count' => null, + ]; + } + } + + if ($custom_ids !== []) { + $placeholders = implode(',', array_fill(0, count($custom_ids), '?')); + $stmt = $db->prepare( + "SELECT c.cl_scitemcustom_id, + o.cl_scobjs_name, + o.cl_scobjs_type, + o.cl_scobjs_subtype, + o.cl_scobjs_uuid, + COUNT(cs.cl_scitemcustomstat_id) AS cl_scitemcustom_stat_count + FROM tbl_scitemcustom c + INNER JOIN tbl_scobjs o ON o.cl_scobjs_id = c.cl_scitemcustom_obj_id + LEFT JOIN tbl_scitemcustomstat cs ON cs.cl_scitemcustomstat_itemcustom_id = c.cl_scitemcustom_id + WHERE c.cl_scitemcustom_owner_auth_id = ? + AND c.cl_scitemcustom_id IN ({$placeholders}) + GROUP BY c.cl_scitemcustom_id" + ); + $stmt->execute(array_merge([$owner_auth_id], $custom_ids)); + + foreach ($stmt->fetchAll() as $row) { + $item_key = 'custom:' . (int) $row['cl_scitemcustom_id']; + $details[$item_key] = [ + 'item_key' => $item_key, + 'source' => 'custom', + 'source_id' => (int) $row['cl_scitemcustom_id'], + 'name' => (string) ($row['cl_scobjs_name'] ?? ''), + 'type' => (string) ($row['cl_scobjs_type'] ?? ''), + 'subtype' => (string) ($row['cl_scobjs_subtype'] ?? ''), + 'uuid' => (string) ($row['cl_scobjs_uuid'] ?? ''), + 'is_custom' => true, + 'stat_count' => (int) ($row['cl_scitemcustom_stat_count'] ?? 0), + ]; + } + } + + return $details; +} + +function sccharacters_fetch_owned_character_item_ids(PDO $db, int $owner_auth_id, int $character_id): array +{ + if ($character_id <= 0 || $owner_auth_id <= 0) { + return []; + } + + sccharacters_reindex_character_items($db, $character_id); + + $stmt = $db->prepare( + 'SELECT ci.cl_sccharacteritem_id + FROM tbl_sccharacteritems ci + INNER JOIN tbl_sccharacters c ON c.cl_sccharacter_id = ci.cl_sccharacteritem_character_id + WHERE ci.cl_sccharacteritem_character_id = :character_id + AND c.cl_sccharacter_owner_auth_id = :owner_auth_id + ORDER BY ci.cl_sccharacteritem_sort_order ASC, ci.cl_sccharacteritem_id ASC' + ); + $stmt->execute([ + 'character_id' => $character_id, + 'owner_auth_id' => $owner_auth_id, + ]); + + return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)); +} + +function sccharacters_save_character_item_order(PDO $db, int $character_id, array $ordered_item_ids): void +{ + if ($character_id <= 0 || $ordered_item_ids === []) { + return; + } + + $stmt = $db->prepare( + 'UPDATE tbl_sccharacteritems + SET cl_sccharacteritem_sort_order = :sort_order + WHERE cl_sccharacteritem_character_id = :character_id + AND cl_sccharacteritem_id = :item_id' + ); + + $db->beginTransaction(); + + try { + $position = 1; + foreach ($ordered_item_ids as $item_id) { + $stmt->execute([ + 'sort_order' => $position, + 'character_id' => $character_id, + 'item_id' => (int) $item_id, + ]); + $position++; + } + + $db->commit(); + } catch (Throwable $exception) { + if ($db->inTransaction()) { + $db->rollBack(); + } + + throw $exception; + } +} + +function sccharacters_move_owned_character_item( + PDO $db, + int $owner_auth_id, + int $character_id, + int $character_item_id, + string $direction +): bool { + $ordered_item_ids = sccharacters_fetch_owned_character_item_ids($db, $owner_auth_id, $character_id); + if ($ordered_item_ids === []) { + return false; + } + + $current_index = array_search($character_item_id, $ordered_item_ids, true); + if ($current_index === false) { + return false; + } + + $target_index = $current_index; + $last_index = count($ordered_item_ids) - 1; + + switch ($direction) { + case 'top': + $target_index = 0; + break; + case 'up': + $target_index = max(0, $current_index - 1); + break; + case 'down': + $target_index = min($last_index, $current_index + 1); + break; + case 'bottom': + $target_index = $last_index; + break; + default: + return false; + } + + if ($target_index === $current_index) { + return true; + } + + $moving_item_id = $ordered_item_ids[$current_index]; + array_splice($ordered_item_ids, $current_index, 1); + array_splice($ordered_item_ids, $target_index, 0, [$moving_item_id]); + + sccharacters_save_character_item_order($db, $character_id, $ordered_item_ids); + + return true; +} + +function sccharacters_fetch_owned_character_item_rows(PDO $db, int $owner_auth_id, int $character_id): array +{ + if ($character_id <= 0 || $owner_auth_id <= 0) { + return []; + } + + sccharacters_reindex_character_items($db, $character_id); + + $stmt = $db->prepare( + "SELECT + ci.cl_sccharacteritem_id, + ci.cl_sccharacteritem_slot, + ci.cl_sccharacteritem_source, + bo.cl_scobjs_type AS cl_sccharacteritem_base_type, + bo.cl_scobjs_subtype AS cl_sccharacteritem_base_subtype, + oo.cl_scobjs_type AS cl_sccharacteritem_custom_type, + oo.cl_scobjs_subtype AS cl_sccharacteritem_custom_subtype + FROM tbl_sccharacteritems ci + INNER JOIN tbl_sccharacters c ON c.cl_sccharacter_id = ci.cl_sccharacteritem_character_id + LEFT JOIN tbl_scobjs bo ON bo.cl_scobjs_id = ci.cl_sccharacteritem_scobjs_id + LEFT JOIN tbl_scitemcustom co ON co.cl_scitemcustom_id = ci.cl_sccharacteritem_scitemcustom_id + LEFT JOIN tbl_scobjs oo ON oo.cl_scobjs_id = co.cl_scitemcustom_obj_id + WHERE ci.cl_sccharacteritem_character_id = :character_id + AND c.cl_sccharacter_owner_auth_id = :owner_auth_id + ORDER BY ci.cl_sccharacteritem_sort_order ASC, ci.cl_sccharacteritem_id ASC" + ); + $stmt->execute([ + 'character_id' => $character_id, + 'owner_auth_id' => $owner_auth_id, + ]); + + return $stmt->fetchAll(); +} + +function sccharacters_group_item_ids_by_category(array $item_rows): array +{ + $grouped = []; + + foreach ($item_rows as $item_row) { + $is_custom = ($item_row['cl_sccharacteritem_source'] ?? '') === 'custom'; + $item_type = $is_custom + ? (string) ($item_row['cl_sccharacteritem_custom_type'] ?? '') + : (string) ($item_row['cl_sccharacteritem_base_type'] ?? ''); + $item_subtype = $is_custom + ? (string) ($item_row['cl_sccharacteritem_custom_subtype'] ?? '') + : (string) ($item_row['cl_sccharacteritem_base_subtype'] ?? ''); + $category_key = sccharacters_resolve_item_category( + (string) ($item_row['cl_sccharacteritem_slot'] ?? ''), + $item_type, + $item_subtype + ); + + if (!isset($grouped[$category_key])) { + $grouped[$category_key] = []; + } + + $grouped[$category_key][] = (int) ($item_row['cl_sccharacteritem_id'] ?? 0); + } + + return $grouped; +} + +function sccharacters_flatten_grouped_item_ids(array $grouped_item_ids, array $category_order): array +{ + $ordered_ids = []; + $sorted_groups = sccharacters_sort_items_by_category_order($grouped_item_ids, $category_order); + + foreach ($sorted_groups as $category_item_ids) { + foreach ($category_item_ids as $item_id) { + $item_id = (int) $item_id; + if ($item_id > 0) { + $ordered_ids[] = $item_id; + } + } + } + + return $ordered_ids; +} + +function sccharacters_move_owned_character_category( + PDO $db, + int $owner_auth_id, + int $character_id, + string $category_key, + string $direction +): bool { + $character = sccharacters_find_owned_character($db, $character_id, $owner_auth_id); + if (!$character) { + return false; + } + + $grouped_item_ids = sccharacters_group_item_ids_by_category( + sccharacters_fetch_owned_character_item_rows($db, $owner_auth_id, $character_id) + ); + if (!isset($grouped_item_ids[$category_key])) { + return false; + } + + $category_order = sccharacters_character_category_order($character); + $visible_order = []; + foreach ($category_order as $ordered_category_key) { + if (isset($grouped_item_ids[$ordered_category_key])) { + $visible_order[] = $ordered_category_key; + } + } + + if ($visible_order === []) { + return false; + } + + $current_index = array_search($category_key, $visible_order, true); + if ($current_index === false) { + return false; + } + + $target_index = $current_index; + if ($direction === 'up') { + $target_index = max(0, $current_index - 1); + } elseif ($direction === 'down') { + $target_index = min(count($visible_order) - 1, $current_index + 1); + } else { + return false; + } + + if ($target_index === $current_index) { + return true; + } + + $moving_category_key = $visible_order[$current_index]; + array_splice($visible_order, $current_index, 1); + array_splice($visible_order, $target_index, 0, [$moving_category_key]); + + $visible_lookup = array_fill_keys(array_keys($grouped_item_ids), true); + $visible_pointer = 0; + $rebuilt_order = []; + foreach ($category_order as $ordered_category_key) { + if (isset($visible_lookup[$ordered_category_key])) { + $rebuilt_order[] = $visible_order[$visible_pointer] ?? $ordered_category_key; + $visible_pointer++; + } else { + $rebuilt_order[] = $ordered_category_key; + } + } + + sccharacters_save_character_category_order($db, $character_id, $rebuilt_order); + sccharacters_save_character_item_order( + $db, + $character_id, + sccharacters_flatten_grouped_item_ids($grouped_item_ids, $rebuilt_order) + ); + + return true; +} + +function sccharacters_reorder_owned_character_items( + PDO $db, + int $owner_auth_id, + int $character_id, + string $category_key, + array $ordered_item_ids +): bool { + $character = sccharacters_find_owned_character($db, $character_id, $owner_auth_id); + if (!$character) { + return false; + } + + $grouped_item_ids = sccharacters_group_item_ids_by_category( + sccharacters_fetch_owned_character_item_rows($db, $owner_auth_id, $character_id) + ); + if (!isset($grouped_item_ids[$category_key])) { + return false; + } + + $current_item_ids = array_map('intval', $grouped_item_ids[$category_key]); + $posted_item_ids = array_values(array_filter(array_map('intval', $ordered_item_ids), static fn ($item_id) => $item_id > 0)); + + sort($current_item_ids); + $sorted_posted = $posted_item_ids; + sort($sorted_posted); + + if ($posted_item_ids === [] || $sorted_posted !== $current_item_ids) { + return false; + } + + $grouped_item_ids[$category_key] = $posted_item_ids; + sccharacters_save_character_item_order( + $db, + $character_id, + sccharacters_flatten_grouped_item_ids($grouped_item_ids, sccharacters_character_category_order($character)) + ); + + return true; +} + function sccharacters_attach_item( PDO $db, int $owner_auth_id, @@ -146,6 +641,8 @@ function sccharacters_attach_item( return false; } + $sort_order = sccharacters_next_item_sort_order($db, $character_id); + if ($source === 'base') { $stmt_source = $db->prepare( 'SELECT cl_scobjs_id AS item_id, cl_scobjs_type, cl_scobjs_subtype @@ -174,14 +671,16 @@ function sccharacters_attach_item( cl_sccharacteritem_scobjs_id, cl_sccharacteritem_scitemcustom_id, cl_sccharacteritem_slot, - cl_sccharacteritem_note + cl_sccharacteritem_note, + cl_sccharacteritem_sort_order ) VALUES ( :character_id, :source, :scobjs_id, NULL, :slot, - :note + :note, + :sort_order )' ); $stmt_insert->execute([ @@ -190,6 +689,7 @@ function sccharacters_attach_item( 'scobjs_id' => $source_id, 'slot' => $category, 'note' => $note !== '' ? $note : null, + 'sort_order' => $sort_order, ]); return true; @@ -228,14 +728,16 @@ function sccharacters_attach_item( cl_sccharacteritem_scobjs_id, cl_sccharacteritem_scitemcustom_id, cl_sccharacteritem_slot, - cl_sccharacteritem_note + cl_sccharacteritem_note, + cl_sccharacteritem_sort_order ) VALUES ( :character_id, :source, NULL, :scitemcustom_id, :slot, - :note + :note, + :sort_order )' ); $stmt_insert->execute([ @@ -244,6 +746,7 @@ function sccharacters_attach_item( 'scitemcustom_id' => $source_id, 'slot' => $category, 'note' => $note !== '' ? $note : null, + 'sort_order' => $sort_order, ]); return true; @@ -287,6 +790,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $description = sccharacters_clean_text($_POST['character_description'] ?? ''); $notes = sccharacters_clean_text($_POST['character_notes'] ?? ''); $share_enabled = isset($_POST['character_share_enabled']) ? 1 : 0; + $posted_category_order_state = sccharacters_clean_text($_POST['category_order_state'] ?? ''); + $posted_item_order_state_raw = trim((string) ($_POST['item_order_state'] ?? '')); if ($name === '') { auth_flash_set('error', 'Le nom du personnage est obligatoire.'); @@ -358,6 +863,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $description = sccharacters_clean_text($_POST['character_description'] ?? ''); $notes = sccharacters_clean_text($_POST['character_notes'] ?? ''); $share_enabled = isset($_POST['character_share_enabled']) ? 1 : 0; + $posted_category_order_state = sccharacters_clean_text($_POST['category_order_state'] ?? ''); + $posted_item_order_state_raw = trim((string) ($_POST['item_order_state'] ?? '')); if ($name === '') { auth_flash_set('error', 'Le nom du personnage est obligatoire.'); @@ -395,6 +902,84 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'owner_auth_id' => $current_owner_auth_id, ]); + $current_item_rows = sccharacters_fetch_owned_character_item_rows($db, $current_owner_auth_id, $character_id); + $grouped_item_ids = sccharacters_group_item_ids_by_category($current_item_rows); + $current_visible_category_keys = array_keys( + sccharacters_sort_items_by_category_order($grouped_item_ids, sccharacters_character_category_order($character)) + ); + + $submitted_visible_category_order = $current_visible_category_keys; + if ($posted_category_order_state !== '') { + $candidate_category_order = array_values(array_filter( + array_map('sccharacters_clean_text', preg_split('/\s*,\s*/', $posted_category_order_state, -1, PREG_SPLIT_NO_EMPTY) ?: []), + static fn (string $category_key): bool => $category_key !== '' + )); + + if ($candidate_category_order !== []) { + $candidate_sorted = $candidate_category_order; + $current_sorted = $current_visible_category_keys; + sort($candidate_sorted); + sort($current_sorted); + + if ($candidate_sorted === $current_sorted) { + $submitted_visible_category_order = $candidate_category_order; + } + } + } + + $submitted_item_order_state = []; + if ($posted_item_order_state_raw !== '') { + $decoded_item_order_state = json_decode($posted_item_order_state_raw, true); + if (is_array($decoded_item_order_state)) { + $submitted_item_order_state = $decoded_item_order_state; + } + } + + foreach ($grouped_item_ids as $category_key => $current_category_item_ids) { + $current_category_item_ids = array_values(array_map('intval', $current_category_item_ids)); + $candidate_category_item_ids = $submitted_item_order_state[$category_key] ?? null; + if (!is_array($candidate_category_item_ids)) { + $grouped_item_ids[$category_key] = $current_category_item_ids; + continue; + } + + $candidate_category_item_ids = array_values(array_filter( + array_map('intval', $candidate_category_item_ids), + static fn (int $item_id): bool => $item_id > 0 + )); + + $sorted_candidate_item_ids = $candidate_category_item_ids; + $sorted_current_category_item_ids = $current_category_item_ids; + sort($sorted_candidate_item_ids); + sort($sorted_current_category_item_ids); + + if ($candidate_category_item_ids !== [] && $sorted_candidate_item_ids === $sorted_current_category_item_ids) { + $grouped_item_ids[$category_key] = $candidate_category_item_ids; + } else { + $grouped_item_ids[$category_key] = $current_category_item_ids; + } + } + + $persisted_category_order = sccharacters_character_category_order($character); + $visible_category_lookup = array_fill_keys(array_keys($grouped_item_ids), true); + $visible_pointer = 0; + $rebuilt_category_order = []; + foreach ($persisted_category_order as $ordered_category_key) { + if (isset($visible_category_lookup[$ordered_category_key])) { + $rebuilt_category_order[] = $submitted_visible_category_order[$visible_pointer] ?? $ordered_category_key; + $visible_pointer++; + } else { + $rebuilt_category_order[] = $ordered_category_key; + } + } + + sccharacters_save_character_category_order($db, $character_id, $rebuilt_category_order); + sccharacters_save_character_item_order( + $db, + $character_id, + sccharacters_flatten_grouped_item_ids($grouped_item_ids, $rebuilt_category_order) + ); + auth_flash_set('success', 'Personnage mis à jour.'); header('Location: sccharacters.php?character=' . $character_id); exit; @@ -459,6 +1044,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $note = sccharacters_clean_text($_POST['item_note'] ?? ''); $item_source_context = sccharacters_clean_text($_POST['item_source_context'] ?? 'base'); $item_search_context = sccharacters_clean_text($_POST['item_search_context'] ?? ''); + $item_page_context = max(1, (int) ($_POST['item_page_context'] ?? 1)); + $item_per_page_context = sccharacters_normalize_item_per_page($_POST['item_per_page_context'] ?? 50); $error_message = null; if (!sccharacters_attach_item( @@ -472,12 +1059,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error_message )) { auth_flash_set('error', $error_message ?? 'Impossible d’ajouter l’objet.'); - header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true)); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true, $item_page_context, $item_per_page_context)); exit; } auth_flash_set('success', 'Objet de la base ajouté au personnage.'); - header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true)); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true, $item_page_context, $item_per_page_context)); exit; } @@ -488,6 +1075,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $note = sccharacters_clean_text($_POST['item_note'] ?? ''); $item_source_context = sccharacters_clean_text($_POST['item_source_context'] ?? 'custom'); $item_search_context = sccharacters_clean_text($_POST['item_search_context'] ?? ''); + $item_page_context = max(1, (int) ($_POST['item_page_context'] ?? 1)); + $item_per_page_context = sccharacters_normalize_item_per_page($_POST['item_per_page_context'] ?? 50); $error_message = null; if (!sccharacters_attach_item( @@ -501,90 +1090,64 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error_message )) { auth_flash_set('error', $error_message ?? 'Impossible d’ajouter l’objet.'); - header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true)); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true, $item_page_context, $item_per_page_context)); exit; } auth_flash_set('success', 'Objet personnalisé ajouté au personnage.'); - header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true)); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true, $item_page_context, $item_per_page_context)); + exit; + } + if ($action === 'move_character_category') { + $character_id = (int) ($_POST['character_id'] ?? 0); + $category_key = sccharacters_clean_text($_POST['category_key'] ?? ''); + $move_direction = sccharacters_clean_text($_POST['move_direction'] ?? ''); + $item_source_context = sccharacters_clean_text($_POST['item_source_context'] ?? 'base'); + $item_search_context = sccharacters_clean_text($_POST['item_search_context'] ?? ''); + $item_page_context = max(1, (int) ($_POST['item_page_context'] ?? 1)); + $item_per_page_context = sccharacters_normalize_item_per_page($_POST['item_per_page_context'] ?? 50); + $item_panel_context = (string) ($_POST['item_panel_context'] ?? '') === '1'; + + if (!sccharacters_move_owned_character_category($db, $current_owner_auth_id, $character_id, $category_key, $move_direction)) { + auth_flash_set('error', 'Impossible de déplacer cette catégorie.'); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, $item_panel_context, $item_page_context, $item_per_page_context)); + exit; + } + + auth_flash_set('success', 'Ordre des catégories mis à jour.'); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, $item_panel_context, $item_page_context, $item_per_page_context)); exit; } - if ($action === 'add_selected_items') { + if ($action === 'reorder_character_items') { $character_id = (int) ($_POST['character_id'] ?? 0); + $category_key = sccharacters_clean_text($_POST['category_key'] ?? ''); + $ordered_item_ids = preg_split('/\s*,\s*/', (string) ($_POST['ordered_item_ids'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; $item_source_context = sccharacters_clean_text($_POST['item_source_context'] ?? 'base'); $item_search_context = sccharacters_clean_text($_POST['item_search_context'] ?? ''); - $selected_items = $_POST['selected_items'] ?? []; - $return_url = sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, true); - $character = sccharacters_find_owned_character($db, $character_id, $current_owner_auth_id); + $item_page_context = max(1, (int) ($_POST['item_page_context'] ?? 1)); + $item_per_page_context = sccharacters_normalize_item_per_page($_POST['item_per_page_context'] ?? 50); + $item_panel_context = (string) ($_POST['item_panel_context'] ?? '') === '1'; - if (!$character) { - auth_flash_set('error', 'Personnage introuvable.'); - header('Location: sccharacters.php'); + if (!sccharacters_reorder_owned_character_items($db, $current_owner_auth_id, $character_id, $category_key, $ordered_item_ids)) { + auth_flash_set('error', 'Impossible de réordonner les objets.'); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, $item_panel_context, $item_page_context, $item_per_page_context)); exit; } - if (!is_array($selected_items) || $selected_items === []) { - auth_flash_set('error', 'Sélectionne au moins un objet avant de valider.'); - header('Location: ' . $return_url); - exit; - } - - $selected_items = array_values(array_unique(array_map('strval', $selected_items))); - $item_slots = isset($_POST['item_slot']) && is_array($_POST['item_slot']) ? $_POST['item_slot'] : []; - $item_notes = isset($_POST['item_note']) && is_array($_POST['item_note']) ? $_POST['item_note'] : []; - $added_count = 0; - $error_count = 0; - - foreach ($selected_items as $item_key) { - if (!preg_match('/^(base|custom):(\d+)$/', $item_key, $matches)) { - $error_count++; - continue; - } - - $source = (string) $matches[1]; - $source_id = (int) $matches[2]; - $requested_category = sccharacters_clean_text($item_slots[$item_key] ?? ''); - $note = sccharacters_clean_text($item_notes[$item_key] ?? ''); - $error_message = null; - - if (sccharacters_attach_item( - $db, - $current_owner_auth_id, - $character_id, - $source, - $source_id, - $requested_category, - $note, - $error_message - )) { - $added_count++; - continue; - } - - $error_count++; - } - - if ($added_count <= 0) { - auth_flash_set('error', 'Aucun objet n’a pu être ajouté à la sélection.'); - header('Location: ' . $return_url); - exit; - } - - if ($error_count > 0) { - auth_flash_set('success', $added_count . ' objet(s) ajouté(s). Certains éléments sélectionnés ont été ignorés.'); - header('Location: ' . $return_url); - exit; - } - - auth_flash_set('success', $added_count . ' objet(s) ajouté(s) au personnage.'); - header('Location: ' . $return_url); + auth_flash_set('success', 'Ordre des objets mis à jour.'); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, $item_panel_context, $item_page_context, $item_per_page_context)); exit; } if ($action === 'delete_character_item') { $character_item_id = (int) ($_POST['character_item_id'] ?? 0); $character_id = (int) ($_POST['character_id'] ?? 0); + $item_source_context = sccharacters_clean_text($_POST['item_source_context'] ?? 'base'); + $item_search_context = sccharacters_clean_text($_POST['item_search_context'] ?? ''); + $item_page_context = max(1, (int) ($_POST['item_page_context'] ?? 1)); + $item_per_page_context = sccharacters_normalize_item_per_page($_POST['item_per_page_context'] ?? 50); + $item_panel_context = (string) ($_POST['item_panel_context'] ?? '') === '1'; $stmt = $db->prepare( 'DELETE ci @@ -600,8 +1163,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'owner_auth_id' => $current_owner_auth_id, ]); + sccharacters_reindex_character_items($db, $character_id); + auth_flash_set('success', 'Objet retiré du personnage.'); - header('Location: sccharacters.php?character=' . $character_id); + header('Location: ' . sccharacters_build_return_url($character_id, $item_source_context, $item_search_context, $item_panel_context, $item_page_context, $item_per_page_context)); exit; } } @@ -641,10 +1206,39 @@ if (!in_array($item_source, ['base', 'custom'], true)) { $item_search = sccharacters_clean_text($_GET['item_search'] ?? ''); $item_panel_open = (string) ($_GET['item_panel'] ?? '') === '1'; +$item_page = max(1, (int) ($_GET['item_page'] ?? 1)); +$item_per_page = sccharacters_normalize_item_per_page($_GET['item_per_page'] ?? 50); $item_results = []; +$item_total_results = 0; +$item_total_pages = 1; +$item_result_offset_start = 0; +$item_result_offset_end = 0; if ($selected_character) { if ($item_source === 'custom') { + $count_sql = "SELECT COUNT(DISTINCT c.cl_scitemcustom_id) + FROM tbl_scitemcustom c + INNER JOIN tbl_scobjs o ON o.cl_scobjs_id = c.cl_scitemcustom_obj_id + WHERE c.cl_scitemcustom_owner_auth_id = :owner_auth_id"; + $params = ['owner_auth_id' => $current_owner_auth_id]; + + if ($item_search !== '') { + $count_sql .= " AND ( + o.cl_scobjs_name LIKE :search + OR o.cl_scobjs_type LIKE :search + OR o.cl_scobjs_subtype LIKE :search + OR o.cl_scobjs_uuid LIKE :search + )"; + $params['search'] = '%' . $item_search . '%'; + } + + $stmt_item_count = $db->prepare($count_sql); + $stmt_item_count->execute($params); + $item_total_results = (int) $stmt_item_count->fetchColumn(); + $item_total_pages = max(1, (int) ceil($item_total_results / $item_per_page)); + $item_page = min($item_page, $item_total_pages); + $item_offset = max(0, ($item_page - 1) * $item_per_page); + $sql = "SELECT c.cl_scitemcustom_id, o.cl_scobjs_name, o.cl_scobjs_type, @@ -655,7 +1249,6 @@ if ($selected_character) { INNER JOIN tbl_scobjs o ON o.cl_scobjs_id = c.cl_scitemcustom_obj_id LEFT JOIN tbl_scitemcustomstat cs ON cs.cl_scitemcustomstat_itemcustom_id = c.cl_scitemcustom_id WHERE c.cl_scitemcustom_owner_auth_id = :owner_auth_id"; - $params = ['owner_auth_id' => $current_owner_auth_id]; if ($item_search !== '') { $sql .= " AND ( @@ -664,25 +1257,24 @@ if ($selected_character) { OR o.cl_scobjs_subtype LIKE :search OR o.cl_scobjs_uuid LIKE :search )"; - $params['search'] = '%' . $item_search . '%'; } $sql .= " GROUP BY c.cl_scitemcustom_id ORDER BY o.cl_scobjs_name ASC, c.cl_scitemcustom_id ASC - LIMIT 25"; + LIMIT " . (int) $item_per_page . " OFFSET " . (int) $item_offset; $stmt_item_results = $db->prepare($sql); $stmt_item_results->execute($params); $item_results = $stmt_item_results->fetchAll(); } else { - $sql = "SELECT cl_scobjs_id, cl_scobjs_name, cl_scobjs_type, cl_scobjs_subtype, cl_scobjs_uuid - FROM tbl_scobjs - WHERE 1 = 1"; + $count_sql = "SELECT COUNT(*) + FROM tbl_scobjs + WHERE 1 = 1"; $params = []; if ($item_search !== '') { - $sql .= " AND ( + $count_sql .= " AND ( cl_scobjs_name LIKE :search OR cl_scobjs_type LIKE :search OR cl_scobjs_subtype LIKE :search @@ -691,19 +1283,47 @@ if ($selected_character) { $params['search'] = '%' . $item_search . '%'; } + $stmt_item_count = $db->prepare($count_sql); + $stmt_item_count->execute($params); + $item_total_results = (int) $stmt_item_count->fetchColumn(); + $item_total_pages = max(1, (int) ceil($item_total_results / $item_per_page)); + $item_page = min($item_page, $item_total_pages); + $item_offset = max(0, ($item_page - 1) * $item_per_page); + + $sql = "SELECT cl_scobjs_id, cl_scobjs_name, cl_scobjs_type, cl_scobjs_subtype, cl_scobjs_uuid + FROM tbl_scobjs + WHERE 1 = 1"; + + if ($item_search !== '') { + $sql .= " AND ( + cl_scobjs_name LIKE :search + OR cl_scobjs_type LIKE :search + OR cl_scobjs_subtype LIKE :search + OR cl_scobjs_uuid LIKE :search + )"; + } + $sql .= " ORDER BY cl_scobjs_name ASC - LIMIT 25"; + LIMIT " . (int) $item_per_page . " OFFSET " . (int) $item_offset; $stmt_item_results = $db->prepare($sql); $stmt_item_results->execute($params); $item_results = $stmt_item_results->fetchAll(); } + + if ($item_total_results > 0) { + $item_result_offset_start = (($item_page - 1) * $item_per_page) + 1; + $item_result_offset_end = min($item_total_results, $item_result_offset_start + count($item_results) - 1); + } + } $selected_character_items = []; $custom_stats_by_itemcustom = []; if ($selected_character) { + sccharacters_reindex_character_items($db, (int) $selected_character['cl_sccharacter_id']); + $stmt_character_items = $db->prepare( "SELECT ci.*, @@ -721,11 +1341,7 @@ if ($selected_character) { LEFT JOIN tbl_scitemcustom co ON co.cl_scitemcustom_id = ci.cl_sccharacteritem_scitemcustom_id LEFT JOIN tbl_scobjs oo ON oo.cl_scobjs_id = co.cl_scitemcustom_obj_id WHERE ci.cl_sccharacteritem_character_id = :character_id - ORDER BY - CASE WHEN TRIM(ci.cl_sccharacteritem_slot) = '' THEN 1 ELSE 0 END, - ci.cl_sccharacteritem_slot ASC, - COALESCE(oo.cl_scobjs_name, bo.cl_scobjs_name, 'ZZZ') ASC, - ci.cl_sccharacteritem_id ASC" + ORDER BY ci.cl_sccharacteritem_sort_order ASC, ci.cl_sccharacteritem_id ASC" ); $stmt_character_items->execute(['character_id' => (int) $selected_character['cl_sccharacter_id']]); $selected_character_items = $stmt_character_items->fetchAll(); @@ -765,10 +1381,10 @@ if ($selected_character) { } $item_category_options = sccharacters_item_category_options(); +$selected_character_category_order = $selected_character + ? sccharacters_character_category_order($selected_character) + : sccharacters_default_category_order(); $selected_character_items_by_category = []; -foreach (array_keys($item_category_options) as $category_key) { - $selected_character_items_by_category[$category_key] = []; -} foreach ($selected_character_items as $character_item_row) { $is_custom = ($character_item_row['cl_sccharacteritem_source'] ?? '') === 'custom'; $item_type = $is_custom @@ -787,11 +1403,34 @@ foreach ($selected_character_items as $character_item_row) { } $selected_character_items_by_category[$category_key][] = $character_item_row; } -$selected_character_items_by_category = array_filter( +$selected_character_items_by_category = sccharacters_sort_items_by_category_order( $selected_character_items_by_category, - static fn(array $items): bool => $items !== [] + $selected_character_category_order ); - +$selected_character_category_order_meta = []; +$visible_category_keys = array_keys($selected_character_items_by_category); +$visible_category_count = count($visible_category_keys); +foreach ($visible_category_keys as $visible_category_index => $visible_category_key) { + $selected_character_category_order_meta[$visible_category_key] = [ + 'is_first' => $visible_category_index === 0, + 'is_last' => $visible_category_index === ($visible_category_count - 1), + ]; +} +$selected_character_visible_category_order_state = implode(',', $visible_category_keys); +$selected_character_item_order_state = []; +foreach ($selected_character_items_by_category as $category_key => $category_items) { + $selected_character_item_order_state[$category_key] = array_values(array_map( + static fn (array $item_row): int => (int) ($item_row['cl_sccharacteritem_id'] ?? 0), + $category_items + )); +} +$selected_character_item_order_state_json = json_encode( + $selected_character_item_order_state, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES +); +if (!is_string($selected_character_item_order_state_json)) { + $selected_character_item_order_state_json = '{}'; +} $create_character = [ 'cl_sccharacter_name' => '', 'cl_sccharacter_role' => '', @@ -801,6 +1440,24 @@ $create_character = [ 'cl_sccharacter_notes' => '', 'cl_sccharacter_share_enabled' => 0, ]; +$item_has_previous_page = $item_page > 1; +$item_has_next_page = $item_page < $item_total_pages; +$item_query_base_params = []; +if ($selected_character) { + $item_query_base_params = [ + 'character' => (int) $selected_character['cl_sccharacter_id'], + 'item_panel' => 1, + 'item_source' => $item_source, + ]; + + if ($item_search !== '') { + $item_query_base_params['item_search'] = $item_search; + } + + if ($item_per_page !== 50) { + $item_query_base_params['item_per_page'] = $item_per_page; + } +} ?> @@ -1283,6 +1940,20 @@ $create_character = [ gap: 0.8rem; padding-bottom: 0.35rem; border-bottom: 1px solid rgba(255,255,255,0.08); + flex-wrap: wrap; + } + + .equipment-section-heading-main { + display: inline-flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; + } + + .equipment-category-order-form { + display: inline-flex; + align-items: center; + gap: 0.35rem; } .equipment-section-title { @@ -1304,6 +1975,18 @@ $create_character = [ display: flex; gap: 0.9rem; align-items: flex-start; + cursor: grab; + transition: opacity 0.18s ease, transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; + } + + .equip-card.dragging { + opacity: 0.45; + cursor: grabbing; + } + + .equip-card.drag-over { + border-color: rgba(162, 155, 120, 0.55); + background: rgba(162, 155, 120, 0.09); } .equip-thumb, @@ -1326,15 +2009,51 @@ $create_character = [ display: flex; flex-direction: column; gap: 0.6rem; + min-width: 0; + } + + .equip-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + } + + .drag-handle { + color: var(--text-soft); + font-size: 1rem; + line-height: 1; + letter-spacing: -0.1em; + user-select: none; + flex: 0 0 auto; + } + + .drag-hint { + font-size: 0.8rem; } .equipment-actions { margin-top: auto; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + justify-content: space-between; + } + + .equipment-reorder-form { + display: none; + } + + .order-btn { + min-width: 2.2rem; + padding-inline: 0.55rem; } .equip-title { font-size: 1rem; line-height: 1.3; + margin: 0; } .equip-stats { @@ -1363,6 +2082,21 @@ $create_character = [ gap: 0.9rem; } + .search-top-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + } + + .search-controls-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 170px auto; + gap: 0.75rem; + align-items: center; + } + .source-switch { display: inline-grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1382,18 +2116,31 @@ $create_character = [ .source-switch label { min-width: 155px; + min-height: 46px; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; text-align: center; padding: 0.7rem 0.9rem; border-radius: 10px; cursor: pointer; + font-family: inherit; font-size: 0.82rem; + font-weight: 400; + line-height: 1.2; letter-spacing: 0.05em; text-transform: uppercase; + white-space: nowrap; color: var(--text-soft); transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease; border: 1px solid transparent; } + .source-switch label:hover { + color: var(--text-main); + } + .source-switch input:checked + label { background: rgba(162, 155, 120, 0.16); border-color: rgba(162, 155, 120, 0.32); @@ -1414,12 +2161,8 @@ $create_character = [ margin-top: 1rem; } - .search-results-form { - display: grid; - gap: 0.75rem; - } - - .search-batch-toolbar { + .search-results-summary, + .pagination-bar { display: flex; align-items: center; justify-content: space-between; @@ -1427,33 +2170,16 @@ $create_character = [ padding: 0.9rem 1rem; } - .search-batch-toolbar-bottom { - margin-top: 0.2rem; - } - .search-result { padding: 0.95rem; display: grid; - grid-template-columns: 130px minmax(0, 1fr) 310px; + grid-template-columns: minmax(0, 1fr) 310px; gap: 1rem; align-items: start; } - .search-result-selector { - display: inline-flex; - align-items: center; - gap: 0.55rem; - min-height: 100%; - color: var(--text-main); - font-weight: 600; - cursor: pointer; - user-select: none; - } - - .search-result-selector input { - width: 1rem; - height: 1rem; - margin: 0; + body.show-item-preview .search-result { + grid-template-columns: 96px minmax(0, 1fr) 310px; } .search-result strong { display: block; margin-bottom: 0.25rem; } @@ -1465,9 +2191,75 @@ $create_character = [ gap: 0.55rem; } + .search-result-preview { + display: none; + align-items: flex-start; + justify-content: center; + } + + body.show-item-preview .search-result-preview { + display: flex; + } + + .preview-container { + position: relative; + width: 80px; + height: 80px; + cursor: zoom-in; + } + + .item-preview, + .item-preview-fallback { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 16px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(0,0,0,0.35); + display: block; + } + + .item-preview-fallback { + display: grid; + place-items: center; + font-size: 1.2rem; + color: var(--text-soft); + } + + .preview-floating { + visibility: hidden; + opacity: 0; + position: absolute; + top: -10px; + left: 92px; + z-index: 20; + padding: 5px; + background: var(--card-bg); + border: 1px solid rgba(162, 155, 120, 0.4); + border-radius: 12px; + box-shadow: 0 14px 32px rgba(0,0,0,0.45); + backdrop-filter: blur(12px); + transition: opacity 0.2s ease, visibility 0.2s ease; + pointer-events: none; + } + + .preview-floating img { + width: 260px; + height: 260px; + object-fit: contain; + display: block; + border-radius: 8px; + } + + .preview-container:hover .preview-floating, + .preview-container:focus-within .preview-floating { + visibility: visible; + opacity: 1; + } + .item-attach-form { display: grid; - gap: 0.55rem; + gap: 0.65rem; } .item-attach-meta { @@ -1476,6 +2268,57 @@ $create_character = [ gap: 0.55rem; } + .item-attach-actions { + display: flex; + justify-content: flex-end; + } + + .item-add-button { + min-width: 2.75rem; + min-height: 2.75rem; + padding: 0.4rem; + font-size: 1.55rem; + line-height: 1; + font-weight: 700; + } + + .search-results-summary { + flex-wrap: wrap; + } + + .preview-toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-size: 0.9rem; + color: var(--text-main); + user-select: none; + cursor: pointer; + } + + .preview-toggle input { + width: 1rem; + height: 1rem; + margin: 0; + } + + .search-result-range { + font-size: 0.88rem; + color: var(--text-soft); + } + + .pagination-links { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; + align-items: center; + } + + .pagination-label { + font-size: 0.88rem; + color: var(--text-soft); + } + .empty-state { padding: 1.2rem; color: var(--text-soft); @@ -1484,23 +2327,60 @@ $create_character = [ .empty-state strong { color: var(--text-main); display: block; margin-bottom: 0.35rem; } + .nav-tabs { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + border-bottom: 1px solid var(--primary-border); + padding-bottom: 1rem; + flex-wrap: wrap; + } + + .nav-tabs a { + text-decoration: none; + color: #888; + text-transform: uppercase; + font-size: 0.9rem; + transition: color 0.3s; + } + + .nav-tabs a:hover, + .nav-tabs a.active { + color: var(--primary); + } + @media (max-width: 1280px) { .main-grid { grid-template-columns: 340px minmax(0, 1fr); } - .search-batch-toolbar { + .search-results-summary, + .pagination-bar { flex-direction: column; align-items: stretch; } - .search-result { + .search-result, + body.show-item-preview .search-result { grid-template-columns: 1fr; } - .item-attach-meta { + .search-result-preview { + justify-content: flex-start; + } + + .item-attach-meta, + .search-controls-row { grid-template-columns: 1fr; } + + .item-attach-actions { + justify-content: stretch; + } + + .item-add-button { + width: 100%; + } } @media (max-width: 1180px) { @@ -1538,7 +2418,7 @@ $create_character = [ } - +
@@ -1570,7 +2450,7 @@ $create_character = [ +
-
+ @@ -1740,10 +2620,12 @@ $create_character = [ +
- + + +
@@ -1780,6 +2662,7 @@ $create_character = [
+
@@ -1810,14 +2693,25 @@ $create_character = [ Utilise le module de recherche plus bas pour ajouter un objet de la base ou un objet personnalisé.
-
+
+ Tu peux réorganiser librement les catégories et les objets ci-dessous, puis enregistrer quand tu veux. + +
+
$category_items): ?> -
+ false, 'is_last' => false]; ?> +
-

- objet(s) +
+

+ objet(s) +
+
+ + +
-
+
-
+
Aperçu de <?php echo htmlspecialchars($item_name, ENT_QUOTES, 'UTF-8'); ?> @@ -1852,7 +2746,10 @@ $create_character = [
-

+
+

+ +
@@ -1881,11 +2778,17 @@ $create_character = [
+ Glisser-déposer pour réordonner dans cette catégorie
+ + + + +
@@ -1896,6 +2799,7 @@ $create_character = [
+
@@ -1910,20 +2814,35 @@ $create_character = [
-
+ + -
- > - +
+
+ > + - > - + > + +
+ +
-
+
+
@@ -1943,96 +2862,123 @@ $create_character = [ Aucun objet de la base ne correspond à « ». - La recherche est prête. Tu peux aussi laisser le champ vide pour parcourir les 25 premiers objets de la base. + Lance une recherche ou parcours le catalogue page par page avec le sélecteur de résultats.
-
- - - - - - -
-
- Ajout en lot -
Coche plusieurs objets, ajuste leurs catégories ou notes si besoin, puis valide tout en une seule fois.
+
+
+ Résultats de recherche +
+ sur résultat(s) + · page /
-
+
Le bouton + ajoute l’objet immédiatement au personnage sans casser ta recherche en cours.
+
- + + +
+
+ +
+ Aperçu de <?php echo htmlspecialchars($result_name, ENT_QUOTES, 'UTF-8'); ?> + +
+ +
+ +
+ +
+
+ +
+
+
+ + + UUID : + + + stat(s) + +
+
+ + + + + + + + + + + +
+ + +
+ +
+ +
+ +
+ + +
+
Navigation dans les résultats
+ -
- +
@@ -2059,41 +3005,246 @@ $create_character = [ } } - document.querySelectorAll('input[name="item_source"]').forEach((input) => { + const itemSearchForm = document.getElementById('itemSearchForm'); + const itemPreviewToggle = document.getElementById('itemPreviewToggle'); + const itemSourceInputs = Array.from(document.querySelectorAll('input[name="item_source"]')); + const previewStorageKey = 'sccharacters-item-preview-v2'; + + function setPreviewState(enabled) { + document.body.classList.toggle('show-item-preview', enabled); + + if (itemPreviewToggle) { + itemPreviewToggle.checked = enabled; + } + } + + let previewEnabled = true; + + try { + const storedPreviewState = window.localStorage.getItem(previewStorageKey); + previewEnabled = storedPreviewState === null ? true : storedPreviewState === '1'; + } catch (error) { + previewEnabled = true; + } + + setPreviewState(previewEnabled); + + if (itemPreviewToggle) { + itemPreviewToggle.addEventListener('change', () => { + const enabled = itemPreviewToggle.checked; + setPreviewState(enabled); + + try { + window.localStorage.setItem(previewStorageKey, enabled ? '1' : '0'); + } catch (error) { + // Ignore storage issues silently. + } + }); + } + + itemSourceInputs.forEach((input) => { input.addEventListener('change', () => { - const form = document.getElementById('itemSearchForm'); - if (form) { - form.submit(); + if (itemSearchForm) { + itemSearchForm.submit(); } }); }); - const itemSelectionCheckboxes = Array.from(document.querySelectorAll('.item-select-checkbox')); - const batchAddButton = document.getElementById('batchAddButton'); - const selectedItemCount = document.getElementById('selectedItemCount'); - const batchSubmitButtons = Array.from(document.querySelectorAll('[data-batch-submit]')); + function initEquipmentOrdering() { + const editForm = document.getElementById('characterEditForm'); + const categoryOrderInput = document.getElementById('characterCategoryOrderState'); + const itemOrderInput = document.getElementById('characterItemOrderState'); + const orderStateIndicator = document.getElementById('characterOrderStateIndicator'); + const equipmentSections = document.getElementById('characterEquipmentSections'); + const categoryButtons = Array.from(document.querySelectorAll('.category-order-btn')); + const grids = Array.from(document.querySelectorAll('.equipment-grid-sortable')); - function updateBatchSelectionState() { - const checkedCount = itemSelectionCheckboxes.filter((checkbox) => checkbox.checked).length; - - if (selectedItemCount) { - selectedItemCount.textContent = String(checkedCount); + if (!editForm || !categoryOrderInput || !itemOrderInput || !equipmentSections) { + return; } - if (batchAddButton) { - batchAddButton.disabled = checkedCount === 0; - } + const readItemOrderState = () => { + try { + const parsed = JSON.parse(itemOrderInput.value || '{}'); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + return {}; + } + }; - batchSubmitButtons.forEach((button) => { - button.disabled = checkedCount === 0; + let hasPendingOrderChanges = false; + + const markPendingOrderChanges = () => { + hasPendingOrderChanges = true; + if (orderStateIndicator) { + orderStateIndicator.hidden = false; + } + }; + + const syncCategoryButtonState = () => { + const sections = Array.from(equipmentSections.querySelectorAll('.equipment-section[data-category-key]')); + const firstKey = sections[0]?.dataset.categoryKey || ''; + const lastKey = sections[sections.length - 1]?.dataset.categoryKey || ''; + + categoryButtons.forEach((button) => { + const categoryKey = button.dataset.categoryKey || ''; + const direction = button.dataset.moveDirection || ''; + button.disabled = (direction === 'up' && categoryKey === firstKey) + || (direction === 'down' && categoryKey === lastKey); + }); + }; + + const syncCategoryOrderState = () => { + const orderedKeys = Array.from(equipmentSections.querySelectorAll('.equipment-section[data-category-key]')) + .map((section) => section.dataset.categoryKey || '') + .filter(Boolean); + categoryOrderInput.value = orderedKeys.join(','); + syncCategoryButtonState(); + }; + + const syncItemOrderStateForGrid = (grid) => { + const categoryKey = grid.dataset.categoryKey || ''; + if (!categoryKey) { + return; + } + + const state = readItemOrderState(); + state[categoryKey] = Array.from(grid.querySelectorAll('.equip-card[data-item-id]')) + .map((card) => Number(card.dataset.itemId || 0)) + .filter((itemId) => Number.isInteger(itemId) && itemId > 0); + itemOrderInput.value = JSON.stringify(state); + }; + + categoryButtons.forEach((button) => { + button.addEventListener('click', () => { + const categoryKey = button.dataset.categoryKey || ''; + const direction = button.dataset.moveDirection || ''; + const section = equipmentSections.querySelector(`.equipment-section[data-category-key="${categoryKey}"]`); + if (!section) { + return; + } + + if (direction === 'up') { + const previousSection = section.previousElementSibling; + if (previousSection) { + equipmentSections.insertBefore(section, previousSection); + } + } else if (direction === 'down') { + const nextSection = section.nextElementSibling; + if (nextSection) { + equipmentSections.insertBefore(nextSection, section); + } + } + + syncCategoryOrderState(); + markPendingOrderChanges(); + }); }); + + grids.forEach((grid) => { + const cards = Array.from(grid.querySelectorAll('.equip-card[data-item-id]')); + if (cards.length < 2) { + syncItemOrderStateForGrid(grid); + return; + } + + let draggedCard = null; + let originalOrder = cards.map((card) => card.dataset.itemId).join(','); + + const currentOrder = () => Array.from(grid.querySelectorAll('.equip-card[data-item-id]')) + .map((card) => card.dataset.itemId) + .filter(Boolean) + .join(','); + + const clearDragMarkers = () => { + grid.querySelectorAll('.equip-card.drag-over').forEach((card) => card.classList.remove('drag-over')); + }; + + cards.forEach((card) => { + card.addEventListener('dragstart', (event) => { + draggedCard = card; + originalOrder = currentOrder(); + card.classList.add('dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', card.dataset.itemId || ''); + } + }); + + card.addEventListener('dragover', (event) => { + if (!draggedCard || draggedCard === card || draggedCard.parentElement !== grid) { + return; + } + + event.preventDefault(); + clearDragMarkers(); + card.classList.add('drag-over'); + + const rect = card.getBoundingClientRect(); + const insertBefore = event.clientY < rect.top + (rect.height / 2); + grid.insertBefore(draggedCard, insertBefore ? card : card.nextSibling); + }); + + card.addEventListener('dragleave', () => { + card.classList.remove('drag-over'); + }); + + card.addEventListener('drop', (event) => { + event.preventDefault(); + card.classList.remove('drag-over'); + }); + + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + clearDragMarkers(); + + if (!draggedCard) { + return; + } + + const updatedOrder = currentOrder(); + draggedCard = null; + + if (updatedOrder !== '' && updatedOrder !== originalOrder) { + syncItemOrderStateForGrid(grid); + markPendingOrderChanges(); + } + }); + }); + + grid.addEventListener('dragover', (event) => { + if (!draggedCard || draggedCard.parentElement !== grid) { + return; + } + + event.preventDefault(); + const targetCard = event.target.closest('.equip-card[data-item-id]'); + if (!targetCard) { + grid.appendChild(draggedCard); + } + }); + + grid.addEventListener('drop', (event) => { + event.preventDefault(); + clearDragMarkers(); + }); + + syncItemOrderStateForGrid(grid); + }); + + editForm.addEventListener('submit', () => { + syncCategoryOrderState(); + grids.forEach((grid) => syncItemOrderStateForGrid(grid)); + hasPendingOrderChanges = false; + if (orderStateIndicator) { + orderStateIndicator.hidden = true; + } + }); + + syncCategoryOrderState(); } - itemSelectionCheckboxes.forEach((checkbox) => { - checkbox.addEventListener('change', updateBatchSelectionState); - }); - - updateBatchSelectionState(); + initEquipmentOrdering();