v1 unk
This commit is contained in:
parent
60132cc620
commit
8be8405504
20
api/audit-log.php
Normal file
20
api/audit-log.php
Normal 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
74
api/channels.php
Normal 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
819
app/channel_data.php
Normal 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()));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,39 +1,819 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const config = window.tvChannelApp || {};
|
||||
const state = {
|
||||
filters: { limit: 50, page: 1 },
|
||||
sort: { field: 'upper_ch_ref', direction: 'ASC' },
|
||||
selectedIds: new Set(),
|
||||
currentRows: [],
|
||||
currentDetail: null,
|
||||
options: {},
|
||||
lastListPayload: null,
|
||||
auditFilters: { page: 1, limit: 20 },
|
||||
analyticsLoaded: false,
|
||||
auditLoaded: false,
|
||||
historyLoaded: false,
|
||||
currentHistoryId: null,
|
||||
};
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
const elements = {
|
||||
filtersForm: document.getElementById('filtersForm'),
|
||||
resetFiltersBtn: document.getElementById('resetFiltersBtn'),
|
||||
channelsTableBody: document.getElementById('channelsTableBody'),
|
||||
resultCounter: document.getElementById('resultCounter'),
|
||||
pageMeta: document.getElementById('pageMeta'),
|
||||
prevPageBtn: document.getElementById('prevPageBtn'),
|
||||
nextPageBtn: document.getElementById('nextPageBtn'),
|
||||
selectPageBtn: document.getElementById('selectPageBtn'),
|
||||
selectAllPageCheckbox: document.getElementById('selectAllPageCheckbox'),
|
||||
bulkEditBtn: document.getElementById('bulkEditBtn'),
|
||||
selectionBar: document.getElementById('selectionBar'),
|
||||
selectionCount: document.getElementById('selectionCount'),
|
||||
selectionBulkBtn: document.getElementById('selectionBulkBtn'),
|
||||
clearSelectionBtn: document.getElementById('clearSelectionBtn'),
|
||||
activeFilterChips: document.getElementById('activeFilterChips'),
|
||||
detailPanel: document.getElementById('detailPanel'),
|
||||
detailForm: document.getElementById('detailForm'),
|
||||
detailLoadingState: document.getElementById('detailLoadingState'),
|
||||
detailChannelId: document.getElementById('detailChannelId'),
|
||||
detailEditableSection: document.getElementById('detailEditableSection'),
|
||||
editableFieldsContainer: document.getElementById('editableFieldsContainer'),
|
||||
lockedFieldsContainer: document.getElementById('lockedFieldsContainer'),
|
||||
bulkEditForm: document.getElementById('bulkEditForm'),
|
||||
bulkEditFieldsContainer: document.getElementById('bulkEditFieldsContainer'),
|
||||
bulkSelectionSummary: document.getElementById('bulkSelectionSummary'),
|
||||
analyticsOverview: document.getElementById('analyticsOverview'),
|
||||
analyticsBreakdowns: document.getElementById('analyticsBreakdowns'),
|
||||
historyLead: document.getElementById('historyLead'),
|
||||
historySummary: document.getElementById('historySummary'),
|
||||
historyTimeline: document.getElementById('historyTimeline'),
|
||||
refreshHistoryBtn: document.getElementById('refreshHistoryBtn'),
|
||||
auditUserFilter: document.getElementById('auditUserFilter'),
|
||||
auditFieldFilter: document.getElementById('auditFieldFilter'),
|
||||
auditDateFrom: document.getElementById('auditDateFrom'),
|
||||
auditDateTo: document.getElementById('auditDateTo'),
|
||||
applyAuditFiltersBtn: document.getElementById('applyAuditFiltersBtn'),
|
||||
auditTableBody: document.getElementById('auditTableBody'),
|
||||
heroTotalChannels: document.getElementById('heroTotalChannels'),
|
||||
heroActiveChannels: document.getElementById('heroActiveChannels'),
|
||||
heroManualChannels: document.getElementById('heroManualChannels'),
|
||||
heroTotalHits: document.getElementById('heroTotalHits'),
|
||||
toastContainer: document.getElementById('toastContainer'),
|
||||
};
|
||||
|
||||
const detailOffcanvas = elements.detailPanel ? bootstrap.Offcanvas.getOrCreateInstance(elements.detailPanel) : null;
|
||||
const bulkModal = document.getElementById('bulkEditModal') ? bootstrap.Modal.getOrCreateInstance(document.getElementById('bulkEditModal')) : null;
|
||||
|
||||
const labels = {
|
||||
upper_ch_ref: 'Upper channel ref',
|
||||
country_iso: 'Country ISO',
|
||||
sat_ref: 'Satellite ref',
|
||||
genre: 'Genre',
|
||||
type: 'Type',
|
||||
groupe: 'Group',
|
||||
region: 'Region',
|
||||
langue: 'Language',
|
||||
resolution: 'Resolution',
|
||||
active: 'Active',
|
||||
sat_in: 'Sat in',
|
||||
sat_out: 'Sat out',
|
||||
sat_update: 'Sat update',
|
||||
country_client: 'Country client',
|
||||
sat_tpinfo: 'Transponder',
|
||||
idtype: 'Id type',
|
||||
six_id: 'six_id',
|
||||
sid: 'sid',
|
||||
onid: 'onid',
|
||||
tid: 'tid',
|
||||
sat_ch_client_upper: 'Client channel',
|
||||
sat_client: 'Client bouquet',
|
||||
counting: 'Counting',
|
||||
lastview: 'Last view',
|
||||
manually_edited: 'Manual edit',
|
||||
manually_edited_at: 'Manual edit date'
|
||||
};
|
||||
|
||||
const editableFields = config.detailFields?.editable || [];
|
||||
const lockedFields = config.detailFields?.locked || [];
|
||||
const selectOptionsFields = ['sat_ref', 'country_iso', 'genre', 'resolution', 'langue', 'region', 'groupe'];
|
||||
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const notify = (message, tone = 'dark') => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'toast align-items-center text-bg-light border-0';
|
||||
wrapper.setAttribute('role', 'alert');
|
||||
wrapper.setAttribute('aria-live', 'assertive');
|
||||
wrapper.setAttribute('aria-atomic', 'true');
|
||||
wrapper.innerHTML = `
|
||||
<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
14
healthz.php
Normal 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
551
index.php
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user