diff --git a/api/audit-log.php b/api/audit-log.php new file mode 100644 index 0000000..ac5cc12 --- /dev/null +++ b/api/audit-log.php @@ -0,0 +1,20 @@ + true, 'data' => audit_log_list($_GET)]); +} catch (Throwable $e) { + audit_respond(500, ['success' => false, 'error' => 'Unable to load audit log.']); +} diff --git a/api/channels.php b/api/channels.php new file mode 100644 index 0000000..f666d20 --- /dev/null +++ b/api/channels.php @@ -0,0 +1,74 @@ + true, 'data' => channel_stats()]); + } + + if (isset($_GET['history_for'])) { + respond(200, ['success' => true, 'data' => channel_history((int) $_GET['history_for'])]); + } + + if (isset($_GET['id'])) { + $channel = channel_get((int) $_GET['id']); + if (!$channel) { + respond(404, ['success' => false, 'error' => 'Channel not found.']); + } + respond(200, ['success' => true, 'data' => $channel, 'meta' => [ + 'editable_fields' => CHANNEL_EDITABLE_FIELDS, + 'locked_fields' => CHANNEL_LOCKED_FIELDS, + 'options' => channel_distinct_options(['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe']), + ]]); + } + + respond(200, ['success' => true, 'data' => channel_list($_GET)]); + } + + $rawBody = file_get_contents('php://input'); + $payload = $rawBody ? json_decode($rawBody, true) : []; + if (!is_array($payload)) { + $payload = []; + } + + if ($method === 'PATCH') { + if (isset($_GET['bulk'])) { + $result = channel_bulk_patch($payload['ids'] ?? [], $payload['fields'] ?? [], $userId); + respond(200, ['success' => true, 'message' => 'Bulk update applied.', 'data' => $result]); + } + + if (!isset($_GET['id'])) { + respond(400, ['success' => false, 'error' => 'Missing channel id.']); + } + + $result = channel_patch((int) $_GET['id'], $payload['fields'] ?? $payload, $userId); + respond(200, ['success' => true, 'message' => 'Channel updated.', 'data' => $result]); + } + + respond(405, ['success' => false, 'error' => 'Method not allowed.']); +} catch (RuntimeException $e) { + if (str_starts_with($e->getMessage(), 'FORBIDDEN_FIELD:')) { + respond(403, ['success' => false, 'error' => 'Attempted to modify a locked system field.']); + } + respond(500, ['success' => false, 'error' => $e->getMessage() ?: 'Unexpected runtime error.']); +} catch (InvalidArgumentException $e) { + respond(422, ['success' => false, 'error' => $e->getMessage()]); +} catch (Throwable $e) { + respond(500, ['success' => false, 'error' => 'Unexpected server error.']); +} diff --git a/app/channel_data.php b/app/channel_data.php new file mode 100644 index 0000000..5d8dad7 --- /dev/null +++ b/app/channel_data.php @@ -0,0 +1,819 @@ +exec( + "CREATE TABLE IF NOT EXISTS channels ( + id INT AUTO_INCREMENT PRIMARY KEY, + upper_ch_ref VARCHAR(255) NOT NULL, + country_iso VARCHAR(10) DEFAULT NULL, + country_client VARCHAR(100) DEFAULT NULL, + sat_ref VARCHAR(100) DEFAULT NULL, + sat_tpinfo VARCHAR(100) DEFAULT NULL, + genre VARCHAR(100) DEFAULT NULL, + type VARCHAR(20) DEFAULT NULL, + resolution VARCHAR(50) DEFAULT NULL, + langue VARCHAR(50) DEFAULT NULL, + region VARCHAR(100) DEFAULT NULL, + groupe VARCHAR(100) DEFAULT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + idtype TINYINT(1) NOT NULL DEFAULT 1, + six_id VARCHAR(50) DEFAULT NULL, + sid VARCHAR(50) DEFAULT NULL, + onid VARCHAR(50) DEFAULT NULL, + tid VARCHAR(50) DEFAULT NULL, + sat_in DATE DEFAULT NULL, + sat_out DATE DEFAULT NULL, + sat_update DATE DEFAULT NULL, + sat_ch_client_upper VARCHAR(255) DEFAULT NULL, + sat_client VARCHAR(255) DEFAULT NULL, + counting INT NOT NULL DEFAULT 0, + lastview DATETIME DEFAULT NULL, + manually_edited TINYINT(1) NOT NULL DEFAULT 0, + manually_edited_at DATE DEFAULT NULL, + date_in DATE DEFAULT NULL, + date_out DATE DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_active (active), + INDEX idx_sat_ref (sat_ref), + INDEX idx_country_iso (country_iso), + INDEX idx_genre (genre), + INDEX idx_idtype (idtype), + INDEX idx_manually_edited (manually_edited) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(100) NOT NULL, + channel_id INT NOT NULL, + field_name VARCHAR(100) NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_channel_id (channel_id), + INDEX idx_user_id (user_id), + INDEX idx_field_name (field_name), + INDEX idx_changed_at (changed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $count = (int) $pdo->query("SELECT COUNT(*) FROM channels")->fetchColumn(); + if ($count === 0) { + $rows = [ + ['DISCOVERY SPORT', 'FR', 'France', 'EUTELSAT-5W', '11554/V/29950', 'Sport', 'free', 'HD', 'FR', 'West Europe', 'Warner Sports', 1, 1, '101001', '1201', '1', '301', '2024-01-04', null, '2026-03-15', 'DISCOVERY SPORT FR', 'Canal France', 1842, '2026-04-04 14:20:00', 0, null, '2024-01-04', null], + ['EURO NEWS WORLD', 'GB', 'United Kingdom', 'ASTRA-28E', '11778/H/27500', 'News', 'free', 'HD', 'EN', 'UK & Ireland', 'Global News', 1, 1, '101002', '1202', '1', '302', '2024-02-12', null, '2026-03-20', 'EURONEWS UK', 'Sky UK', 2215, '2026-04-04 13:08:00', 1, '2026-04-03', '2024-02-12', null], + ['CINEMA MAX', 'DE', 'Germany', 'HOTBIRD-13E', '11034/H/29900', 'Movie', 'payed', 'UHD', 'DE', 'DACH', 'Max Studios', 1, 1, '101003', '1203', '1', '303', '2023-11-01', null, '2026-03-29', 'CINEMA MAX DE', 'HD+ Germany', 1560, '2026-04-04 12:11:00', 0, null, '2023-11-01', null], + ['KIDS ONE', 'IT', 'Italy', 'HOTBIRD-13E', '12149/V/27500', 'Kids', 'free', 'HD', 'IT', 'Southern Europe', 'Junior Media', 1, 1, '101004', '1204', '1', '304', '2024-04-18', null, '2026-03-30', 'KIDS ONE IT', 'Sky Italia', 980, '2026-04-03 11:42:00', 0, null, '2024-04-18', null], + ['MUSIC HITS', 'ES', 'Spain', 'ASTRA-19E', '10847/V/22000', 'Music', 'free', 'SD', 'ES', 'Iberia', 'Nova Music', 1, 1, '101005', '1205', '1', '305', '2023-08-10', null, '2026-03-14', 'MUSIC HITS ES', 'Movistar+', 3104, '2026-04-04 08:15:00', 1, '2026-04-01', '2023-08-10', null], + ['WORLD DOCS', 'US', 'United States', 'INTELSAT-34', '11720/H/45000', 'Documentary', 'payed', 'HD', 'EN', 'Americas', 'Discovery Group', 1, 1, '101006', '1206', '2', '306', '2022-06-20', null, '2026-03-25', 'WORLD DOCS US', 'Dish Americas', 884, '2026-04-04 16:20:00', 0, null, '2022-06-20', null], + ['NEWS 24 AFRICA', 'ZA', 'South Africa', 'EUTELSAT-36E', '12360/R/27500', 'News', 'free', 'HD', 'EN', 'Africa', 'Africa Media', 1, 1, '101007', '1207', '7', '307', '2024-01-16', null, '2026-03-18', 'NEWS 24 AFRICA', 'MultiChoice', 1733, '2026-04-04 15:00:00', 0, null, '2024-01-16', null], + ['SPORT PLUS 4K', 'PL', 'Poland', 'HOTBIRD-13E', '10758/V/30000', 'Sport', 'payed', 'UHD', 'PL', 'Central Europe', 'Polsat Sport', 1, 1, '101008', '1208', '3', '308', '2024-03-05', null, '2026-03-27', 'SPORT PLUS 4K', 'Cyfrowy Polsat', 1270, '2026-04-04 12:55:00', 1, '2026-04-02', '2024-03-05', null], + ['ARTE CULTURE', 'FR', 'France', 'ASTRA-19E', '11376/V/22000', 'Culture', 'free', 'HD', 'FR', 'West Europe', 'Arte Group', 1, 1, '101009', '1209', '1', '309', '2023-02-14', null, '2026-03-21', 'ARTE CULTURE FR', 'Canal France', 2011, '2026-04-04 10:30:00', 0, null, '2023-02-14', null], + ['MOVIES ACTION', 'TR', 'Turkey', 'TURKSAT-42E', '11844/V/2222', 'Movie', 'payed', 'HD', 'TR', 'Middle East', 'Action Studios', 0, 1, '101010', '1210', '4', '310', '2024-05-12', '2026-03-01', '2026-03-01', 'MOVIES ACTION TR', 'D-Smart', 542, '2026-03-01 09:00:00', 1, '2026-03-01', '2024-05-12', null], + ['UNKNOWN HIT 1198', null, 'Unmatched', 'EUTELSAT-7B', '11200/H/30000', null, null, null, null, null, null, 1, 2, '101011', '1211', '9', '311', '2026-03-22', null, '2026-03-22', 'UNMATCHED FEED', 'Pushimatic', 14, '2026-04-04 05:55:00', 0, null, '2026-03-22', null], + ['LATAM SPORT EXTRA', 'BR', 'Brazil', 'INTELSAT-34', '12050/V/45000', 'Sport', 'payed', 'HD', 'PT', 'LATAM', 'Globo Sports', 1, 1, '101012', '1212', '6', '312', '2024-07-01', null, '2026-03-31', 'LATAM SPORT EXTRA', 'Claro TV', 698, '2026-04-04 18:05:00', 0, null, '2024-07-01', null], + ['ASIA BUSINESS TV', 'SG', 'Singapore', 'ASIASAT-7', '12210/H/43200', 'Business', 'free', 'HD', 'EN', 'Asia Pacific', 'Asia Finance', 1, 1, '101013', '1213', '5', '313', '2023-09-09', null, '2026-03-26', 'ASIA BUSINESS TV', 'StarHub', 431, '2026-04-02 06:12:00', 0, null, '2023-09-09', null], + ['FAMILY LIFE', 'US', 'United States', 'EUTELSAT-5W', '12648/H/29500', 'Lifestyle', 'free', 'HD', 'EN', 'North America', 'Family Media', 1, 1, '101014', '1214', '2', '314', '2024-01-28', null, '2026-03-17', 'FAMILY LIFE US', 'Dish Americas', 799, '2026-04-03 19:44:00', 1, '2026-03-28', '2024-01-28', null], + ['SCIENCE HUB', 'CA', 'Canada', 'INTELSAT-34', '12190/V/45000', 'Education', 'payed', 'HD', 'EN', 'North America', 'Knowledge Networks', 1, 1, '101015', '1215', '2', '315', '2024-06-11', null, '2026-03-19', 'SCIENCE HUB CA', 'Bell TV', 611, '2026-04-04 07:12:00', 0, null, '2024-06-11', null], + ['RETRO CINEMA', 'MX', 'Mexico', 'SATMEX-8', '11180/H/30000', 'Movie', 'free', 'SD', 'ES', 'LATAM', 'Retro Media', 1, 1, '101016', '1216', '8', '316', '2022-10-03', null, '2026-03-11', 'RETRO CINEMA MX', 'Sky Mexico', 1221, '2026-04-04 09:40:00', 0, null, '2022-10-03', null], + ]; + + $stmt = $pdo->prepare( + "INSERT INTO channels ( + upper_ch_ref, country_iso, country_client, sat_ref, sat_tpinfo, genre, type, resolution, langue, + region, groupe, active, idtype, six_id, sid, onid, tid, sat_in, sat_out, sat_update, + sat_ch_client_upper, sat_client, counting, lastview, manually_edited, manually_edited_at, date_in, date_out + ) VALUES ( + :upper_ch_ref, :country_iso, :country_client, :sat_ref, :sat_tpinfo, :genre, :type, :resolution, :langue, + :region, :groupe, :active, :idtype, :six_id, :sid, :onid, :tid, :sat_in, :sat_out, :sat_update, + :sat_ch_client_upper, :sat_client, :counting, :lastview, :manually_edited, :manually_edited_at, :date_in, :date_out + )" + ); + + foreach ($rows as $row) { + $stmt->execute([ + ':upper_ch_ref' => $row[0], + ':country_iso' => $row[1], + ':country_client' => $row[2], + ':sat_ref' => $row[3], + ':sat_tpinfo' => $row[4], + ':genre' => $row[5], + ':type' => $row[6], + ':resolution' => $row[7], + ':langue' => $row[8], + ':region' => $row[9], + ':groupe' => $row[10], + ':active' => $row[11], + ':idtype' => $row[12], + ':six_id' => $row[13], + ':sid' => $row[14], + ':onid' => $row[15], + ':tid' => $row[16], + ':sat_in' => $row[17], + ':sat_out' => $row[18], + ':sat_update' => $row[19], + ':sat_ch_client_upper' => $row[20], + ':sat_client' => $row[21], + ':counting' => $row[22], + ':lastview' => $row[23], + ':manually_edited' => $row[24], + ':manually_edited_at' => $row[25], + ':date_in' => $row[26], + ':date_out' => $row[27], + ]); + } + + $auditStmt = $pdo->prepare( + "INSERT INTO audit_log (user_id, channel_id, field_name, old_value, new_value, changed_at) + VALUES (:user_id, :channel_id, :field_name, :old_value, :new_value, :changed_at)" + ); + $auditStmt->execute([ + ':user_id' => 'system.seed', + ':channel_id' => 2, + ':field_name' => 'genre', + ':old_value' => 'International', + ':new_value' => 'News', + ':changed_at' => '2026-04-03 09:14:00', + ]); + $auditStmt->execute([ + ':user_id' => 'ops.demo', + ':channel_id' => 8, + ':field_name' => 'resolution', + ':old_value' => 'HD', + ':new_value' => 'UHD', + ':changed_at' => '2026-04-02 17:20:00', + ]); + $auditStmt->execute([ + ':user_id' => 'ops.demo', + ':channel_id' => 14, + ':field_name' => 'active', + ':old_value' => '0', + ':new_value' => '1', + ':changed_at' => '2026-03-28 12:03:00', + ]); + } + + $booted = true; +} + +function channel_current_user_id(): string +{ + return $_SERVER['REMOTE_USER'] ?? $_SERVER['PHP_AUTH_USER'] ?? 'ops.demo'; +} + +function channel_allowed_sort_columns(): array +{ + return [ + 'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution', 'langue', + 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out', 'sat_update', + 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited_at' + ]; +} + +function channel_parse_csv_param(string $key): array +{ + if (!isset($_GET[$key])) { + return []; + } + + $value = $_GET[$key]; + if (is_array($value)) { + $items = $value; + } else { + $items = explode(',', (string) $value); + } + + $clean = []; + foreach ($items as $item) { + $item = trim((string) $item); + if ($item !== '') { + $clean[] = $item; + } + } + + return array_values(array_unique($clean)); +} + +function channel_distinct_options(array $fields): array +{ + channel_app_bootstrap(); + $pdo = db(); + $out = []; + foreach ($fields as $field) { + $stmt = $pdo->query("SELECT DISTINCT {$field} AS value FROM channels WHERE date_out IS NULL AND {$field} IS NOT NULL AND {$field} <> '' ORDER BY {$field} ASC"); + $out[$field] = array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll())); + } + return $out; +} + +function channel_list(array $query): array +{ + channel_app_bootstrap(); + $pdo = db(); + + $limit = isset($query['limit']) ? max(1, min(100, (int) $query['limit'])) : 50; + $page = isset($query['page']) ? max(1, (int) $query['page']) : 1; + $offset = ($page - 1) * $limit; + + $sortable = channel_allowed_sort_columns(); + $sort = in_array(($query['sort'] ?? ''), $sortable, true) ? (string) $query['sort'] : 'upper_ch_ref'; + $direction = strtoupper((string) ($query['direction'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC'; + + $filterMap = [ + 'sat_ref' => 'sat_ref', + 'country_iso' => 'country_iso', + 'genre' => 'genre', + 'type' => 'type', + 'resolution' => 'resolution', + 'langue' => 'langue', + 'region' => 'region', + 'groupe' => 'groupe', + 'active' => 'active', + 'idtype' => 'idtype', + 'manually_edited' => 'manually_edited', + ]; + + $where = []; + $params = []; + $hasExplicitFilter = false; + + if (!empty($query['search'])) { + $hasExplicitFilter = true; + $where[] = "( + upper_ch_ref LIKE :search OR country_iso LIKE :search OR country_client LIKE :search OR sat_ref LIKE :search OR + sat_tpinfo LIKE :search OR genre LIKE :search OR type LIKE :search OR resolution LIKE :search OR langue LIKE :search OR + region LIKE :search OR groupe LIKE :search OR six_id LIKE :search OR sid LIKE :search OR onid LIKE :search OR tid LIKE :search OR + sat_client LIKE :search OR sat_ch_client_upper LIKE :search + )"; + $params[':search'] = '%' . $query['search'] . '%'; + } + + foreach ($filterMap as $param => $column) { + $values = []; + if (isset($query[$param])) { + if (is_array($query[$param])) { + $values = $query[$param]; + } else { + $values = explode(',', (string) $query[$param]); + } + } + $values = array_values(array_filter(array_map(static fn($v) => trim((string) $v), $values), static fn($v) => $v !== '')); + if ($values) { + $hasExplicitFilter = true; + $placeholders = []; + foreach ($values as $index => $value) { + $name = ':' . $param . '_' . $index; + $placeholders[] = $name; + $params[$name] = $value; + } + $where[] = sprintf('%s IN (%s)', $column, implode(',', $placeholders)); + } + } + + foreach (['six_id', 'sid', 'onid', 'tid'] as $exactField) { + if (!empty($query[$exactField])) { + $hasExplicitFilter = true; + $where[] = "{$exactField} = :{$exactField}"; + $params[':' . $exactField] = (string) $query[$exactField]; + } + } + + $where[] = 'date_out IS NULL'; + + if (!$hasExplicitFilter) { + $where[] = 'active = 1'; + $where[] = 'sat_out IS NULL'; + } + + $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; + + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM channels {$whereSql}"); + foreach ($params as $key => $value) { + $countStmt->bindValue($key, $value); + } + $countStmt->execute(); + $total = (int) $countStmt->fetchColumn(); + + $sql = sprintf( + 'SELECT %s FROM channels %s ORDER BY %s %s LIMIT :limit OFFSET :offset', + implode(', ', CHANNEL_LIST_COLUMNS), + $whereSql, + $sort, + $direction + ); + $stmt = $pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return [ + 'data' => $stmt->fetchAll(), + 'pagination' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) max(1, ceil($total / $limit)), + ], + 'meta' => [ + 'sort' => $sort, + 'direction' => $direction, + 'filters_applied' => $hasExplicitFilter, + ], + 'options' => channel_distinct_options(['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe']), + ]; +} + +function channel_get(int $id): ?array +{ + channel_app_bootstrap(); + $stmt = db()->prepare('SELECT * FROM channels WHERE id = :id'); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->execute(); + $row = $stmt->fetch(); + return $row ?: null; +} + +function channel_insert(array $record, ?PDO $pdo = null): int +{ + $pdo = $pdo ?: db(); + $columns = CHANNEL_RECORD_COLUMNS; + $placeholders = array_map(static fn(string $column): string => ':' . $column, $columns); + + $sql = sprintf( + 'INSERT INTO channels (%s) VALUES (%s)', + implode(', ', $columns), + implode(', ', $placeholders) + ); + + $stmt = $pdo->prepare($sql); + foreach ($columns as $column) { + $stmt->bindValue(':' . $column, $record[$column] ?? null); + } + $stmt->execute(); + + return (int) $pdo->lastInsertId(); +} + +function channel_close_and_version(int $id, array $changes, string $userId): array +{ + $pdo = db(); + $channel = channel_get($id); + if (!$channel) { + throw new InvalidArgumentException('Channel not found.'); + } + if ($channel['date_out'] !== null) { + throw new InvalidArgumentException('Only the current channel version can be edited.'); + } + + $versionDate = gmdate('Y-m-d'); + $nextRecord = []; + foreach (CHANNEL_RECORD_COLUMNS as $column) { + $nextRecord[$column] = $channel[$column] ?? null; + } + + foreach ($changes as $field => $change) { + $nextRecord[$field] = $change['new']; + } + + $nextRecord['manually_edited'] = 1; + $nextRecord['manually_edited_at'] = $versionDate; + $nextRecord['date_in'] = $versionDate; + $nextRecord['date_out'] = null; + + $pdo->beginTransaction(); + try { + $closeStmt = $pdo->prepare('UPDATE channels SET date_out = :date_out WHERE id = :id AND date_out IS NULL'); + $closeStmt->bindValue(':date_out', $versionDate); + $closeStmt->bindValue(':id', $id, PDO::PARAM_INT); + $closeStmt->execute(); + + if ($closeStmt->rowCount() !== 1) { + throw new RuntimeException('Unable to close the previous channel version.'); + } + + $newId = channel_insert($nextRecord, $pdo); + + $auditStmt = $pdo->prepare( + 'INSERT INTO audit_log (user_id, channel_id, field_name, old_value, new_value) VALUES (:user_id, :channel_id, :field_name, :old_value, :new_value)' + ); + + foreach ($changes as $field => $change) { + $auditStmt->execute([ + ':user_id' => $userId, + ':channel_id' => $newId, + ':field_name' => $field, + ':old_value' => $change['old'] === null ? null : (string) $change['old'], + ':new_value' => $change['new'] === null ? null : (string) $change['new'], + ]); + } + + $auditStmt->execute([ + ':user_id' => $userId, + ':channel_id' => $newId, + ':field_name' => 'versioned_from', + ':old_value' => (string) $id, + ':new_value' => (string) $newId, + ]); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + + return [ + 'previous_channel_id' => $id, + 'channel' => channel_get($newId), + 'changed_fields' => array_keys($changes), + ]; +} + +function channel_validate_patch_fields(array $fields): array +{ + if (!$fields) { + throw new InvalidArgumentException('No editable fields were provided.'); + } + + $clean = []; + $editable = CHANNEL_EDITABLE_FIELDS; + $existingSatRefs = channel_distinct_options(['sat_ref'])['sat_ref']; + + foreach ($fields as $field => $value) { + if (!in_array($field, $editable, true)) { + throw new RuntimeException('FORBIDDEN_FIELD:' . $field); + } + + if (in_array($field, ['sat_in', 'sat_out', 'sat_update'], true)) { + if ($value === '' || $value === null) { + $clean[$field] = null; + continue; + } + $date = date_create((string) $value); + if (!$date) { + throw new InvalidArgumentException("Invalid date for {$field}."); + } + $clean[$field] = $date->format('Y-m-d'); + continue; + } + + if ($field === 'active') { + if (!in_array((string) $value, ['0', '1'], true)) { + throw new InvalidArgumentException('Active must be 0 or 1.'); + } + $clean[$field] = (int) $value; + continue; + } + + if ($field === 'type') { + if (!in_array((string) $value, ['free', 'payed'], true)) { + throw new InvalidArgumentException('Type must be free or payed.'); + } + $clean[$field] = (string) $value; + continue; + } + + if ($field === 'country_iso' && $value !== null && $value !== '') { + $value = strtoupper(substr((string) $value, 0, 3)); + } + + if ($field === 'sat_ref' && $value !== null && $value !== '' && !in_array((string) $value, $existingSatRefs, true)) { + throw new InvalidArgumentException('sat_ref must match an existing satellite reference.'); + } + + $clean[$field] = $value === '' ? null : trim((string) $value); + } + + return $clean; +} + +function channel_patch(int $id, array $fields, string $userId): array +{ + channel_app_bootstrap(); + $channel = channel_get($id); + if (!$channel) { + throw new InvalidArgumentException('Channel not found.'); + } + + $clean = channel_validate_patch_fields($fields); + $changes = []; + foreach ($clean as $field => $newValue) { + $oldValue = $channel[$field]; + if ((string) ($oldValue ?? '') !== (string) ($newValue ?? '')) { + $changes[$field] = ['old' => $oldValue, 'new' => $newValue]; + } + } + + if (!$changes) { + return ['previous_channel_id' => $id, 'channel' => $channel, 'changed_fields' => []]; + } + + return channel_close_and_version($id, $changes, $userId); +} + +function channel_bulk_patch(array $ids, array $fields, string $userId): array +{ + $ids = array_values(array_unique(array_map('intval', $ids))); + $ids = array_values(array_filter($ids, static fn(int $id): bool => $id > 0)); + if (!$ids) { + throw new InvalidArgumentException('No valid ids were provided for bulk update.'); + } + + $cleanFields = channel_validate_patch_fields($fields); + $updated = []; + foreach ($ids as $id) { + $result = channel_patch($id, $cleanFields, $userId); + if (!empty($result['changed_fields'])) { + $updated[] = [ + 'previous_id' => $id, + 'new_id' => (int) ($result['channel']['id'] ?? 0), + 'changed_fields' => $result['changed_fields'], + ]; + } + } + + return [ + 'updated_count' => count($updated), + 'items' => $updated, + ]; +} + +function channel_history(int $id): array +{ + channel_app_bootstrap(); + $pdo = db(); + + $selected = channel_get($id); + if (!$selected) { + throw new InvalidArgumentException('Channel not found.'); + } + + $backwardIds = []; + $cursor = $id; + while (true) { + $stmt = $pdo->prepare("SELECT old_value FROM audit_log WHERE channel_id = :channel_id AND field_name = 'versioned_from' ORDER BY id DESC LIMIT 1"); + $stmt->bindValue(':channel_id', $cursor, PDO::PARAM_INT); + $stmt->execute(); + $previousId = (int) $stmt->fetchColumn(); + if ($previousId <= 0 || in_array($previousId, $backwardIds, true)) { + break; + } + $backwardIds[] = $previousId; + $cursor = $previousId; + } + + $forwardIds = []; + $cursor = $id; + while (true) { + $stmt = $pdo->prepare("SELECT new_value FROM audit_log WHERE field_name = 'versioned_from' AND old_value = :old_value ORDER BY id ASC LIMIT 1"); + $stmt->bindValue(':old_value', (string) $cursor); + $stmt->execute(); + $nextId = (int) $stmt->fetchColumn(); + if ($nextId <= 0 || in_array($nextId, $forwardIds, true)) { + break; + } + $forwardIds[] = $nextId; + $cursor = $nextId; + } + + $orderedIds = array_values(array_unique(array_merge(array_reverse($backwardIds), [$id], $forwardIds))); + $placeholders = implode(', ', array_fill(0, count($orderedIds), '?')); + + $rowStmt = $pdo->prepare("SELECT * FROM channels WHERE id IN ({$placeholders})"); + foreach ($orderedIds as $index => $versionId) { + $rowStmt->bindValue($index + 1, $versionId, PDO::PARAM_INT); + } + $rowStmt->execute(); + $rows = $rowStmt->fetchAll(); + + $rowsById = []; + foreach ($rows as $row) { + $rowsById[(int) $row['id']] = $row; + } + + $auditStmt = $pdo->prepare("SELECT channel_id, user_id, field_name, old_value, new_value, changed_at FROM audit_log WHERE channel_id IN ({$placeholders}) ORDER BY changed_at ASC, id ASC"); + foreach ($orderedIds as $index => $versionId) { + $auditStmt->bindValue($index + 1, $versionId, PDO::PARAM_INT); + } + $auditStmt->execute(); + + $changesByChannel = []; + $previousMap = []; + foreach ($auditStmt->fetchAll() as $entry) { + $channelId = (int) $entry['channel_id']; + if (($entry['field_name'] ?? '') === 'versioned_from') { + $previousMap[$channelId] = (int) ($entry['old_value'] ?? 0); + continue; + } + + $changesByChannel[$channelId][] = [ + 'field_name' => (string) $entry['field_name'], + 'old_value' => $entry['old_value'], + 'new_value' => $entry['new_value'], + 'changed_at' => $entry['changed_at'], + 'user_id' => $entry['user_id'], + ]; + } + + $versions = []; + foreach ($orderedIds as $position => $versionId) { + if (!isset($rowsById[$versionId])) { + continue; + } + + $row = $rowsById[$versionId]; + $changes = $changesByChannel[$versionId] ?? []; + $versions[] = [ + 'id' => (int) $row['id'], + 'version_number' => $position + 1, + 'is_current' => $row['date_out'] === null, + 'previous_id' => $previousMap[$versionId] ?? null, + 'upper_ch_ref' => $row['upper_ch_ref'], + 'sat_ref' => $row['sat_ref'], + 'country_iso' => $row['country_iso'], + 'genre' => $row['genre'], + 'type' => $row['type'], + 'resolution' => $row['resolution'], + 'active' => $row['active'], + 'sat_in' => $row['sat_in'], + 'sat_out' => $row['sat_out'], + 'sat_update' => $row['sat_update'], + 'manually_edited' => $row['manually_edited'], + 'manually_edited_at' => $row['manually_edited_at'], + 'date_in' => $row['date_in'], + 'date_out' => $row['date_out'], + 'changes' => $changes, + 'changed_fields' => array_values(array_unique(array_map(static fn(array $change): string => $change['field_name'], $changes))), + ]; + } + + return [ + 'selected_id' => $id, + 'root_id' => $orderedIds[0] ?? $id, + 'latest_id' => $orderedIds ? $orderedIds[count($orderedIds) - 1] : $id, + 'total_versions' => count($versions), + 'versions' => $versions, + ]; +} + +function channel_stats(): array +{ + channel_app_bootstrap(); + $pdo = db(); + + $overview = $pdo->query( + 'SELECT COUNT(*) AS total_channels, SUM(active = 1 AND sat_out IS NULL) AS active_channels, SUM(manually_edited = 1) AS manually_edited_channels, COALESCE(SUM(counting), 0) AS total_hits FROM channels WHERE date_out IS NULL' + )->fetch(); + + $dimensions = ['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'region']; + $breakdowns = []; + foreach ($dimensions as $dimension) { + $stmt = $pdo->query("SELECT COALESCE({$dimension}, 'Unknown') AS label, COUNT(*) AS total FROM channels WHERE date_out IS NULL GROUP BY {$dimension} ORDER BY total DESC, label ASC LIMIT 8"); + $breakdowns[$dimension] = $stmt->fetchAll(); + } + + return [ + 'overview' => $overview, + 'breakdowns' => $breakdowns, + ]; +} + +function audit_log_list(array $query): array +{ + channel_app_bootstrap(); + $pdo = db(); + + $page = isset($query['page']) ? max(1, (int) $query['page']) : 1; + $limit = isset($query['limit']) ? max(1, min(100, (int) $query['limit'])) : 20; + $offset = ($page - 1) * $limit; + + $where = []; + $params = []; + + if (!empty($query['user_id'])) { + $where[] = 'a.user_id = :user_id'; + $params[':user_id'] = (string) $query['user_id']; + } + if (!empty($query['field_name'])) { + $where[] = 'a.field_name = :field_name'; + $params[':field_name'] = (string) $query['field_name']; + } + if (!empty($query['date_from'])) { + $where[] = 'DATE(a.changed_at) >= :date_from'; + $params[':date_from'] = (string) $query['date_from']; + } + if (!empty($query['date_to'])) { + $where[] = 'DATE(a.changed_at) <= :date_to'; + $params[':date_to'] = (string) $query['date_to']; + } + + $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; + + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM audit_log a {$whereSql}"); + foreach ($params as $key => $value) { + $countStmt->bindValue($key, $value); + } + $countStmt->execute(); + $total = (int) $countStmt->fetchColumn(); + + $sql = "SELECT a.*, c.upper_ch_ref, c.sat_ref + FROM audit_log a + LEFT JOIN channels c ON c.id = a.channel_id + {$whereSql} + ORDER BY a.changed_at DESC, a.id DESC + LIMIT :limit OFFSET :offset"; + $stmt = $pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return [ + 'data' => $stmt->fetchAll(), + 'pagination' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => (int) max(1, ceil($total / $limit)), + ], + 'filters' => [ + 'users' => channel_distinct_audit_values('user_id'), + 'fields' => channel_distinct_audit_values('field_name'), + ], + ]; +} + +function channel_distinct_audit_values(string $field): array +{ + $allowed = ['user_id', 'field_name']; + if (!in_array($field, $allowed, true)) { + return []; + } + + $stmt = db()->query("SELECT DISTINCT {$field} AS value FROM audit_log ORDER BY {$field} ASC"); + return array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll())); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..ccf6601 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,514 @@ +:root { + --bg: #f5f6f8; + --surface: #ffffff; + --surface-muted: #f8f9fb; + --border: #d9dde3; + --border-strong: #c8ced8; + --text: #101828; + --text-muted: #667085; + --accent: #1f2937; + --accent-soft: #e5e7eb; + --success-soft: #ecfdf3; + --warning-soft: #fff7e6; + --warning-border: #f2cc8f; + --selection-soft: #eff6ff; + --selection-border: #bfdbfe; + --danger-soft: #fef3f2; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04); + --shadow-md: 0 12px 24px rgba(16, 24, 40, 0.06); +} + +html { scroll-behavior: smooth; } body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 14px; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; +a { color: inherit; } +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + font-size: 0.9em; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.app-header { backdrop-filter: saturate(120%) blur(8px); } +.panel-card, +.modal-content, +.offcanvas { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; +.navbar-brand { text-decoration: none; } +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--accent); + color: #fff; + font-weight: 700; + letter-spacing: -0.02em; +} +.brand-title { font-size: 0.95rem; font-weight: 700; line-height: 1.1; } +.brand-subtitle { color: var(--text-muted); font-size: 0.73rem; line-height: 1.1; } +.nav-link { color: var(--text-muted); font-weight: 500; } +.nav-link:hover, .nav-link:focus { color: var(--accent); } + +.hero-panel .panel-card { min-height: 100%; } +.eyebrow, +.status-chip { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.65rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid var(--border); + background: var(--surface-muted); +} +.status-chip { text-transform: none; letter-spacing: 0; } +.hero-title { + font-size: clamp(1.8rem, 2vw, 2.65rem); + line-height: 1.08; + letter-spacing: -0.04em; + max-width: 13ch; + margin-bottom: 1rem; +} +.hero-copy { + color: var(--text-muted); + font-size: 0.98rem; + max-width: 64ch; +} +.metrics-inline .metric-tile { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); + padding: 1rem; + min-height: 104px; +} +.metric-tile-warm { + background: var(--warning-soft); + border-color: var(--warning-border); +} +.metric-label { color: var(--text-muted); font-size: 0.76rem; text-transform: uppercase; letter-spacing: 0.04em; } +.metric-value { font-size: 1.7rem; font-weight: 700; margin-top: 0.35rem; letter-spacing: -0.04em; } +.section-title { font-size: 1rem; font-weight: 700; } +.rule-list { display: grid; gap: 0.8rem; } +.rule-item { + display: flex; + gap: 0.75rem; + align-items: flex-start; + color: var(--text-muted); +} +.rule-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--accent); + margin-top: 0.3rem; + flex-shrink: 0; +} +.surface-note { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); + padding: 1rem; +} +.surface-note-title { + font-weight: 600; + margin-bottom: 0.35rem; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; +.filters-card { top: 88px; } +.form-label { + font-size: 0.76rem; + color: var(--text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.45rem; +} +.form-control, +.form-select, +.btn { + border-radius: var(--radius-sm); +} +.form-control, +.form-select { + min-height: 42px; + border-color: var(--border); + background: #fff; +} +.form-control:focus, +.form-select:focus, +.btn:focus, +.nav-link:focus { + border-color: #94a3b8; + box-shadow: 0 0 0 0.2rem rgba(148, 163, 184, 0.18); +} +.btn-dark { + background: var(--accent); + border-color: var(--accent); +} +.btn-dark:hover, +.btn-dark:focus { background: #111827; border-color: #111827; } +.toolbar-strip, +.selection-bar { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-muted); + padding: 0.8rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} +.selection-bar { + background: var(--selection-soft); + border-color: var(--selection-border); +} +.toolbar-left, +.toolbar-right, +.chip-row { + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} +.chip-row { min-height: 28px; } +.filter-chip { + background: #fff; + border: 1px solid var(--border); + padding: 0.35rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + color: var(--text-muted); +} +.legend { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--text-muted); + font-size: 0.78rem; +} +.legend-swatch { + width: 12px; + height: 12px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--warning-soft); +} +.legend-selected .legend-swatch { + background: var(--selection-soft); + border-color: var(--selection-border); +} +.section-tabs .nav-link { + color: var(--text-muted); + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding-left: 0; + padding-right: 0; + margin-right: 1.5rem; +} +.section-tabs .nav-link.active { + color: var(--accent); + background: transparent; + border-bottom-color: var(--accent); + font-weight: 700; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; +.channel-table-wrap, +.audit-table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fff; +} +.channel-table, +.audit-table { + min-width: 1500px; + margin-bottom: 0; +} +.channel-table thead th, +.audit-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: #fbfcfd; + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} +.channel-table th[data-sort] { + cursor: pointer; + user-select: none; +} +.channel-table td, +.channel-table th, +.audit-table td, +.audit-table th { + padding: 0.8rem 0.75rem; + vertical-align: middle; +} +.checkbox-col { width: 48px; } +.sticky-col { + position: sticky; + left: 0; + z-index: 3; + background: inherit; +} +.channel-table tbody td.sticky-col, +.channel-table thead th.sticky-col { background: #fff; } +.channel-table tbody tr.row-manual td, +.channel-table tbody tr.row-manual td.sticky-col { + background: var(--warning-soft); +} +.channel-table tbody tr.row-selected td, +.channel-table tbody tr.row-selected td.sticky-col { + background: var(--selection-soft); +} +.channel-table tbody tr:hover td, +.channel-table tbody tr:hover td.sticky-col { + background: #f8fafc; +} +.channel-table tbody tr.row-manual:hover td, +.channel-table tbody tr.row-manual:hover td.sticky-col { background: #fff2d6; } +.channel-table tbody tr.row-selected:hover td, +.channel-table tbody tr.row-selected:hover td.sticky-col { background: #dbeafe; } +.channel-row { cursor: pointer; } +.sys-col, +.sys-field { color: #98a2b3; } +.channel-pill { + display: inline-flex; + padding: 0.22rem 0.52rem; + border-radius: 999px; + background: var(--surface-muted); + border: 1px solid var(--border); + font-size: 0.75rem; +} +.empty-state-cell { + padding: 2.5rem 1rem !important; + color: var(--text-muted); + text-align: center; +} +.page-button.disabled { opacity: 0.5; pointer-events: none; } + +.analytics-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fff; + padding: 1rem; + height: 100%; +} +.analytics-bars { + display: grid; + gap: 0.7rem; +} +.analytics-bar-row { + display: grid; + gap: 0.35rem; +} +.analytics-bar-top { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.82rem; +} +.analytics-bar-track { + width: 100%; + height: 8px; + background: var(--accent-soft); + border-radius: 999px; + overflow: hidden; +} +.analytics-bar-fill { + height: 100%; + background: var(--accent); + border-radius: inherit; } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; +.offcanvas.detail-offcanvas { width: min(560px, 100vw); } +.detail-form .form-control[readonly] { + background: #f8f9fb; + color: #98a2b3; +} +.detail-section { background: #fff; } +.section-subtitle { + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.05em; + font-weight: 700; + margin-bottom: 1rem; +} +.locked-card { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: #fff; + padding: 0.75rem; +} +.locked-card .locked-label { + color: var(--text-muted); + font-size: 0.76rem; + margin-bottom: 0.2rem; +} +.locked-card .locked-value { font-weight: 600; } +.locked-card .lock-icon { color: var(--text-muted); } +.bulk-field-card { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1rem; + background: var(--surface-muted); + height: 100%; +} +.bulk-field-card .form-check { margin-bottom: 0.75rem; } +.bulk-field-card.is-enabled { + background: #fff; + border-color: var(--border-strong); +} +.audit-change { + display: inline-flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} +.audit-old, +.audit-new { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 999px; + font-size: 0.76rem; +} +.audit-old { + background: var(--danger-soft); + color: #b42318; + text-decoration: line-through; +} +.audit-new { + background: var(--success-soft); + color: #027a48; +} +.toast { + border: 1px solid var(--border); + box-shadow: var(--shadow-md); +} +.history-metric-value { + font-size: 1.2rem; +} +.history-stack { + display: grid; + gap: 1rem; +} +.history-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fff; + padding: 1rem; + box-shadow: var(--shadow-sm); +} +.history-card.is-current { + border-color: var(--selection-border); + background: #f8fbff; +} +.history-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.history-card-title { + font-size: 1rem; + font-weight: 700; + margin: 0; +} +.history-version-pill, +.history-status-pill { + display: inline-flex; + align-items: center; + padding: 0.28rem 0.6rem; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 600; +} +.history-version-pill { + background: var(--surface-muted); + border: 1px solid var(--border); +} +.history-status-current { + background: var(--selection-soft); + color: #1d4ed8; +} +.history-status-closed { + background: var(--warning-soft); + color: #b54708; +} +.history-date-block { + display: grid; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--text-muted); +} +.history-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} +.history-meta-grid > div { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.75rem; + background: var(--surface-muted); +} +.history-meta-label, +.history-change-meta { + display: block; + color: var(--text-muted); + font-size: 0.75rem; +} +.history-changes-list { + display: grid; + gap: 0.75rem; +} +.history-change { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + border-top: 1px dashed var(--border); + padding-top: 0.75rem; +} +.history-change:first-child { + border-top: none; + padding-top: 0; +} +.history-change-field { + font-weight: 600; } -::-webkit-scrollbar-track { - background: transparent; +@media (max-width: 1199.98px) { + .filters-card { position: static !important; } } - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; +@media (max-width: 767.98px) { + .hero-title { max-width: none; } + .toolbar-strip, + .selection-bar { align-items: flex-start; } } - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { - display: flex; - justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; - gap: 1rem; -} - -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; -} - -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; -} - -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.history-table { - width: 100%; -} - -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; -} - -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; -} - -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; -} - -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index d349598..a7f05b1 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,819 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const config = window.tvChannelApp || {}; + const state = { + filters: { limit: 50, page: 1 }, + sort: { field: 'upper_ch_ref', direction: 'ASC' }, + selectedIds: new Set(), + currentRows: [], + currentDetail: null, + options: {}, + lastListPayload: null, + auditFilters: { page: 1, limit: 20 }, + analyticsLoaded: false, + auditLoaded: false, + historyLoaded: false, + currentHistoryId: null, + }; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const elements = { + filtersForm: document.getElementById('filtersForm'), + resetFiltersBtn: document.getElementById('resetFiltersBtn'), + channelsTableBody: document.getElementById('channelsTableBody'), + resultCounter: document.getElementById('resultCounter'), + pageMeta: document.getElementById('pageMeta'), + prevPageBtn: document.getElementById('prevPageBtn'), + nextPageBtn: document.getElementById('nextPageBtn'), + selectPageBtn: document.getElementById('selectPageBtn'), + selectAllPageCheckbox: document.getElementById('selectAllPageCheckbox'), + bulkEditBtn: document.getElementById('bulkEditBtn'), + selectionBar: document.getElementById('selectionBar'), + selectionCount: document.getElementById('selectionCount'), + selectionBulkBtn: document.getElementById('selectionBulkBtn'), + clearSelectionBtn: document.getElementById('clearSelectionBtn'), + activeFilterChips: document.getElementById('activeFilterChips'), + detailPanel: document.getElementById('detailPanel'), + detailForm: document.getElementById('detailForm'), + detailLoadingState: document.getElementById('detailLoadingState'), + detailChannelId: document.getElementById('detailChannelId'), + detailEditableSection: document.getElementById('detailEditableSection'), + editableFieldsContainer: document.getElementById('editableFieldsContainer'), + lockedFieldsContainer: document.getElementById('lockedFieldsContainer'), + bulkEditForm: document.getElementById('bulkEditForm'), + bulkEditFieldsContainer: document.getElementById('bulkEditFieldsContainer'), + bulkSelectionSummary: document.getElementById('bulkSelectionSummary'), + analyticsOverview: document.getElementById('analyticsOverview'), + analyticsBreakdowns: document.getElementById('analyticsBreakdowns'), + historyLead: document.getElementById('historyLead'), + historySummary: document.getElementById('historySummary'), + historyTimeline: document.getElementById('historyTimeline'), + refreshHistoryBtn: document.getElementById('refreshHistoryBtn'), + auditUserFilter: document.getElementById('auditUserFilter'), + auditFieldFilter: document.getElementById('auditFieldFilter'), + auditDateFrom: document.getElementById('auditDateFrom'), + auditDateTo: document.getElementById('auditDateTo'), + applyAuditFiltersBtn: document.getElementById('applyAuditFiltersBtn'), + auditTableBody: document.getElementById('auditTableBody'), + heroTotalChannels: document.getElementById('heroTotalChannels'), + heroActiveChannels: document.getElementById('heroActiveChannels'), + heroManualChannels: document.getElementById('heroManualChannels'), + heroTotalHits: document.getElementById('heroTotalHits'), + toastContainer: document.getElementById('toastContainer'), + }; + + const detailOffcanvas = elements.detailPanel ? bootstrap.Offcanvas.getOrCreateInstance(elements.detailPanel) : null; + const bulkModal = document.getElementById('bulkEditModal') ? bootstrap.Modal.getOrCreateInstance(document.getElementById('bulkEditModal')) : null; + + const labels = { + upper_ch_ref: 'Upper channel ref', + country_iso: 'Country ISO', + sat_ref: 'Satellite ref', + genre: 'Genre', + type: 'Type', + groupe: 'Group', + region: 'Region', + langue: 'Language', + resolution: 'Resolution', + active: 'Active', + sat_in: 'Sat in', + sat_out: 'Sat out', + sat_update: 'Sat update', + country_client: 'Country client', + sat_tpinfo: 'Transponder', + idtype: 'Id type', + six_id: 'six_id', + sid: 'sid', + onid: 'onid', + tid: 'tid', + sat_ch_client_upper: 'Client channel', + sat_client: 'Client bouquet', + counting: 'Counting', + lastview: 'Last view', + manually_edited: 'Manual edit', + manually_edited_at: 'Manual edit date' + }; + + const editableFields = config.detailFields?.editable || []; + const lockedFields = config.detailFields?.locked || []; + const selectOptionsFields = ['sat_ref', 'country_iso', 'genre', 'resolution', 'langue', 'region', 'groupe']; + + const formatValue = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return String(value); + }; + + const escapeHtml = (value) => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + const notify = (message, tone = 'dark') => { + const wrapper = document.createElement('div'); + wrapper.className = 'toast align-items-center text-bg-light border-0'; + wrapper.setAttribute('role', 'alert'); + wrapper.setAttribute('aria-live', 'assertive'); + wrapper.setAttribute('aria-atomic', 'true'); + wrapper.innerHTML = ` +
+
${escapeHtml(message)}
+ +
`; + elements.toastContainer.appendChild(wrapper); + const toast = bootstrap.Toast.getOrCreateInstance(wrapper, { delay: 2600 }); + wrapper.addEventListener('hidden.bs.toast', () => wrapper.remove()); + toast.show(); + }; + + const buildQueryString = (params) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return; + query.set(key, value); + }); + return query.toString(); + }; + + const syncFilterForm = () => { + Object.entries(state.filters).forEach(([key, value]) => { + const input = elements.filtersForm?.elements.namedItem(key); + if (input) input.value = value; + }); + }; + + const renderFilterOptions = (options) => { + state.options = options || {}; + const mappings = { + sat_ref: document.getElementById('satRefFilter'), + country_iso: document.getElementById('countryFilter'), + genre: document.getElementById('genreFilter'), + resolution: document.getElementById('resolutionFilter'), }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; - - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); - } + Object.entries(mappings).forEach(([field, select]) => { + if (!select) return; + const currentValue = state.filters[field] || ''; + const list = state.options[field] || []; + select.innerHTML = `` + + list.map(item => ``).join(''); + select.value = currentValue; }); + }; + + const renderFilterChips = () => { + const ignored = new Set(['page', 'limit']); + const chips = Object.entries(state.filters) + .filter(([key, value]) => !ignored.has(key) && value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`); + elements.activeFilterChips.innerHTML = chips.join(''); + }; + + const updateSelectionUI = () => { + const count = state.selectedIds.size; + elements.selectionCount.textContent = String(count); + elements.selectionBar.classList.toggle('d-none', count === 0); + elements.bulkEditBtn.disabled = count === 0; + elements.selectionBulkBtn.disabled = count === 0; + elements.bulkEditBtn.textContent = `Bulk edit (${count})`; + elements.bulkSelectionSummary.textContent = String(count); + + const currentPageIds = state.currentRows.map(row => String(row.id)); + const allSelected = currentPageIds.length > 0 && currentPageIds.every(id => state.selectedIds.has(id)); + elements.selectAllPageCheckbox.checked = allSelected; + }; + + const rowClasses = (row) => { + const classes = ['channel-row']; + if (Number(row.manually_edited) === 1) classes.push('row-manual'); + if (state.selectedIds.has(String(row.id))) classes.push('row-selected'); + return classes.join(' '); + }; + + const renderChannels = (payload) => { + const { data, pagination } = payload; + state.lastListPayload = payload; + state.currentRows = data; + renderFilterOptions(payload.options || {}); + renderFilterChips(); + + if (!data.length) { + elements.channelsTableBody.innerHTML = 'No channels match the current filters.'; + } else { + elements.channelsTableBody.innerHTML = data.map(row => ` + + + ${escapeHtml(formatValue(row.upper_ch_ref))} + ${escapeHtml(formatValue(row.country_iso))} + ${escapeHtml(formatValue(row.country_client))} + ${escapeHtml(formatValue(row.sat_ref))} + ${escapeHtml(formatValue(row.sat_tpinfo))} + ${escapeHtml(formatValue(row.genre))} + ${escapeHtml(formatValue(row.type))} + ${escapeHtml(formatValue(row.resolution))} + ${escapeHtml(formatValue(row.langue))} + ${escapeHtml(formatValue(row.region))} + ${escapeHtml(formatValue(row.groupe))} + ${escapeHtml(formatValue(row.active))} + ${escapeHtml(formatValue(row.idtype))} + ${escapeHtml(formatValue(row.six_id))} + ${escapeHtml(formatValue(row.sid))} + ${escapeHtml(formatValue(row.onid))} + ${escapeHtml(formatValue(row.tid))} + ${escapeHtml(formatValue(row.sat_in))} + ${escapeHtml(formatValue(row.sat_out))} + ${escapeHtml(formatValue(row.sat_update))} + ${escapeHtml(formatValue(row.sat_ch_client_upper))} + ${escapeHtml(formatValue(row.sat_client))} + ${escapeHtml(formatValue(row.counting))} + ${escapeHtml(formatValue(row.lastview))} + ${escapeHtml(formatValue(row.manually_edited_at))} + `).join(''); + } + + const start = pagination.total === 0 ? 0 : ((pagination.page - 1) * pagination.limit) + 1; + const end = Math.min(pagination.total, pagination.page * pagination.limit); + elements.resultCounter.textContent = `${pagination.total} result(s) · showing ${start}-${end}`; + elements.pageMeta.textContent = `Page ${pagination.page} of ${pagination.pages}`; + elements.prevPageBtn.disabled = pagination.page <= 1; + elements.nextPageBtn.disabled = pagination.page >= pagination.pages; + updateSelectionUI(); + }; + + const fetchChannels = async () => { + elements.resultCounter.textContent = 'Loading channels…'; + const query = buildQueryString({ ...state.filters, sort: state.sort.field, direction: state.sort.direction }); + const response = await fetch(`${config.apiBase}?${query}`, { headers: { 'Accept': 'application/json' } }); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Unable to fetch channels.'); + renderChannels(result.data); + }; + + const renderDetailForm = (channel) => { + state.currentDetail = channel; + elements.detailChannelId.value = channel.id; + elements.detailLoadingState.classList.add('d-none'); + elements.detailEditableSection.classList.remove('d-none'); + + const buildInput = (field, value) => { + const label = labels[field] || field; + if (field === 'active') { + return ` +
+ + +
`; + } + if (field === 'type') { + return ` +
+ + +
`; + } + if (selectOptionsFields.includes(field)) { + const opts = state.options[field] || []; + return ` +
+ + +
`; + } + if (['sat_in', 'sat_out', 'sat_update'].includes(field)) { + return ` +
+ + +
`; + } + return ` +
+ + +
`; + }; + + elements.editableFieldsContainer.innerHTML = editableFields.map(field => buildInput(field, channel[field])).join(''); + elements.lockedFieldsContainer.innerHTML = lockedFields.map(field => ` +
+
+
+
${escapeHtml(labels[field] || field)}
+ 🔒 +
+
${escapeHtml(formatValue(channel[field]))}
+
+
`).join(''); + }; + + const fetchDetail = async (id) => { + elements.detailLoadingState.textContent = 'Loading channel detail…'; + elements.detailLoadingState.classList.remove('d-none'); + elements.detailEditableSection.classList.add('d-none'); + detailOffcanvas.show(); + const response = await fetch(`${config.apiBase}?id=${encodeURIComponent(id)}`); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load detail.'); + state.options = { ...state.options, ...(result.meta?.options || {}) }; + renderDetailForm(result.data); + state.currentHistoryId = Number(result.data.id); + state.historyLoaded = false; + try { + await fetchHistory(result.data.id, { silent: true }); + } catch (error) { + renderHistoryEmpty(error.message); + } + }; + + const payloadFromForm = (form, fields) => { + const payload = {}; + fields.forEach(field => { + const input = form.elements.namedItem(field); + if (!input) return; + payload[field] = input.value; + }); + return payload; + }; + + const renderBulkForm = () => { + elements.bulkEditFieldsContainer.innerHTML = editableFields.map(field => { + const label = labels[field] || field; + let control = ''; + if (field === 'active') { + control = ` + `; + } else if (field === 'type') { + control = ` + `; + } else if (selectOptionsFields.includes(field)) { + const opts = state.options[field] || []; + control = ` + `; + } else if (['sat_in', 'sat_out', 'sat_update'].includes(field)) { + control = ``; + } else { + control = ``; + } + return ` +
+
+
+ + +
+ ${control} +
+
`; + }).join(''); + }; + + const fetchAnalytics = async () => { + const response = await fetch(`${config.apiBase}?stats=1`); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load analytics.'); + const stats = result.data; + const overview = [ + ['Total channels', stats.overview.total_channels], + ['Active', stats.overview.active_channels], + ['Manually edited', stats.overview.manually_edited_channels], + ['Total hits', Number(stats.overview.total_hits).toLocaleString()], + ]; + elements.analyticsOverview.innerHTML = overview.map(([label, value]) => ` +
${escapeHtml(label)}
${escapeHtml(value)}
`).join(''); + + elements.heroTotalChannels.textContent = stats.overview.total_channels; + elements.heroActiveChannels.textContent = stats.overview.active_channels; + elements.heroManualChannels.textContent = stats.overview.manually_edited_channels; + elements.heroTotalHits.textContent = Number(stats.overview.total_hits).toLocaleString(); + + elements.analyticsBreakdowns.innerHTML = Object.entries(stats.breakdowns).map(([dimension, rows]) => { + const max = Math.max(...rows.map(item => Number(item.total)), 1); + const bars = rows.map(item => ` +
+
${escapeHtml(item.label)}${escapeHtml(item.total)}
+
+
`).join(''); + return ` +
+
+
+

${escapeHtml(dimension.replaceAll('_', ' '))}

+ Top distribution +
+
${bars}
+
+
`; + }).join(''); + }; + + const renderHistoryEmpty = (message) => { + elements.historyLead.textContent = message; + elements.historySummary.innerHTML = '
No channel selected yet.
'; + elements.historyTimeline.innerHTML = `
${escapeHtml(message)}
`; + }; + + const renderHistory = (payload) => { + const versions = payload.versions || []; + if (!versions.length) { + renderHistoryEmpty('No history entries were found for this channel.'); + return; + } + + const currentVersion = versions.find(version => Number(version.is_current) === 1 || version.is_current === true) || versions[versions.length - 1]; + const firstVersion = versions[0]; + elements.historyLead.textContent = `Showing ${versions.length} version(s) for ${currentVersion.upper_ch_ref || 'selected channel'} (current row #${currentVersion.id}).`; + + const summaryCards = [ + ['Selected row', `#${payload.selected_id}`], + ['First version', firstVersion.date_in || `#${firstVersion.id}`], + ['Latest version', `#${payload.latest_id}`], + ['Total versions', String(payload.total_versions || versions.length)], + ]; + + elements.historySummary.innerHTML = summaryCards.map(([label, value]) => ` +
+
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+
`).join(''); + + elements.historyTimeline.innerHTML = versions.map(version => { + const statusClass = version.is_current ? 'history-status-current' : 'history-status-closed'; + const statusLabel = version.is_current ? 'Current' : 'Closed'; + const changeList = (version.changes || []).length + ? version.changes.map(change => ` +
+ ${escapeHtml(labels[change.field_name] || change.field_name)} + ${escapeHtml(formatValue(change.old_value))} + + ${escapeHtml(formatValue(change.new_value))} + ${escapeHtml(formatValue(change.user_id))} · ${escapeHtml(formatValue(change.changed_at))} +
`).join('') + : '
Initial imported/current row snapshot with no manual field changes recorded for this version.
'; + + return ` +
+
+
+
+ Version ${escapeHtml(version.version_number)} + ${statusLabel} + Row #${escapeHtml(version.id)} +
+

${escapeHtml(formatValue(version.upper_ch_ref))}

+
${escapeHtml(formatValue(version.sat_ref))} · ${escapeHtml(formatValue(version.country_iso))} · ${escapeHtml(formatValue(version.genre))}
+
+
+
Date in: ${escapeHtml(formatValue(version.date_in))}
+
Date out: ${escapeHtml(formatValue(version.date_out))}
+
+
+ +
+
Type${escapeHtml(formatValue(version.type))}
+
Resolution${escapeHtml(formatValue(version.resolution))}
+
Active${escapeHtml(formatValue(version.active))}
+
sat_out${escapeHtml(formatValue(version.sat_out))}
+
+ +
+
Field changes for this version
+
${changeList}
+
+
`; + }).join(''); + }; + + const fetchHistory = async (id, options = {}) => { + const { silent = false } = options; + if (!id) { + state.historyLoaded = false; + state.currentHistoryId = null; + renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); + return; + } + + if (!silent) { + elements.historyLead.textContent = `Loading history for row #${id}…`; + elements.historyTimeline.innerHTML = '
Loading history…
'; + } + + const response = await fetch(`${config.apiBase}?history_for=${encodeURIComponent(id)}`); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load history.'); + + state.currentHistoryId = Number(id); + state.historyLoaded = true; + renderHistory(result.data); + }; + + const fetchAuditLog = async () => { + elements.auditTableBody.innerHTML = 'Loading audit log…'; + const params = { + page: state.auditFilters.page, + limit: state.auditFilters.limit, + user_id: elements.auditUserFilter.value, + field_name: elements.auditFieldFilter.value, + date_from: elements.auditDateFrom.value, + date_to: elements.auditDateTo.value, + }; + const response = await fetch(`${config.auditApi}?${buildQueryString(params)}`); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load audit log.'); + const { data, filters } = result.data; + + const fillSelect = (select, items, currentValue) => { + if (!select.dataset.loaded) { + const first = select.options[0]?.outerHTML || ''; + select.innerHTML = first + items.map(item => ``).join(''); + select.dataset.loaded = 'true'; + } + select.value = currentValue || ''; + }; + + fillSelect(elements.auditUserFilter, filters.users || [], params.user_id); + fillSelect(elements.auditFieldFilter, filters.fields || [], params.field_name); + + if (!data.length) { + elements.auditTableBody.innerHTML = 'No audit entries for the current filters.'; + return; + } + + elements.auditTableBody.innerHTML = data.map(item => ` + + ${escapeHtml(formatValue(item.changed_at))} + ${escapeHtml(formatValue(item.user_id))} + +
${escapeHtml(formatValue(item.upper_ch_ref))}
+
#${escapeHtml(item.channel_id)} · ${escapeHtml(formatValue(item.sat_ref))}
+ + ${escapeHtml(formatValue(item.field_name))} + +
+ ${escapeHtml(formatValue(item.old_value))} + + ${escapeHtml(formatValue(item.new_value))} +
+ + `).join(''); + }; + + const refreshAll = async () => { + try { + await fetchChannels(); + if (!state.analyticsLoaded) { + await fetchAnalytics(); + state.analyticsLoaded = true; + } + } catch (error) { + elements.channelsTableBody.innerHTML = `${escapeHtml(error.message)}`; + notify(error.message, 'danger'); + } + }; + + elements.filtersForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const formData = new FormData(elements.filtersForm); + state.filters = { limit: 50, page: 1 }; + for (const [key, value] of formData.entries()) { + if (String(value).trim() !== '') state.filters[key] = String(value).trim(); + } + await refreshAll(); + }); + + elements.resetFiltersBtn?.addEventListener('click', async () => { + state.filters = { limit: 50, page: 1 }; + elements.filtersForm.reset(); + syncFilterForm(); + await refreshAll(); + notify('Filters reset to default active view.'); + }); + + elements.prevPageBtn?.addEventListener('click', async () => { + if ((state.filters.page || 1) <= 1) return; + state.filters.page = (state.filters.page || 1) - 1; + await refreshAll(); + }); + + elements.nextPageBtn?.addEventListener('click', async () => { + state.filters.page = (state.filters.page || 1) + 1; + await refreshAll(); + }); + + elements.selectAllPageCheckbox?.addEventListener('change', (event) => { + const checked = event.target.checked; + state.currentRows.forEach(row => { + const id = String(row.id); + if (checked) state.selectedIds.add(id); + else state.selectedIds.delete(id); + }); + if (state.lastListPayload) renderChannels(state.lastListPayload); + }); + + elements.selectPageBtn?.addEventListener('click', () => { + state.currentRows.forEach(row => state.selectedIds.add(String(row.id))); + document.querySelectorAll('.row-checkbox').forEach(box => { box.checked = true; }); + updateSelectionUI(); + document.querySelectorAll('tr[data-row-id]').forEach(row => row.classList.add('row-selected')); + notify('Current page selected.'); + }); + + elements.clearSelectionBtn?.addEventListener('click', () => { + state.selectedIds.clear(); + document.querySelectorAll('.row-checkbox').forEach(box => { box.checked = false; }); + document.querySelectorAll('tr[data-row-id]').forEach(row => row.classList.remove('row-selected')); + updateSelectionUI(); + }); + + document.querySelectorAll('.channel-table thead th[data-sort]').forEach(th => { + th.addEventListener('click', async () => { + const field = th.dataset.sort; + if (state.sort.field === field) { + state.sort.direction = state.sort.direction === 'ASC' ? 'DESC' : 'ASC'; + } else { + state.sort.field = field; + state.sort.direction = 'ASC'; + } + await refreshAll(); + }); + }); + + elements.channelsTableBody?.addEventListener('click', async (event) => { + const checkbox = event.target.closest('.row-checkbox'); + if (checkbox) { + const id = String(checkbox.dataset.rowId); + if (checkbox.checked) state.selectedIds.add(id); else state.selectedIds.delete(id); + const rowEl = checkbox.closest('tr'); + rowEl?.classList.toggle('row-selected', checkbox.checked); + updateSelectionUI(); + return; + } + + const row = event.target.closest('tr[data-row-id]'); + if (!row) return; + try { + await fetchDetail(row.dataset.rowId); + } catch (error) { + notify(error.message, 'danger'); + } + }); + + elements.detailForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const id = elements.detailChannelId.value; + const fields = payloadFromForm(elements.detailForm, editableFields); + try { + const response = await fetch(`${config.apiBase}?id=${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ fields }) + }); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Unable to save changes.'); + detailOffcanvas.hide(); + notify(`Saved ${result.data.changed_fields.length || 0} field(s).`); + state.auditLoaded = false; + state.historyLoaded = false; + state.currentHistoryId = Number(result.data?.channel?.id || id); + try { + await fetchHistory(state.currentHistoryId, { silent: true }); + } catch (historyError) { + renderHistoryEmpty(historyError.message); + } + await refreshAll(); + } catch (error) { + notify(error.message, 'danger'); + } + }); + + document.getElementById('bulkEditModal')?.addEventListener('show.bs.modal', () => { + renderBulkForm(); + elements.bulkSelectionSummary.textContent = String(state.selectedIds.size); + }); + + elements.bulkEditFieldsContainer?.addEventListener('change', (event) => { + const toggle = event.target.closest('.bulk-enable'); + if (!toggle) return; + const card = document.querySelector(`[data-field-card="${toggle.value}"]`); + const control = card?.querySelector('.bulk-control'); + if (control) control.disabled = !toggle.checked; + card?.classList.toggle('is-enabled', toggle.checked); + }); + + elements.bulkEditForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (state.selectedIds.size === 0) { + notify('Select at least one row before bulk editing.', 'danger'); + return; + } + + const enabledFields = [...document.querySelectorAll('.bulk-enable:checked')]; + if (enabledFields.length === 0) { + notify('Check at least one field to apply.', 'danger'); + return; + } + + const fields = {}; + enabledFields.forEach(toggle => { + const control = document.querySelector(`[data-field-card="${toggle.value}"] .bulk-control`); + fields[toggle.value] = control ? control.value : ''; + }); + + try { + const response = await fetch(`${config.apiBase}?bulk=1`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ ids: [...state.selectedIds].map(Number), fields }) + }); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Bulk update failed.'); + bulkModal.hide(); + state.selectedIds.clear(); + notify(`Bulk update applied to ${result.data.updated_count} row(s).`); + state.auditLoaded = false; + state.historyLoaded = false; + state.currentHistoryId = null; + renderHistoryEmpty('Bulk edit finished. Select one row in Dataset to inspect its version chain.'); + await refreshAll(); + } catch (error) { + notify(error.message, 'danger'); + } + }); + + document.querySelector('[data-bs-target="#analytics-panel"]')?.addEventListener('shown.bs.tab', async () => { + try { + await fetchAnalytics(); + state.analyticsLoaded = true; + } catch (error) { + notify(error.message, 'danger'); + } + }); + + document.querySelector('[data-bs-target="#history-panel"]')?.addEventListener('shown.bs.tab', async () => { + if (!state.currentHistoryId) { + renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); + return; + } + if (state.historyLoaded) return; + try { + await fetchHistory(state.currentHistoryId); + } catch (error) { + renderHistoryEmpty(error.message); + notify(error.message, 'danger'); + } + }); + + document.querySelector('[data-bs-target="#audit-panel"]')?.addEventListener('shown.bs.tab', async () => { + if (state.auditLoaded) return; + try { + await fetchAuditLog(); + state.auditLoaded = true; + } catch (error) { + notify(error.message, 'danger'); + } + }); + + elements.refreshHistoryBtn?.addEventListener('click', async () => { + if (!state.currentHistoryId) { + renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); + return; + } + try { + await fetchHistory(state.currentHistoryId); + } catch (error) { + renderHistoryEmpty(error.message); + notify(error.message, 'danger'); + } + }); + + elements.applyAuditFiltersBtn?.addEventListener('click', async () => { + try { + await fetchAuditLog(); + state.auditLoaded = true; + } catch (error) { + notify(error.message, 'danger'); + } + }); + + syncFilterForm(); + renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); + refreshAll(); }); diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..d8e8b70 --- /dev/null +++ b/healthz.php @@ -0,0 +1,14 @@ +query('SELECT COUNT(*) FROM channels')->fetchColumn(); + echo json_encode(['ok' => true, 'channels' => $count, 'time' => gmdate('c')], JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['ok' => false], JSON_UNESCAPED_SLASHES); +} diff --git a/index.php b/index.php index 7205f3d..74196a5 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,431 @@ - + - - - New Style - - - - - - - - - - - - - - - + + + <?= htmlspecialchars($projectName) ?> · TV Channels Data Management + + + + + + + + + + - - + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+ +
+ +
+
+
+
+
+
+
+ Initial MVP slice + Read-only source data · whitelisted PATCH only +
+

