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 = `${escapeHtml(select.options[0]?.textContent || 'All')} ` +
+ list.map(item => `${escapeHtml(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 `
+
+ ${escapeHtml(label)}
+
+ 1
+ 0
+
+
`;
+ }
+ if (field === 'type') {
+ return `
+
+ ${escapeHtml(label)}
+
+ free
+ payed
+
+
`;
+ }
+ if (selectOptionsFields.includes(field)) {
+ const opts = state.options[field] || [];
+ return `
+
+ ${escapeHtml(label)}
+
+ Select
+ ${opts.map(item => `${escapeHtml(item)} `).join('')}
+
+
`;
+ }
+ if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
+ return `
+
+ ${escapeHtml(label)}
+
+
`;
+ }
+ return `
+
+ ${escapeHtml(label)}
+
+
`;
+ };
+
+ 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 = `
+
+ 1
+ 0
+ `;
+ } else if (field === 'type') {
+ control = `
+
+ free
+ payed
+ `;
+ } else if (selectOptionsFields.includes(field)) {
+ const opts = state.options[field] || [];
+ control = `
+
+ Select
+ ${opts.map(item => `${escapeHtml(item)} `).join('')}
+ `;
+ } else if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
+ control = ` `;
+ } else {
+ control = ` `;
+ }
+ return `
+
+
+
+
+ ${escapeHtml(label)}
+
+ ${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 = '';
+ elements.historyTimeline.innerHTML = ``;
+ };
+
+ 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 = '';
+ }
+
+ 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 || 'All ';
+ select.innerHTML = first + items.map(item => `${escapeHtml(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
+
= (int) ($stats['overview']['total_channels'] ?? 0) ?>
+
+
+
+
+
Active now
+
= (int) ($stats['overview']['active_channels'] ?? 0) ?>
+
+
+
+
+
Manual edits
+
= (int) ($stats['overview']['manually_edited_channels'] ?? 0) ?>
+
+
+
+
+
Total hits
+
= number_format((int) ($stats['overview']['total_hits'] ?? 0)) ?>
+
+
+
+
+
+
+
+
+
+
+
Operating rules
+ UTC = htmlspecialchars($now) ?>
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Filters
+
All filters combine with AND logic.
+
+
Reset
+
+
+
+
+
+
+
+
+
+
+
+
+
Channels
+
Full dataset browser with strict editable fields and protected system metadata.
+
+
+ Select page
+ Bulk edit (0)
+
+
+
+
+
+
+
+
+
0 row(s) selected
+
+ Bulk edit selected
+ Clear selection
+
+
+
+
+ Dataset
+ Analytics
+ History
+ Audit log
+
+
+
+
+
+
+
+
+
+
+
Version history
+
Click a row in Dataset to load its previous and current versions.
+
+
+ Refresh history
+
+
+
+
+
History will appear here after you select a channel row.
+
+
+
+
+
+
User All users
+
Field All fields
+
From
+
To
+
+
+
Audit entries show each field-level change with old → new value.
+
Refresh log
+
+
+
+
+
+ When
+ User
+ Channel
+ Field
+ Change
+
+
+
+ Load the audit trail…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
+
+
+
+
+
+
+
+
+
+