This commit is contained in:
Flatlogic Bot 2026-04-05 09:55:47 +00:00
parent 60132cc620
commit 8be8405504
7 changed files with 2653 additions and 554 deletions

20
api/audit-log.php Normal file
View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../app/channel_data.php';
function audit_respond(int $status, array $payload): void
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
try {
channel_app_bootstrap();
audit_respond(200, ['success' => true, 'data' => audit_log_list($_GET)]);
} catch (Throwable $e) {
audit_respond(500, ['success' => false, 'error' => 'Unable to load audit log.']);
}

74
api/channels.php Normal file
View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../app/channel_data.php';
function respond(int $status, array $payload): void
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
channel_app_bootstrap();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$userId = channel_current_user_id();
try {
if ($method === 'GET') {
if (isset($_GET['stats'])) {
respond(200, ['success' => 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.']);
}

819
app/channel_data.php Normal file
View File

@ -0,0 +1,819 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
const CHANNEL_EDITABLE_FIELDS = [
'upper_ch_ref',
'country_iso',
'sat_ref',
'genre',
'type',
'groupe',
'region',
'langue',
'resolution',
'active',
'sat_in',
'sat_out',
'sat_update',
];
const CHANNEL_LOCKED_FIELDS = [
'country_client',
'sat_tpinfo',
'idtype',
'six_id',
'sid',
'onid',
'tid',
'sat_ch_client_upper',
'sat_client',
'counting',
'lastview',
'manually_edited',
'manually_edited_at',
];
const CHANNEL_LIST_COLUMNS = [
'id', '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'
];
const CHANNEL_RECORD_COLUMNS = [
'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'
];
function channel_app_bootstrap(): void
{
static $booted = false;
if ($booted) {
return;
}
$pdo = db();
$pdo->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()));
}

View File

@ -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;
}

View File

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
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 = `
<div class="d-flex">
<div class="toast-body"><strong class="text-${tone}">${escapeHtml(message)}</strong></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>`;
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 = `<option value="">${escapeHtml(select.options[0]?.textContent || 'All')}</option>` +
list.map(item => `<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>`).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]) => `<span class="filter-chip">${escapeHtml(key)}: ${escapeHtml(value)}</span>`);
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 = '<tr><td colspan="26" class="empty-state-cell">No channels match the current filters.</td></tr>';
} else {
elements.channelsTableBody.innerHTML = data.map(row => `
<tr class="${rowClasses(row)}" data-row-id="${row.id}">
<td class="sticky-col checkbox-col"><input class="form-check-input row-checkbox" type="checkbox" data-row-id="${row.id}" ${state.selectedIds.has(String(row.id)) ? 'checked' : ''} aria-label="Select row ${escapeHtml(row.upper_ch_ref)}"></td>
<td><strong>${escapeHtml(formatValue(row.upper_ch_ref))}</strong></td>
<td>${escapeHtml(formatValue(row.country_iso))}</td>
<td>${escapeHtml(formatValue(row.country_client))}</td>
<td>${escapeHtml(formatValue(row.sat_ref))}</td>
<td>${escapeHtml(formatValue(row.sat_tpinfo))}</td>
<td>${escapeHtml(formatValue(row.genre))}</td>
<td><span class="channel-pill">${escapeHtml(formatValue(row.type))}</span></td>
<td>${escapeHtml(formatValue(row.resolution))}</td>
<td>${escapeHtml(formatValue(row.langue))}</td>
<td>${escapeHtml(formatValue(row.region))}</td>
<td>${escapeHtml(formatValue(row.groupe))}</td>
<td>${escapeHtml(formatValue(row.active))}</td>
<td>${escapeHtml(formatValue(row.idtype))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.six_id))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.sid))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.onid))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.tid))}</td>
<td>${escapeHtml(formatValue(row.sat_in))}</td>
<td>${escapeHtml(formatValue(row.sat_out))}</td>
<td>${escapeHtml(formatValue(row.sat_update))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.sat_ch_client_upper))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.sat_client))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.counting))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.lastview))}</td>
<td>${escapeHtml(formatValue(row.manually_edited_at))}</td>
</tr>`).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 `
<div class="col-sm-6">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<select class="form-select" id="field_${field}" name="${field}">
<option value="1" ${String(value) === '1' ? 'selected' : ''}>1</option>
<option value="0" ${String(value) === '0' ? 'selected' : ''}>0</option>
</select>
</div>`;
}
if (field === 'type') {
return `
<div class="col-sm-6">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<select class="form-select" id="field_${field}" name="${field}">
<option value="free" ${String(value) === 'free' ? 'selected' : ''}>free</option>
<option value="payed" ${String(value) === 'payed' ? 'selected' : ''}>payed</option>
</select>
</div>`;
}
if (selectOptionsFields.includes(field)) {
const opts = state.options[field] || [];
return `
<div class="col-sm-6">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<select class="form-select" id="field_${field}" name="${field}">
<option value="">Select</option>
${opts.map(item => `<option value="${escapeHtml(item)}" ${String(value || '') === String(item) ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}
</select>
</div>`;
}
if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
return `
<div class="col-sm-6">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<input class="form-control" id="field_${field}" name="${field}" type="date" value="${escapeHtml(value || '')}">
</div>`;
}
return `
<div class="col-sm-6">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<input class="form-control" id="field_${field}" name="${field}" type="text" value="${escapeHtml(value || '')}">
</div>`;
};
elements.editableFieldsContainer.innerHTML = editableFields.map(field => buildInput(field, channel[field])).join('');
elements.lockedFieldsContainer.innerHTML = lockedFields.map(field => `
<div class="col-sm-6">
<div class="locked-card sys-field">
<div class="d-flex justify-content-between gap-2">
<div class="locked-label">${escapeHtml(labels[field] || field)}</div>
<span class="lock-icon">🔒</span>
</div>
<div class="locked-value">${escapeHtml(formatValue(channel[field]))}</div>
</div>
</div>`).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 = `
<select class="form-select bulk-control" name="${field}" disabled>
<option value="1">1</option>
<option value="0">0</option>
</select>`;
} else if (field === 'type') {
control = `
<select class="form-select bulk-control" name="${field}" disabled>
<option value="free">free</option>
<option value="payed">payed</option>
</select>`;
} else if (selectOptionsFields.includes(field)) {
const opts = state.options[field] || [];
control = `
<select class="form-select bulk-control" name="${field}" disabled>
<option value="">Select</option>
${opts.map(item => `<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>`).join('')}
</select>`;
} else if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
control = `<input class="form-control bulk-control" name="${field}" type="date" disabled>`;
} else {
control = `<input class="form-control bulk-control" name="${field}" type="text" disabled>`;
}
return `
<div class="col-md-6">
<div class="bulk-field-card" data-field-card="${field}">
<div class="form-check">
<input class="form-check-input bulk-enable" type="checkbox" value="${field}" id="bulk_enable_${field}">
<label class="form-check-label fw-semibold" for="bulk_enable_${field}">${escapeHtml(label)}</label>
</div>
${control}
</div>
</div>`;
}).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]) => `
<div class="col-sm-6 col-xl-3"><div class="analytics-card"><div class="metric-label">${escapeHtml(label)}</div><div class="metric-value">${escapeHtml(value)}</div></div></div>`).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 => `
<div class="analytics-bar-row">
<div class="analytics-bar-top"><span>${escapeHtml(item.label)}</span><strong>${escapeHtml(item.total)}</strong></div>
<div class="analytics-bar-track"><div class="analytics-bar-fill" style="width:${(Number(item.total) / max) * 100}%"></div></div>
</div>`).join('');
return `
<div class="col-lg-6">
<div class="analytics-card h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="section-title mb-0 text-capitalize">${escapeHtml(dimension.replaceAll('_', ' '))}</h3>
<span class="small text-muted">Top distribution</span>
</div>
<div class="analytics-bars">${bars}</div>
</div>
</div>`;
}).join('');
};
const renderHistoryEmpty = (message) => {
elements.historyLead.textContent = message;
elements.historySummary.innerHTML = '<div class="col-12"><div class="analytics-card"><div class="small text-muted">No channel selected yet.</div></div></div>';
elements.historyTimeline.innerHTML = `<div class="analytics-card"><div class="small text-muted">${escapeHtml(message)}</div></div>`;
};
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]) => `
<div class="col-sm-6 col-xl-3">
<div class="analytics-card h-100">
<div class="metric-label">${escapeHtml(label)}</div>
<div class="metric-value history-metric-value">${escapeHtml(value)}</div>
</div>
</div>`).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 => `
<div class="history-change">
<span class="history-change-field">${escapeHtml(labels[change.field_name] || change.field_name)}</span>
<span class="audit-old">${escapeHtml(formatValue(change.old_value))}</span>
<span aria-hidden="true"></span>
<span class="audit-new">${escapeHtml(formatValue(change.new_value))}</span>
<span class="history-change-meta">${escapeHtml(formatValue(change.user_id))} · ${escapeHtml(formatValue(change.changed_at))}</span>
</div>`).join('')
: '<div class="small text-muted">Initial imported/current row snapshot with no manual field changes recorded for this version.</div>';
return `
<article class="history-card ${version.is_current ? 'is-current' : ''}">
<div class="history-card-top">
<div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<span class="history-version-pill">Version ${escapeHtml(version.version_number)}</span>
<span class="history-status-pill ${statusClass}">${statusLabel}</span>
<span class="small text-muted">Row #${escapeHtml(version.id)}</span>
</div>
<h3 class="history-card-title">${escapeHtml(formatValue(version.upper_ch_ref))}</h3>
<div class="small text-muted">${escapeHtml(formatValue(version.sat_ref))} · ${escapeHtml(formatValue(version.country_iso))} · ${escapeHtml(formatValue(version.genre))}</div>
</div>
<div class="history-date-block">
<div><strong>Date in:</strong> ${escapeHtml(formatValue(version.date_in))}</div>
<div><strong>Date out:</strong> ${escapeHtml(formatValue(version.date_out))}</div>
</div>
</div>
<div class="history-meta-grid">
<div><span class="history-meta-label">Type</span><strong>${escapeHtml(formatValue(version.type))}</strong></div>
<div><span class="history-meta-label">Resolution</span><strong>${escapeHtml(formatValue(version.resolution))}</strong></div>
<div><span class="history-meta-label">Active</span><strong>${escapeHtml(formatValue(version.active))}</strong></div>
<div><span class="history-meta-label">sat_out</span><strong>${escapeHtml(formatValue(version.sat_out))}</strong></div>
</div>
<div class="history-changes-block">
<div class="section-subtitle mb-2">Field changes for this version</div>
<div class="history-changes-list">${changeList}</div>
</div>
</article>`;
}).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 = '<div class="analytics-card"><div class="small text-muted">Loading history…</div></div>';
}
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 = '<tr><td colspan="5" class="empty-state-cell">Loading audit log…</td></tr>';
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 || '<option value="">All</option>';
select.innerHTML = first + items.map(item => `<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>`).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 = '<tr><td colspan="5" class="empty-state-cell">No audit entries for the current filters.</td></tr>';
return;
}
elements.auditTableBody.innerHTML = data.map(item => `
<tr>
<td>${escapeHtml(formatValue(item.changed_at))}</td>
<td>${escapeHtml(formatValue(item.user_id))}</td>
<td>
<div class="fw-semibold">${escapeHtml(formatValue(item.upper_ch_ref))}</div>
<div class="small text-muted">#${escapeHtml(item.channel_id)} · ${escapeHtml(formatValue(item.sat_ref))}</div>
</td>
<td>${escapeHtml(formatValue(item.field_name))}</td>
<td>
<div class="audit-change">
<span class="audit-old">${escapeHtml(formatValue(item.old_value))}</span>
<span aria-hidden="true"></span>
<span class="audit-new">${escapeHtml(formatValue(item.new_value))}</span>
</div>
</td>
</tr>`).join('');
};
const refreshAll = async () => {
try {
await fetchChannels();
if (!state.analyticsLoaded) {
await fetchAnalytics();
state.analyticsLoaded = true;
}
} catch (error) {
elements.channelsTableBody.innerHTML = `<tr><td colspan="26" class="empty-state-cell">${escapeHtml(error.message)}</td></tr>`;
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();
});

14
healthz.php Normal file
View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/app/channel_data.php';
try {
channel_app_bootstrap();
$count = (int) db()->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);
}

551
index.php
View File

@ -1,150 +1,431 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
require_once __DIR__ . '/app/channel_data.php';
channel_app_bootstrap();
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
$projectName = $_SERVER['PROJECT_NAME'] ?? 'SignalDesk TV';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'TV channels data management workspace for controlled edits, analytics, and auditability.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$now = gmdate('Y-m-d H:i:s');
$stats = channel_stats();
?>
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($projectName) ?> · TV Channels Data Management</title>
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>">
<meta name="author" content="Flatlogic AI">
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>">
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time() ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<div class="app-shell">
<header class="border-bottom bg-white sticky-top app-header">
<nav class="navbar navbar-expand-lg navbar-light py-2">
<div class="container-fluid px-3 px-lg-4">
<a class="navbar-brand d-flex align-items-center gap-2" href="#workspace">
<span class="brand-mark">SD</span>
<span>
<span class="d-block brand-title">SignalDesk TV</span>
<span class="d-block brand-subtitle">Controlled channel operations</span>
</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="#workspace">Workspace</a></li>
<li class="nav-item"><a class="nav-link" href="#analytics-panel">Analytics</a></li>
<li class="nav-item"><a class="nav-link" href="#audit-panel">Audit log</a></li>
<li class="nav-item"><a class="nav-link" href="healthz.php" target="_blank" rel="noopener">Health</a></li>
</ul>
</div>
</div>
</nav>
</header>
<main class="container-fluid px-3 px-lg-4 py-4">
<section class="hero-panel mb-4" id="workspace">
<div class="row g-3 align-items-stretch">
<div class="col-12 col-xxl-7">
<div class="card panel-card h-100">
<div class="card-body p-4">
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<span class="eyebrow">Initial MVP slice</span>
<span class="status-chip">Read-only source data · whitelisted PATCH only</span>
</div>
<h1 class="hero-title">TV channels workspace with strict edits, bulk operations, analytics, and full audit trace.</h1>
<p class="hero-copy mb-4">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.</p>
<div class="row g-3 metrics-inline">
<div class="col-sm-6 col-lg-3">
<div class="metric-tile">
<div class="metric-label">Total channels</div>
<div class="metric-value" id="heroTotalChannels"><?= (int) ($stats['overview']['total_channels'] ?? 0) ?></div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="metric-tile">
<div class="metric-label">Active now</div>
<div class="metric-value" id="heroActiveChannels"><?= (int) ($stats['overview']['active_channels'] ?? 0) ?></div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="metric-tile metric-tile-warm">
<div class="metric-label">Manual edits</div>
<div class="metric-value" id="heroManualChannels"><?= (int) ($stats['overview']['manually_edited_channels'] ?? 0) ?></div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="metric-tile">
<div class="metric-label">Total hits</div>
<div class="metric-value" id="heroTotalHits"><?= number_format((int) ($stats['overview']['total_hits'] ?? 0)) ?></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xxl-5">
<div class="card panel-card h-100">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="section-title mb-0">Operating rules</h2>
<span class="text-muted small">UTC <?= htmlspecialchars($now) ?></span>
</div>
<div class="rule-list">
<div class="rule-item"><span class="rule-dot"></span> No UI insertion path source rows remain pushimatic-owned.</div>
<div class="rule-item"><span class="rule-dot"></span> System identifiers stay locked; forbidden edits return HTTP 403.</div>
<div class="rule-item"><span class="rule-dot"></span> Single-row and bulk edits both flag <code>manually_edited</code>.</div>
<div class="rule-item"><span class="rule-dot"></span> Every changed field creates an <code>audit_log</code> entry.</div>
</div>
<div class="surface-note mt-4">
<div class="surface-note-title">Default query behavior</div>
<p class="mb-0">If no filter is applied, the list loads only current active rows with <code>date_out IS NULL</code> and <code>sat_out IS NULL</code>.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="row g-4">
<aside class="col-12 col-xl-3">
<div class="card panel-card sticky-xl-top filters-card">
<div class="card-body p-3 p-lg-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="section-title mb-1">Filters</h2>
<p class="text-muted small mb-0">All filters combine with AND logic.</p>
</div>
<button class="btn btn-sm btn-outline-secondary" id="resetFiltersBtn">Reset</button>
</div>
<form id="filtersForm" class="vstack gap-3" autocomplete="off">
<div>
<label class="form-label" for="searchInput">Global search</label>
<input id="searchInput" name="search" class="form-control" type="search" placeholder="Channel, satellite, system id…">
</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="satRefFilter">Satellite</label>
<select id="satRefFilter" name="sat_ref" class="form-select filter-select"><option value="">All satellites</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="countryFilter">Country ISO</label>
<select id="countryFilter" name="country_iso" class="form-select filter-select"><option value="">All countries</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="genreFilter">Genre</label>
<select id="genreFilter" name="genre" class="form-select filter-select"><option value="">All genres</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="typeFilter">Type</label>
<select id="typeFilter" name="type" class="form-select filter-select">
<option value="">All types</option>
<option value="free">free</option>
<option value="payed">payed</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="resolutionFilter">Resolution</label>
<select id="resolutionFilter" name="resolution" class="form-select filter-select"><option value="">All resolutions</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="activeFilter">Active</label>
<select id="activeFilter" name="active" class="form-select">
<option value="">Default logic</option>
<option value="1">1</option>
<option value="0">0</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="manualFilter">Manual flag</label>
<select id="manualFilter" name="manually_edited" class="form-select">
<option value="">All</option>
<option value="1">Edited</option>
<option value="0">Not edited</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="idtypeFilter">Id type</label>
<select id="idtypeFilter" name="idtype" class="form-select">
<option value="">All</option>
<option value="1">Known (1)</option>
<option value="2">Unknown (2)</option>
</select>
</div>
</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="sixIdFilter">six_id</label>
<input id="sixIdFilter" name="six_id" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="sidFilter">sid</label>
<input id="sidFilter" name="sid" class="form-control" type="text" placeholder="Exact match">
</div>
</div>
<button class="btn btn-dark" type="submit">Apply filters</button>
</form>
</div>
</div>
</aside>
<div class="col-12 col-xl-9">
<div class="card panel-card mb-4">
<div class="card-body p-3 p-lg-4">
<div class="d-flex flex-column gap-3">
<div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-3">
<div>
<h2 class="section-title mb-1">Channels</h2>
<p class="text-muted small mb-0">Full dataset browser with strict editable fields and protected system metadata.</p>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" id="selectPageBtn">Select page</button>
<button class="btn btn-dark btn-sm" id="bulkEditBtn" data-bs-toggle="modal" data-bs-target="#bulkEditModal" disabled>Bulk edit (0)</button>
</div>
</div>
<div class="toolbar-strip">
<div class="toolbar-left">
<span class="legend legend-manual"><span class="legend-swatch"></span> Manually edited</span>
<span class="legend legend-selected"><span class="legend-swatch"></span> Selected row</span>
</div>
<div class="toolbar-right small text-muted">
<span id="resultCounter">Loading channels…</span>
</div>
</div>
<div id="activeFilterChips" class="chip-row"></div>
<div id="selectionBar" class="selection-bar d-none" aria-live="polite">
<div><strong id="selectionCount">0</strong> row(s) selected</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-dark" id="selectionBulkBtn" data-bs-toggle="modal" data-bs-target="#bulkEditModal">Bulk edit selected</button>
<button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Clear selection</button>
</div>
</div>
<ul class="nav nav-tabs section-tabs" id="workspaceTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#channels-panel" type="button" role="tab">Dataset</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#analytics-panel" type="button" role="tab">Analytics</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history-panel" type="button" role="tab">History</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#audit-panel" type="button" role="tab">Audit log</button></li>
</ul>
<div class="tab-content pt-3">
<div class="tab-pane fade show active" id="channels-panel" role="tabpanel">
<div class="table-responsive channel-table-wrap">
<table class="table align-middle channel-table mb-0">
<thead>
<tr>
<th class="sticky-col checkbox-col"><input class="form-check-input" type="checkbox" id="selectAllPageCheckbox" aria-label="Select all rows on current page"></th>
<th data-sort="upper_ch_ref">upper_ch_ref</th>
<th data-sort="country_iso">country_iso</th>
<th data-sort="country_client">country_client</th>
<th data-sort="sat_ref">sat_ref</th>
<th data-sort="sat_tpinfo">sat_tpinfo</th>
<th data-sort="genre">genre</th>
<th data-sort="type">type</th>
<th data-sort="resolution">resolution</th>
<th data-sort="langue">langue</th>
<th data-sort="region">region</th>
<th data-sort="groupe">groupe</th>
<th data-sort="active">active</th>
<th data-sort="idtype">idtype</th>
<th class="sys-col" data-sort="six_id">six_id</th>
<th class="sys-col" data-sort="sid">sid</th>
<th class="sys-col" data-sort="onid">onid</th>
<th class="sys-col" data-sort="tid">tid</th>
<th data-sort="sat_in">sat_in</th>
<th data-sort="sat_out">sat_out</th>
<th data-sort="sat_update">sat_update</th>
<th class="sys-col" data-sort="sat_ch_client_upper">sat_ch_client_upper</th>
<th class="sys-col" data-sort="sat_client">sat_client</th>
<th class="sys-col" data-sort="counting">counting</th>
<th class="sys-col" data-sort="lastview">lastview</th>
<th data-sort="manually_edited_at">manually_edited_at</th>
</tr>
</thead>
<tbody id="channelsTableBody">
<tr><td colspan="26" class="empty-state-cell">Loading dataset…</td></tr>
</tbody>
</table>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 pt-3">
<div class="small text-muted" id="pageMeta">Page 1 of 1</div>
<div class="pagination-controls d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" id="prevPageBtn">Previous</button>
<button class="btn btn-outline-secondary btn-sm" id="nextPageBtn">Next</button>
</div>
</div>
</div>
<div class="tab-pane fade" id="analytics-panel" role="tabpanel">
<div class="row g-3 mb-4" id="analyticsOverview"></div>
<div class="row g-3" id="analyticsBreakdowns"></div>
</div>
<div class="tab-pane fade" id="history-panel" role="tabpanel">
<div class="history-header d-flex flex-column flex-lg-row justify-content-between gap-3 mb-3">
<div>
<h3 class="section-title mb-1">Version history</h3>
<p class="small text-muted mb-0" id="historyLead">Click a row in Dataset to load its previous and current versions.</p>
</div>
<div class="d-flex gap-2 align-items-start align-items-lg-center">
<button class="btn btn-outline-secondary btn-sm" id="refreshHistoryBtn">Refresh history</button>
</div>
</div>
<div class="row g-3 mb-3" id="historySummary">
<div class="col-12"><div class="analytics-card"><div class="small text-muted">No channel selected yet.</div></div></div>
</div>
<div id="historyTimeline" class="history-stack">
<div class="analytics-card"><div class="small text-muted">History will appear here after you select a channel row.</div></div>
</div>
</div>
<div class="tab-pane fade" id="audit-panel" role="tabpanel">
<div class="row g-3 mb-3">
<div class="col-md-4"><label class="form-label" for="auditUserFilter">User</label><select id="auditUserFilter" class="form-select"><option value="">All users</option></select></div>
<div class="col-md-4"><label class="form-label" for="auditFieldFilter">Field</label><select id="auditFieldFilter" class="form-select"><option value="">All fields</option></select></div>
<div class="col-md-2"><label class="form-label" for="auditDateFrom">From</label><input id="auditDateFrom" class="form-control" type="date"></div>
<div class="col-md-2"><label class="form-label" for="auditDateTo">To</label><input id="auditDateTo" class="form-control" type="date"></div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="small text-muted mb-0">Audit entries show each field-level change with old new value.</p>
<button class="btn btn-outline-secondary btn-sm" id="applyAuditFiltersBtn">Refresh log</button>
</div>
<div class="table-responsive audit-table-wrap">
<table class="table align-middle audit-table mb-0">
<thead>
<tr>
<th>When</th>
<th>User</th>
<th>Channel</th>
<th>Field</th>
<th>Change</th>
</tr>
</thead>
<tbody id="auditTableBody">
<tr><td colspan="5" class="empty-state-cell">Load the audit trail…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<div class="offcanvas offcanvas-end detail-offcanvas" tabindex="-1" id="detailPanel" aria-labelledby="detailPanelLabel">
<div class="offcanvas-header border-bottom">
<div>
<h2 class="offcanvas-title h5 mb-1" id="detailPanelLabel">Channel detail</h2>
<p class="small text-muted mb-0">Editable metadata + locked system fields.</p>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<div class="offcanvas-body p-0">
<form id="detailForm" class="detail-form">
<div class="detail-loading px-4 py-5 text-muted" id="detailLoadingState">Select a row to inspect and update it.</div>
<input type="hidden" id="detailChannelId">
<div id="detailEditableSection" class="d-none">
<div class="detail-section px-4 py-4 border-bottom">
<div class="section-subtitle">Editable fields</div>
<div class="row g-3" id="editableFieldsContainer"></div>
</div>
<div class="detail-section px-4 py-4 border-bottom bg-light-subtle">
<div class="section-subtitle">Locked system fields</div>
<div class="row g-3" id="lockedFieldsContainer"></div>
</div>
<div class="detail-actions p-4 d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="offcanvas">Cancel</button>
<button type="submit" class="btn btn-dark">Save changes</button>
</div>
</div>
</form>
</div>
</div>
<div class="modal fade" id="bulkEditModal" tabindex="-1" aria-labelledby="bulkEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-bottom">
<div>
<h2 class="modal-title h5 mb-1" id="bulkEditModalLabel">Bulk edit selected rows</h2>
<p class="small text-muted mb-0">Only checked fields will be applied to the selected channels. This includes <code>sat_out</code>.</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="bulkEditForm">
<div class="modal-body">
<div class="alert alert-light border small mb-4">This applies changes row by row, closes the previous version with <code>date_out</code>, creates a new current row, and writes one audit entry per modified field, per channel.</div>
<div class="row g-3" id="bulkEditFieldsContainer"></div>
</div>
<div class="modal-footer border-top d-flex justify-content-between">
<div class="small text-muted"><span id="bulkSelectionSummary">0</span> row(s) currently selected</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-dark">Apply to selected rows</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
<script>
window.tvChannelApp = {
apiBase: 'api/channels.php',
auditApi: 'api/audit-log.php',
detailFields: {
editable: <?= json_encode(CHANNEL_EDITABLE_FIELDS, JSON_UNESCAPED_SLASHES) ?>,
locked: <?= json_encode(CHANNEL_LOCKED_FIELDS, JSON_UNESCAPED_SLASHES) ?>
},
currentUser: <?= json_encode(channel_current_user_id(), JSON_UNESCAPED_SLASHES) ?>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/js/main.js?v=<?= time() ?>"></script>
</body>
</html>