TV channels workspace with strict edits, bulk operations, analytics, and full audit trace.

+

Built as an internal operator console: browse active and historical channels, filter by business dimensions, update only allowed metadata fields, and review every field-level change in the audit stream.

+
+
+
+
Total channels
+
+
+
+
+
+
Active now
+
+
+
+
+
+
Manual edits
+
+
+
+
+
+
Total hits
+
+
+
+
+
+
+
+
+
+
+
+

Operating rules

+ UTC +
+
+
No UI insertion path — source rows remain pushimatic-owned.
+
System identifiers stay locked; forbidden edits return HTTP 403.
+
Single-row and bulk edits both flag manually_edited.
+
Every changed field creates an audit_log entry.
+
+
+
Default query behavior
+

If no filter is applied, the list loads only current active rows with date_out IS NULL and sat_out IS NULL.

+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+

Channels

+

Full dataset browser with strict editable fields and protected system metadata.

+
+
+ + +
+
+ +
+
+ Manually edited + Selected row +
+
+ Loading channels… +
+
+ +
+ +
+
0 row(s) selected
+
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
upper_ch_refcountry_isocountry_clientsat_refsat_tpinfogenretyperesolutionlangueregiongroupeactiveidtypesix_idsidonidtidsat_insat_outsat_updatesat_ch_client_uppersat_clientcountinglastviewmanually_edited_at
Loading dataset…
+
+
+
Page 1 of 1
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+

Version history

+

Click a row in Dataset to load its previous and current versions.

+
+
+ +
+
+
+
No channel selected yet.
+
+
+
History will appear here after you select a channel row.
+
+
+ +
+
+
+
+
+
+
+
+

Audit entries show each field-level change with old → new value.

+ +
+
+ + + + + + + + + + + + + +
WhenUserChannelFieldChange
Load the audit trail…
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Channel detail

+

Editable metadata + locked system fields.

-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
-
- +
+
+
Select a row to inspect and update it.
+ +
+
+
Editable fields
+
+
+
+
Locked system fields
+
+
+
+ + +
+
+
+
+ + + + +
+ + + +