|
<?php |
|
// YouTube Channel Banner Remixer - Single File PHP App |
|
// (c) 2025 - Hasan AlDoy @aldoyh - MIT License |
|
|
|
declare(strict_types=1); |
|
|
|
// --- CONFIGURATION & INITIALIZATION --- |
|
|
|
session_start(); |
|
error_reporting(E_ALL); |
|
ini_set('display_errors', 1); |
|
|
|
// --- IMPORTANT SECURITY NOTE --- |
|
// In a real-world app, use environment variables instead of hardcoding secrets. |
|
// We get these from docker-compose.yml for this setup. |
|
define('YOUTUBE_CLIENT_ID', getenv('YOUTUBE_CLIENT_ID') ?: ''); |
|
define('YOUTUBE_CLIENT_SECRET', getenv('YOUTUBE_CLIENT_SECRET') ?: ''); |
|
// This MUST be the exact URL to this script and be authorized in your Google Cloud project. |
|
define('OAUTH_REDIRECT_URI', 'http://localhost:8080/'); |
|
|
|
define('DB_PATH', __DIR__ . '/data/banners.sqlite3'); |
|
define('UPLOAD_DIR', __DIR__ . '/data/banners'); |
|
|
|
// Ensure data directories exist and are writable |
|
if (!is_dir(__DIR__ . '/data')) mkdir(__DIR__ . '/data', 0775); |
|
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0775); |
|
|
|
// --- BILINGUAL SUPPORT (EN/AR) --- |
|
|
|
$lang = 'en'; // Default language |
|
if (isset($_GET['lang']) && in_array($_GET['lang'], ['en', 'ar'])) { |
|
$_SESSION['lang'] = $_GET['lang']; |
|
$lang = $_GET['lang']; |
|
} elseif (isset($_SESSION['lang'])) { |
|
$lang = $_SESSION['lang']; |
|
} |
|
|
|
$translations = [ |
|
'en' => [ |
|
'title' => 'YouTube Banner Remixer', |
|
'tagline' => 'Remix your YouTube channel banner into a customized variation.', |
|
'lang_en' => 'English', |
|
'lang_ar' => 'Arabic', |
|
'step1_title' => 'Step 1: Authenticate with YouTube', |
|
'step1_text' => 'You need to grant permission to manage your channel\'s branding settings.', |
|
'connect_btn' => 'Connect with YouTube', |
|
'disconnect_btn' => 'Disconnect', |
|
'step2_title' => 'Step 2: Choose Your Banner Source', |
|
'upload_option' => 'Upload a new banner', |
|
'upload_btn' => 'Start Remix', |
|
'step3_title' => 'Step 3: Remix Your Banner', |
|
'overlay_text' => 'Overlay Text:', |
|
'text_color' => 'Text Color:', |
|
'font_size' => 'Font Size:', |
|
'position_x' => 'Position X:', |
|
'position_y' => 'Position Y:', |
|
'apply_changes' => 'Apply Changes & Upload to YouTube', |
|
'banner_history' => 'Previous Banners', |
|
'no_history' => 'No banners have been created yet.', |
|
'created_at' => 'Created At', |
|
'auth_success' => 'Authentication successful! You can now choose a banner.', |
|
'auth_error' => 'Authentication failed. Please try again.', |
|
'upload_error_file' => 'Please select a file to upload.', |
|
'upload_error_type' => 'Invalid file type. Please upload a JPG, PNG, or GIF.', |
|
'remix_success' => 'Banner successfully remixed and uploaded to YouTube!', |
|
'remix_fail' => 'Failed to upload banner to YouTube. Response:', |
|
'config_warning' => 'Warning: YouTube API credentials are not set in docker-compose.yml. Authentication will fail.', |
|
'status_connected' => 'Status: Connected as', |
|
'status_not_connected' => 'Status: Not Connected', |
|
], |
|
'ar' => [ |
|
'title' => 'مُعدِّل غلاف قناة يوتيوب', |
|
'tagline' => 'قم بتعديل غلاف قناتك على يوتيوب إلى نسخة مخصصة.', |
|
'lang_en' => 'الإنجليزية', |
|
'lang_ar' => 'العربية', |
|
'step1_title' => 'الخطوة 1: المصادقة مع يوتيوب', |
|
'step1_text' => 'تحتاج إلى منح الإذن لإدارة إعدادات العلامة التجارية لقناتك.', |
|
'connect_btn' => 'الاتصال بيوتيوب', |
|
'disconnect_btn' => 'قطع الاتصال', |
|
'step2_title' => 'الخطوة 2: اختر مصدر الغلاف', |
|
'upload_option' => 'رفع غلاف جديد', |
|
'upload_btn' => 'بدء التعديل', |
|
'step3_title' => 'الخطوة 3: تعديل الغلاف', |
|
'overlay_text' => 'النص العلوي:', |
|
'text_color' => 'لون النص:', |
|
'font_size' => 'حجم الخط:', |
|
'position_x' => 'الموضع الأفقي:', |
|
'position_y' => 'الموضع الرأسي:', |
|
'apply_changes' => 'تطبيق التغييرات والرفع إلى يوتيوب', |
|
'banner_history' => 'الأغلفة السابقة', |
|
'no_history' => 'لم يتم إنشاء أي أغلفة بعد.', |
|
'created_at' => 'أنشئ في', |
|
'auth_success' => 'تمت المصادقة بنجاح! يمكنك الآن اختيار غلاف.', |
|
'auth_error' => 'فشلت المصادقة. يرجى المحاولة مرة أخرى.', |
|
'upload_error_file' => 'يرجى تحديد ملف لرفعه.', |
|
'upload_error_type' => 'نوع الملف غير صالح. يرجى رفع ملف JPG أو PNG أو GIF.', |
|
'remix_success' => 'تم تعديل الغلاف ورفعه إلى يوتيوب بنجاح!', |
|
'remix_fail' => 'فشل رفع الغلاف إلى يوتيوب. الاستجابة:', |
|
'config_warning' => 'تحذير: بيانات اعتماد واجهة برمجة تطبيقات يوتيوب غير معينة في docker-compose.yml. ستفشل المصادقة.', |
|
'status_connected' => 'الحالة: متصل كـ', |
|
'status_not_connected' => 'الحالة: غير متصل', |
|
] |
|
]; |
|
|
|
/** |
|
* Translation helper function |
|
* @param string $key |
|
* @return string |
|
*/ |
|
function t(string $key): string { |
|
global $lang, $translations; |
|
return $translations[$lang][$key] ?? $key; |
|
} |
|
|
|
// --- DATABASE SETUP --- |
|
|
|
/** |
|
* Initializes the SQLite database and table. |
|
* @return PDO |
|
*/ |
|
function get_db(): PDO { |
|
$db = new PDO('sqlite:' . DB_PATH); |
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); |
|
$db->exec("CREATE TABLE IF NOT EXISTS banners ( |
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
image_path TEXT NOT NULL, |
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP |
|
)"); |
|
return $db; |
|
} |
|
|
|
/** |
|
* Fetches all banner records from the database. |
|
* @return array |
|
*/ |
|
function get_banner_history(): array { |
|
$db = get_db(); |
|
$stmt = $db->query("SELECT * FROM banners ORDER BY created_at DESC"); |
|
return $stmt->fetchAll(PDO::FETCH_ASSOC); |
|
} |
|
|
|
|
|
// --- CONTROLLER LOGIC --- |
|
|
|
$action = $_GET['action'] ?? 'home'; |
|
$message = $_SESSION['message'] ?? null; |
|
unset($_SESSION['message']); |
|
|
|
// Handle Logout |
|
if ($action === 'logout') { |
|
unset($_SESSION['youtube_access_token']); |
|
unset($_SESSION['user_info']); |
|
header('Location: ' . OAUTH_REDIRECT_URI); |
|
exit; |
|
} |
|
|
|
|
|
// Handle OAuth2 Callback |
|
if (isset($_GET['code']) && $action === 'home') { |
|
if (YOUTUBE_CLIENT_ID && YOUTUBE_CLIENT_SECRET) { |
|
try { |
|
// Exchange authorization code for an access token |
|
$token_url = 'https://oauth2.googleapis.com/token'; |
|
$post_data = [ |
|
'code' => $_GET['code'], |
|
'client_id' => YOUTUBE_CLIENT_ID, |
|
'client_secret' => YOUTUBE_CLIENT_SECRET, |
|
'redirect_uri' => OAUTH_REDIRECT_URI, |
|
'grant_type' => 'authorization_code' |
|
]; |
|
|
|
$ch = curl_init(); |
|
curl_setopt($ch, CURLOPT_URL, $token_url); |
|
curl_setopt($ch, CURLOPT_POST, true); |
|
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data)); |
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); |
|
$response = curl_exec($ch); |
|
curl_close($ch); |
|
|
|
$token_data = json_decode($response, true); |
|
if (isset($token_data['access_token'])) { |
|
$_SESSION['youtube_access_token'] = $token_data['access_token']; |
|
|
|
// Fetch user info |
|
$user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo'; |
|
$ch = curl_init(); |
|
curl_setopt($ch, CURLOPT_URL, $user_info_url); |
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $token_data['access_token']]); |
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); |
|
$user_response = curl_exec($ch); |
|
curl_close($ch); |
|
$_SESSION['user_info'] = json_decode($user_response, true); |
|
|
|
$_SESSION['message'] = ['type' => 'success', 'text' => t('auth_success')]; |
|
} else { |
|
$_SESSION['message'] = ['type' => 'error', 'text' => t('auth_error') . ' ' . ($token_data['error_description'] ?? '')]; |
|
} |
|
} catch (Exception $e) { |
|
$_SESSION['message'] = ['type' => 'error', 'text' => t('auth_error') . ' ' . $e->getMessage()]; |
|
} |
|
} |
|
header('Location: ' . OAUTH_REDIRECT_URI); |
|
exit; |
|
} |
|
|
|
// Handle Image Remix and Upload |
|
if ($action === 'remix' && $_SERVER['REQUEST_METHOD'] === 'POST') { |
|
if (!isset($_SESSION['youtube_access_token'])) { |
|
die('Authentication required.'); |
|
} |
|
|
|
$source_image_path = ''; |
|
|
|
// Check if a file was uploaded |
|
if (isset($_FILES['banner_image']) && $_FILES['banner_image']['error'] === UPLOAD_ERR_OK) { |
|
$file = $_FILES['banner_image']; |
|
$allowed_types = ['image/jpeg', 'image/png', 'image/gif']; |
|
if (!in_array($file['type'], $allowed_types)) { |
|
$_SESSION['message'] = ['type' => 'error', 'text' => t('upload_error_type')]; |
|
header('Location: ' . OAUTH_REDIRECT_URI); |
|
exit; |
|
} |
|
$source_image_path = $file['tmp_name']; |
|
} else { |
|
$_SESSION['message'] = ['type' => 'error', 'text' => t('upload_error_file')]; |
|
header('Location: ' . OAUTH_REDIRECT_URI); |
|
exit; |
|
} |
|
|
|
// --- Image Processing with GD --- |
|
$image_type = exif_imagetype($source_image_path); |
|
$image = null; |
|
switch ($image_type) { |
|
case IMAGETYPE_JPEG: $image = imagecreatefromjpeg($source_image_path); break; |
|
case IMAGETYPE_PNG: $image = imagecreatefrompng($source_image_path); break; |
|
case IMAGETYPE_GIF: $image = imagecreatefromgif($source_image_path); break; |
|
} |
|
imagesavealpha($image, true); |
|
imagealphablending($image, true); |
|
|
|
|
|
// Get form data |
|
$text = $_POST['overlay_text'] ?? 'Hello YouTube!'; |
|
$color_hex = $_POST['text_color'] ?? '#FFFFFF'; |
|
sscanf($color_hex, "#%02x%02x%02x", $r, $g, $b); |
|
$text_color = imagecolorallocate($image, $r, $g, $b); |
|
$font_size = (int)($_POST['font_size'] ?? 50); |
|
$pos_x = (int)($_POST['pos_x'] ?? 100); |
|
$pos_y = (int)($_POST['pos_y'] ?? 200); |
|
|
|
// Use a built-in font for simplicity (5 is a good default). |
|
// For custom fonts: imagettftext() and a .ttf file are needed. |
|
$font = 5; |
|
$text_width = imagefontwidth($font) * strlen($text); |
|
$text_height = imagefontheight($font); |
|
|
|
imagestring($image, $font, $pos_x, $pos_y - $text_height, $text, $text_color); |
|
|
|
// Save the final image |
|
$new_filename = 'banner_' . time() . '.png'; |
|
$final_image_path = UPLOAD_DIR . '/' . $new_filename; |
|
imagepng($image, $final_image_path); |
|
imagedestroy($image); |
|
|
|
// --- Upload to YouTube --- |
|
// This is a complex API call. YouTube API requires multipart/related upload. |
|
// For simplicity in a single file, we will use a raw cURL call. |
|
$channel_id_url = 'https://www.googleapis.com/youtube/v3/channels?part=id&mine=true'; |
|
$ch = curl_init(); |
|
curl_setopt($ch, CURLOPT_URL, $channel_id_url); |
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $_SESSION['youtube_access_token']]); |
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); |
|
$channel_response = json_decode(curl_exec($ch), true); |
|
curl_close($ch); |
|
|
|
if (!empty($channel_response['items'][0]['id'])) { |
|
$channel_id = $channel_response['items'][0]['id']; |
|
|
|
$upload_url = "https://www.googleapis.com/upload/youtube/v3/channelBanners/insert?channelId={$channel_id}"; |
|
|
|
$image_data = file_get_contents($final_image_path); |
|
|
|
$ch = curl_init(); |
|
curl_setopt($ch, CURLOPT_URL, $upload_url); |
|
curl_setopt($ch, CURLOPT_POST, true); |
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $image_data); |
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); |
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [ |
|
'Authorization: Bearer ' . $_SESSION['youtube_access_token'], |
|
'Content-Type: image/png', |
|
'Content-Length: ' . strlen($image_data) |
|
]); |
|
|
|
$upload_response_json = curl_exec($ch); |
|
curl_close($ch); |
|
$upload_response = json_decode($upload_response_json, true); |
|
|
|
if (isset($upload_response['url'])) { |
|
$_SESSION['message'] = ['type' => 'success', 'text' => t('remix_success')]; |
|
// Save to local DB on success |
|
$db = get_db(); |
|
$stmt = $db->prepare("INSERT INTO banners (image_path) VALUES (?)"); |
|
$stmt->execute(['banners/' . $new_filename]); |
|
} else { |
|
$_SESSION['message'] = ['type' => 'error', 'text' => t('remix_fail') . ' ' . $upload_response_json]; |
|
} |
|
} else { |
|
$_SESSION['message'] = ['type' => 'error', 'text' => 'Could not fetch your Channel ID.']; |
|
} |
|
|
|
header('Location: ' . OAUTH_REDIRECT_URI); |
|
exit; |
|
} |
|
|
|
|
|
// --- VIEW (HTML/CSS/JS) --- |
|
?> |
|
<!DOCTYPE html> |
|
<html lang="<?= $lang ?>" dir="<?= $lang === 'ar' ? 'rtl' : 'ltr' ?>"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title><?= t('title') ?></title> |
|
<style> |
|
:root { |
|
--primary-color: #FF0000; |
|
--secondary-color: #282828; |
|
--background-color: #181818; |
|
--card-background: #212121; |
|
--text-color: #FFFFFF; |
|
--border-color: #444; |
|
--success-color: #4CAF50; |
|
--error-color: #F44336; |
|
} |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
|
background-color: var(--background-color); |
|
color: var(--text-color); |
|
margin: 0; |
|
padding: 20px; |
|
line-height: 1.6; |
|
} |
|
.container { max-width: 900px; margin: auto; } |
|
.card { background-color: var(--card-background); border: 1px solid var(--border-color); border-radius: 12px; padding: 25px; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); } |
|
h1, h2 { border-bottom: 2px solid var(--primary-color); padding-bottom: 10px; margin-top: 0; } |
|
h1 { font-size: 2.2em; } |
|
h2 { font-size: 1.8em; } |
|
.header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; } |
|
.lang-switcher a { color: var(--text-color); text-decoration: none; padding: 5px 10px; border-radius: 6px; } |
|
.lang-switcher a.active { background-color: var(--primary-color); font-weight: bold; } |
|
.btn { display: inline-block; background-color: var(--primary-color); color: var(--text-color); padding: 12px 25px; border-radius: 8px; text-decoration: none; font-weight: bold; border: none; cursor: pointer; transition: background-color 0.3s ease; } |
|
.btn:hover { background-color: #c00; } |
|
.btn-disconnect { background-color: #555; } |
|
.btn-disconnect:hover { background-color: #444; } |
|
.message { padding: 15px; margin-bottom: 20px; border-radius: 8px; font-weight: bold; } |
|
.message.success { background-color: var(--success-color); color: white; } |
|
.message.error { background-color: var(--error-color); color: white; } |
|
.form-group { margin-bottom: 15px; } |
|
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; } |
|
.form-group input, .form-group select { width: 100%; padding: 10px; border-radius: 6px; border: 1px solid var(--border-color); background-color: var(--secondary-color); color: var(--text-color); box-sizing: border-box; } |
|
.history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; } |
|
.history-item img { max-width: 100%; border-radius: 8px; border: 1px solid var(--border-color); } |
|
.history-item p { text-align: center; font-size: 0.9em; margin-top: 5px; } |
|
.status-bar { padding: 10px; border-radius: 8px; background-color: var(--secondary-color); border: 1px solid var(--border-color); margin-bottom: 20px; font-weight: bold; } |
|
.status-bar.connected { border-left: 5px solid var(--success-color); } |
|
.status-bar.not-connected { border-left: 5px solid var(--error-color); } |
|
.warning { background-color: #ffc107; color: #333; padding: 15px; border-radius: 8px; margin-bottom: 20px; } |
|
[dir="rtl"] .status-bar { border-left: none; border-right: 5px solid var(--error-color); } |
|
[dir="rtl"] .status-bar.connected { border-right-color: var(--success-color); } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<div class="header"> |
|
<h1><?= t('title') ?></h1> |
|
<div class="lang-switcher"> |
|
<a href="?lang=en" class="<?= $lang === 'en' ? 'active' : '' ?>"><?= t('lang_en') ?></a> |
|
<a href="?lang=ar" class="<?= $lang === 'ar' ? 'active' : '' ?>"><?= t('lang_ar') ?></a> |
|
</div> |
|
</div> |
|
<p><?= t('tagline') ?></p> |
|
|
|
<?php if (!YOUTUBE_CLIENT_ID || !YOUTUBE_CLIENT_SECRET): ?> |
|
<div class="warning"><?= t('config_warning') ?></div> |
|
<?php endif; ?> |
|
|
|
<?php if ($message): ?> |
|
<div class="message <?= htmlspecialchars($message['type']) ?>"><?= htmlspecialchars($message['text']) ?></div> |
|
<?php endif; ?> |
|
|
|
<?php $is_connected = isset($_SESSION['youtube_access_token']); ?> |
|
<div class="status-bar <?= $is_connected ? 'connected' : 'not-connected' ?>"> |
|
<?php if ($is_connected && isset($_SESSION['user_info']['name'])): ?> |
|
<?= t('status_connected') ?> <?= htmlspecialchars($_SESSION['user_info']['name'] . ' (' . $_SESSION['user_info']['email'] . ')') ?> |
|
<?php else: ?> |
|
<?= t('status_not_connected') ?> |
|
<?php endif; ?> |
|
</div> |
|
|
|
|
|
<!-- Step 1: Authentication --> |
|
<div class="card"> |
|
<h2><?= t('step1_title') ?></h2> |
|
<p><?= t('step1_text') ?></p> |
|
<?php if (!$is_connected): |
|
$auth_url = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([ |
|
'client_id' => YOUTUBE_CLIENT_ID, |
|
'redirect_uri' => OAUTH_REDIRECT_URI, |
|
'response_type' => 'code', |
|
'scope' => 'https://www.googleapis.com/auth/youtube.force-ssl https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email', |
|
'access_type' => 'offline' |
|
]); |
|
?> |
|
<a href="<?= $auth_url ?>" class="btn"><?= t('connect_btn') ?></a> |
|
<?php else: ?> |
|
<a href="?action=logout" class="btn btn-disconnect"><?= t('disconnect_btn') ?></a> |
|
<?php endif; ?> |
|
</div> |
|
|
|
<?php if ($is_connected): ?> |
|
<!-- Step 2 & 3: Upload and Remix --> |
|
<div class="card"> |
|
<form action="?action=remix" method="POST" enctype="multipart/form-data"> |
|
<h2><?= t('step2_title') ?></h2> |
|
<div class="form-group"> |
|
<label for="banner_image"><?= t('upload_option') ?> (Recommended: 2560x1440px)</label> |
|
<input type="file" id="banner_image" name="banner_image" required accept="image/png, image/jpeg, image/gif"> |
|
</div> |
|
|
|
<hr style="border-color: var(--border-color); margin: 20px 0;"> |
|
|
|
<h2><?= t('step3_title') ?></h2> |
|
<div class="form-group"> |
|
<label for="overlay_text"><?= t('overlay_text') ?></label> |
|
<input type="text" id="overlay_text" name="overlay_text" value="Special Event!"> |
|
</div> |
|
<div class="form-group"> |
|
<label for="text_color"><?= t('text_color') ?></label> |
|
<input type="color" id="text_color" name="text_color" value="#FFFFFF"> |
|
</div> |
|
<div class="form-group"> |
|
<label for="font_size"><?= t('font_size') ?></label> |
|
<input type="range" id="font_size" name="font_size" min="10" max="200" value="50"> |
|
</div> |
|
<div class="form-group"> |
|
<label for="pos_x"><?= t('position_x') ?></label> |
|
<input type="number" id="pos_x" name="pos_x" value="100"> |
|
</div> |
|
<div class="form-group"> |
|
<label for="pos_y"><?= t('position_y') ?></label> |
|
<input type="number" id="pos_y" name="pos_y" value="200"> |
|
</div> |
|
|
|
<button type="submit" class="btn"><?= t('apply_changes') ?></button> |
|
</form> |
|
</div> |
|
<?php endif; ?> |
|
|
|
<!-- Banner History --> |
|
<div class="card"> |
|
<h2><?= t('banner_history') ?></h2> |
|
<?php |
|
$history = get_banner_history(); |
|
if (empty($history)): |
|
?> |
|
<p><?= t('no_history') ?></p> |
|
<?php else: ?> |
|
<div class="history-grid"> |
|
<?php foreach ($history as $banner): ?> |
|
<div class="history-item"> |
|
<img src="<?= htmlspecialchars($banner['image_path']) ?>" alt="Banner"> |
|
<p><?= t('created_at') ?>: <?= htmlspecialchars($banner['created_at']) ?></p> |
|
</div> |
|
<?php endforeach; ?> |
|
</div> |
|
<?php endif; ?> |
|
</div> |
|
</div> |
|
</body> |
|
</html> |