Commit 38add483 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(org-announcements): publishing reaches every member, once

Image uploads never worked (validateFile's null success was read as failure,
generatePath got three arguments, the returned URL was treated as a flag);
expiry was never saved; an Arabic-only announcement could not be stored
(title and content are NOT NULL); the list linked to an unpublish route that
did not exist.

Now: Arabic title and text are what is required, expiry is entered in Cairo
time, images go to org-media (JPEG/PNG/WebP up to 5 MB, removed from storage
when replaced, removed or the announcement is deleted). Publishing sets
published_at and sends one org_announcement notification to every active
member, in chunks of 500, with org_id and announcement_id in data; it is
logged in org_activity_log. Republishing after an unpublish never notifies
twice. "Publish now" on create needs the same permission as publish.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent af3c054a
...@@ -479,5 +479,6 @@ return [ ...@@ -479,5 +479,6 @@ return [
'organizations/{id}/announcements/{annId}/edit' => ['module' => 'org-announcements', 'action' => 'edit'], 'organizations/{id}/announcements/{annId}/edit' => ['module' => 'org-announcements', 'action' => 'edit'],
'organizations/{id}/announcements/{annId}/update' => ['module' => 'org-announcements', 'action' => 'update'], 'organizations/{id}/announcements/{annId}/update' => ['module' => 'org-announcements', 'action' => 'update'],
'organizations/{id}/announcements/{annId}/publish' => ['module' => 'org-announcements', 'action' => 'publish'], 'organizations/{id}/announcements/{annId}/publish' => ['module' => 'org-announcements', 'action' => 'publish'],
'organizations/{id}/announcements/{annId}/unpublish' => ['module' => 'org-announcements', 'action' => 'unpublish'],
'organizations/{id}/announcements/{annId}/delete' => ['module' => 'org-announcements', 'action' => 'delete'], 'organizations/{id}/announcements/{annId}/delete' => ['module' => 'org-announcements', 'action' => 'delete'],
]; ];
<?php <?php
/**
* Org announcements to members (org_announcements). Drafts are private;
* publishing makes the announcement visible on the org page in the game and
* sends one bell notification to every active member, once.
*/
class OrgAnnouncementsController class OrgAnnouncementsController
{ {
private Database $db; private Database $db;
private SupabaseStorage $storage; private SupabaseStorage $storage;
public const TYPES = ['general' => 'عام', 'important' => 'مهم', 'event' => 'فعالية', 'tournament' => 'بطولة', 'maintenance' => 'صيانة'];
private const IMAGE_BUCKET = 'org-media';
private const IMAGE_MAX = 5 * 1024 * 1024;
public function __construct() public function __construct()
{ {
$this->db = Database::getInstance(); $this->db = Database::getInstance();
$this->storage = SupabaseStorage::getInstance(); $this->storage = SupabaseStorage::getInstance();
} }
public function list(array $params, string $method): void private function org(string $id): array
{ {
$orgId = $params['id']; $org = preg_match('/^[0-9a-f-]{36}$/i', $id) ? $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]) : null;
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) { if (!$org) {
http_response_code(404); http_response_code(404);
View::render('errors/404'); View::render('errors/404');
return; exit;
} }
return $org;
}
$filters = [ private function announcement(array $org, string $annId): ?array
'select' => '*', {
'org_id' => "eq.{$orgId}", if (!preg_match('/^[0-9a-f-]{36}$/i', $annId)) return null;
'order' => 'priority.desc,is_pinned.desc,created_at.desc', return $this->db->selectOne('org_announcements', ['id' => "eq.{$annId}", 'org_id' => "eq.{$org['id']}"]);
]; }
if (!empty($_GET['type'])) {
$filters['type'] = "eq.{$_GET['type']}";
}
if (isset($_GET['is_draft']) && $_GET['is_draft'] !== '') { private static function orgName(array $org): string
$filters['is_draft'] = "eq.{$_GET['is_draft']}"; {
} return (($org['name_ar'] ?? '') ?: $org['name']);
}
$total = $this->db->count('org_announcements', $filters); public function list(array $params, string $method): void
{
$org = $this->org($params['id']);
$filters = ['select' => '*', 'org_id' => "eq.{$org['id']}", 'order' => 'is_pinned.desc,priority.desc,created_at.desc'];
if (isset(self::TYPES[$_GET['type'] ?? ''])) $filters['type'] = "eq.{$_GET['type']}";
if (in_array($_GET['is_draft'] ?? '', ['true', 'false'], true)) $filters['is_draft'] = "eq.{$_GET['is_draft']}";
$countFilters = $filters;
unset($countFilters['select'], $countFilters['order']);
$total = $this->db->count('org_announcements', $countFilters);
$pagination = Pagination::fromRequest($total); $pagination = Pagination::fromRequest($total);
$filters['offset'] = $pagination->offset; $filters['offset'] = $pagination->offset;
$filters['limit'] = $pagination->perPage; $filters['limit'] = $pagination->perPage;
$announcements = $this->db->select('org_announcements', $filters); $announcements = $this->db->select('org_announcements', $filters);
$activeMembers = $this->db->count('org_members', ['org_id' => "eq.{$org['id']}", 'status' => 'eq.active']);
$pageTitle = 'الإعلانات - ' . ($org['name_ar'] ?? $org['name']); $pageTitle = 'الإعلانات - ' . self::orgName($org);
View::render('org-announcements/list', compact('org', 'announcements', 'pagination', 'activeMembers', 'pageTitle'));
View::render('org-announcements/list', compact('org', 'announcements', 'pagination', 'pageTitle'));
} }
public function create(array $params, string $method): void public function create(array $params, string $method): void
{ {
$orgId = $params['id']; $org = $this->org($params['id']);
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]); $pageTitle = 'إنشاء إعلان - ' . self::orgName($org);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'إنشاء إعلان - ' . ($org['name_ar'] ?? $org['name']);
View::render('org-announcements/form', compact('org', 'pageTitle')); View::render('org-announcements/form', compact('org', 'pageTitle'));
} }
public function store(array $params, string $method): void public function store(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$org = $this->org($params['id']);
$back = "/organizations/{$org['id']}/announcements/create";
$orgId = $params['id']; $data = $this->fromForm();
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]); if (is_string($data)) {
Response::error($data, $back);
if (!$org) {
Response::error('المنظمة غير موجودة', '/organizations');
return; return;
} }
$image = $this->uploadImage($org['id']);
$user = Auth::user(); if (is_string($image) && !str_starts_with($image, 'http')) {
$imageUrl = null; Response::error($image, $back);
return;
// Handle image upload if provided
if (!empty($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['image'];
$validation = $this->storage->validateFile($file, ['image/*'], 10 * 1024 * 1024);
if ($validation !== true) {
Response::error($validation, "/organizations/{$orgId}/announcements/create");
return;
}
$path = $this->storage->generatePath('org-media', $orgId, $file['name']);
$uploadResult = $this->storage->upload('org-media', $path, $file['tmp_name'], $file['type']);
if ($uploadResult) {
$imageUrl = $this->storage->getPublicUrl('org-media', $path);
}
} }
$data = [ $data += ['org_id' => $org['id'], 'author_id' => Auth::user()['id'] ?? null, 'is_draft' => true, 'image_url' => $image];
'org_id' => $orgId, $row = $this->db->insert('org_announcements', $data);
'title' => trim($_POST['title'] ?? ''), if (!$row) {
'title_ar' => trim($_POST['title_ar'] ?? ''), $this->dropImage($image);
'content' => trim($_POST['content'] ?? ''), Response::error('لم يُحفظ الإعلان: ' . $this->db->lastErrorMessage(), $back);
'content_ar' => trim($_POST['content_ar'] ?? ''), return;
'type' => $_POST['type'] ?? 'general', }
'priority' => !empty($_POST['priority']) ? (int) $_POST['priority'] : 0, AuditLog::log('create', 'org_announcement', $row['id'] ?? null, null, $data);
'is_pinned' => isset($_POST['is_pinned']) ? true : false,
'image_url' => $imageUrl,
'author_id' => $user['id'],
'is_draft' => true,
];
$result = $this->db->insert('org_announcements', $data); // Publishing needs the update permission (org_manager may only draft).
AuditLog::log('create', 'org_announcement', $result['id'] ?? null, null, $data); if (!empty($_POST['publish_now']) && Auth::hasPermission('org-announcements.update')) {
Response::success('تم إنشاء الإعلان بنجاح', "/organizations/{$orgId}/announcements"); $this->doPublish($org, $row);
return;
}
Response::success('حُفظ الإعلان كمسودة. انشره ليظهر للأعضاء ويصلهم إشعار.', "/organizations/{$org['id']}/announcements");
} }
public function edit(array $params, string $method): void public function edit(array $params, string $method): void
{ {
$orgId = $params['id']; $org = $this->org($params['id']);
$annId = $params['annId']; $announcement = $this->announcement($org, $params['annId'] ?? '');
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$announcement = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
if (!$announcement) { if (!$announcement) {
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements"); Response::error('الإعلان غير موجود', "/organizations/{$org['id']}/announcements");
return; return;
} }
$pageTitle = 'تعديل الإعلان - ' . (($announcement['title_ar'] ?? '') ?: $announcement['title']);
$pageTitle = 'تعديل الإعلان - ' . ($announcement['title_ar'] ?? $announcement['title']);
View::render('org-announcements/form', compact('org', 'announcement', 'pageTitle')); View::render('org-announcements/form', compact('org', 'announcement', 'pageTitle'));
} }
public function update(array $params, string $method): void public function update(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$org = $this->org($params['id']);
$orgId = $params['id']; $old = $this->announcement($org, $params['annId'] ?? '');
$annId = $params['annId'];
$old = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
if (!$old) { if (!$old) {
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements"); Response::error('الإعلان غير موجود', "/organizations/{$org['id']}/announcements");
return; return;
} }
$back = "/organizations/{$org['id']}/announcements/{$old['id']}/edit";
$data = [ $data = $this->fromForm();
'title' => trim($_POST['title'] ?? ''), if (is_string($data)) {
'title_ar' => trim($_POST['title_ar'] ?? ''), Response::error($data, $back);
'content' => trim($_POST['content'] ?? ''), return;
'content_ar' => trim($_POST['content_ar'] ?? ''),
'type' => $_POST['type'] ?? 'general',
'priority' => !empty($_POST['priority']) ? (int) $_POST['priority'] : 0,
'is_pinned' => isset($_POST['is_pinned']) ? true : false,
];
// Handle image upload if new one provided
if (!empty($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['image'];
$validation = $this->storage->validateFile($file, ['image/*'], 10 * 1024 * 1024);
if ($validation === true) {
$path = $this->storage->generatePath('org-media', $orgId, $file['name']);
$uploadResult = $this->storage->upload('org-media', $path, $file['tmp_name'], $file['type']);
if ($uploadResult) {
$data['image_url'] = $this->storage->getPublicUrl('org-media', $path);
}
}
} }
$image = $this->uploadImage($org['id']);
if (is_string($image) && !str_starts_with($image, 'http')) {
Response::error($image, $back);
return;
}
if ($image !== null) $data['image_url'] = $image;
elseif (!empty($_POST['remove_image'])) $data['image_url'] = null;
$data['updated_at'] = date('c');
$this->db->update('org_announcements', ['id' => "eq.{$annId}"], $data); if ($this->db->update('org_announcements', ['id' => "eq.{$old['id']}"], $data) === null) {
AuditLog::log('update', 'org_announcement', $annId, $old, $data); $this->dropImage($image);
Response::success('تم تحديث الإعلان بنجاح', "/organizations/{$orgId}/announcements"); Response::error('لم يُحفظ: ' . $this->db->lastErrorMessage(), $back);
return;
}
if (array_key_exists('image_url', $data)) $this->dropImage($old['image_url'] ?? null);
AuditLog::log('update', 'org_announcement', $old['id'], $old, $data);
Response::success(empty($old['is_draft'])
? 'حُدّث الإعلان المنشور (لا يُرسل إشعار جديد)'
: 'حُدّثت المسودة', "/organizations/{$org['id']}/announcements");
} }
public function publish(array $params, string $method): void public function publish(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$org = $this->org($params['id']);
$orgId = $params['id']; $ann = $this->announcement($org, $params['annId'] ?? '');
$annId = $params['annId']; if (!$ann) {
Response::error('الإعلان غير موجود', "/organizations/{$org['id']}/announcements");
$announcement = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
if (!$announcement) {
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements");
return; return;
} }
if (empty($ann['is_draft'])) {
Response::error('الإعلان منشور بالفعل', "/organizations/{$org['id']}/announcements");
return;
}
$this->doPublish($org, $ann);
}
$data = [ /** Back to draft: hidden from the org page. Notifications already sent stay in bells. */
'is_draft' => false, public function unpublish(array $params, string $method): void
'published_at' => date('c'), {
]; Auth::requireCsrf();
$org = $this->org($params['id']);
$this->db->update('org_announcements', ['id' => "eq.{$annId}"], $data); $ann = $this->announcement($org, $params['annId'] ?? '');
AuditLog::log('publish', 'org_announcement', $annId, $announcement, $data); $back = "/organizations/{$org['id']}/announcements";
Response::success('تم نشر الإعلان بنجاح', "/organizations/{$orgId}/announcements"); if (!$ann || !empty($ann['is_draft'])) {
Response::error(!$ann ? 'الإعلان غير موجود' : 'الإعلان مسودة بالفعل', $back);
return;
}
if ($this->db->update('org_announcements', ['id' => "eq.{$ann['id']}"], ['is_draft' => true, 'updated_at' => date('c')]) === null) {
Response::error('لم يتم: ' . $this->db->lastErrorMessage(), $back);
return;
}
AuditLog::log('unpublish', 'org_announcement', $ann['id'], ['is_draft' => false], ['is_draft' => true]);
Response::success('أُخفي الإعلان عن الأعضاء (عاد مسودة)', $back);
} }
public function delete(array $params, string $method): void public function delete(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$org = $this->org($params['id']);
$ann = $this->announcement($org, $params['annId'] ?? '');
$back = "/organizations/{$org['id']}/announcements";
if (!$ann) {
Response::error('الإعلان غير موجود', $back);
return;
}
if (!$this->db->delete('org_announcements', ['id' => "eq.{$ann['id']}"])) {
Response::error('لم يُحذف: ' . $this->db->lastErrorMessage(), $back);
return;
}
$this->dropImage($ann['image_url'] ?? null);
AuditLog::log('delete', 'org_announcement', $ann['id'], $ann, null);
Response::success('حُذف الإعلان', $back);
}
$orgId = $params['id']; // ─── Internals ──────────────────────────────────────────────────────
$annId = $params['annId'];
$announcement = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
if (!$announcement) { /**
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements"); * Publish once: is_draft false, published_at now (kept if it was published
* before and unpublished, so members are not notified twice), then one bell
* notification per active member.
*/
private function doPublish(array $org, array $ann): void
{
$back = "/organizations/{$org['id']}/announcements";
$first = empty($ann['published_at']);
$patch = ['is_draft' => false, 'updated_at' => date('c')];
if ($first) $patch['published_at'] = date('c');
if ($this->db->update('org_announcements', ['id' => "eq.{$ann['id']}"], $patch) === null) {
Response::error('لم يُنشر: ' . $this->db->lastErrorMessage(), $back);
return; return;
} }
$this->db->delete('org_announcements', ['id' => "eq.{$annId}"]); $sent = 0;
AuditLog::log('delete', 'org_announcement', $annId, $announcement, null); if ($first) {
Response::success('تم حذف الإعلان', "/organizations/{$orgId}/announcements"); $sent = $this->notifyMembers($org, $ann);
$this->db->soft(fn(Database $d) => $d->insert('org_activity_log', [
'org_id' => $org['id'], 'actor_id' => Auth::user()['id'] ?? '00000000-0000-0000-0000-000000000000',
'action' => 'announcement.published', 'resource_type' => 'announcement', 'resource_id' => $ann['id'],
'details' => ['title' => ($ann['title_ar'] ?? '') ?: $ann['title'], 'notified' => $sent, 'by_admin' => true],
]));
}
$this->db->clearWriteErrors();
AuditLog::log('publish', 'org_announcement', $ann['id'], ['is_draft' => true], $patch + ['notified' => $sent]);
Response::success($first
? "نُشر الإعلان ووصل إشعار إلى {$sent} عضو"
: 'أُعيد نشر الإعلان (أُرسل إشعاره عند النشر الأول، فلا يُرسل مرة أخرى)', $back);
}
/** One notification per active member, inserted 500 at a time. Returns how many landed. */
private function notifyMembers(array $org, array $ann): int
{
$title = ($ann['title_ar'] ?? '') ?: $ann['title'];
$content = ($ann['content_ar'] ?? '') ?: (string)($ann['content'] ?? '');
$body = mb_strlen($content) > 140 ? mb_substr($content, 0, 139) . '…' : $content;
$name = self::orgName($org);
$sent = 0;
$offset = 0;
do {
$members = $this->db->select('org_members', [
'select' => 'player_id', 'org_id' => "eq.{$org['id']}", 'status' => 'eq.active',
'order' => 'id.asc', 'offset' => $offset, 'limit' => 500,
]);
$offset += 500;
if (!$members) break;
$rows = array_map(fn($m) => [
'user_id' => $m['player_id'],
'type' => 'org_announcement',
'title' => $org['name'] . ': ' . (($ann['title'] ?? '') ?: $title),
'title_ar' => "{$name}: {$title}",
'body' => $body,
'body_ar' => $body,
'data' => ['org_id' => $org['id'], 'announcement_id' => $ann['id'], 'type' => $ann['type'] ?? 'general'],
], $members);
$ok = $this->db->soft(fn(Database $d) => $d->insert('notifications', $rows));
if ($ok !== null) $sent += count($rows);
} while (count($members) === 500);
return $sent;
}
/** The form as columns, or why it is refused. */
private function fromForm()
{
$titleAr = trim((string)($_POST['title_ar'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
$contentAr = trim((string)($_POST['content_ar'] ?? ''));
$content = trim((string)($_POST['content'] ?? ''));
if ($titleAr === '' && $title === '') return 'اكتب عنوان الإعلان';
if ($contentAr === '' && $content === '') return 'اكتب نص الإعلان';
if (mb_strlen($titleAr) > 120 || mb_strlen($title) > 120) return 'العنوان أطول من 120 حرفًا';
if (mb_strlen($contentAr) > 4000 || mb_strlen($content) > 4000) return 'النص أطول من 4000 حرف';
$type = (string)($_POST['type'] ?? 'general');
if (!isset(self::TYPES[$type])) return 'نوع الإعلان غير معروف';
$priority = (int)($_POST['priority'] ?? 0);
if ($priority < 0 || $priority > 100) return 'الأولوية من 0 إلى 100';
$expires = trim((string)($_POST['expires_at'] ?? ''));
$expiresUtc = $expires !== '' ? Time::toUtc($expires) : null;
if ($expires !== '' && !$expiresUtc) return 'تاريخ الانتهاء غير صالح';
if ($expiresUtc && strtotime($expiresUtc) <= time()) return 'تاريخ الانتهاء في الماضي';
return [
// title and content are NOT NULL; the Arabic text stands in when English is empty.
'title' => $title !== '' ? $title : $titleAr,
'title_ar' => $titleAr !== '' ? $titleAr : null,
'content' => $content !== '' ? $content : $contentAr,
'content_ar' => $contentAr !== '' ? $contentAr : null,
'type' => $type,
'priority' => $priority,
'is_pinned' => !empty($_POST['is_pinned']),
'expires_at' => $expiresUtc,
];
}
/** null when no image was sent, the public URL when stored, or why it was refused. */
private function uploadImage(string $orgId): ?string
{
$file = $_FILES['image'] ?? null;
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null;
if ($this->storage->validateFile($file, ['image/jpeg', 'image/png', 'image/webp'], self::IMAGE_MAX) !== null) {
return 'الصورة: JPEG أو PNG أو WebP حتى 5 MB';
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime];
$path = $orgId . '/announcements/' . time() . '-' . bin2hex(random_bytes(4)) . '.' . $ext;
try {
return $this->storage->upload(self::IMAGE_BUCKET, $path, $file['tmp_name'], $mime);
} catch (RuntimeException $e) {
return 'فشل رفع الصورة: ' . $e->getMessage();
}
}
private function dropImage(?string $url): void
{
if ($url && preg_match('#/storage/v1/object/public/' . self::IMAGE_BUCKET . '/([^?]+)#', $url, $m)) {
try { $this->storage->delete(self::IMAGE_BUCKET, rawurldecode($m[1])); } catch (Throwable $e) { /* stays orphaned */ }
}
} }
} }
...@@ -10,7 +10,7 @@ $formAction = $isEdit ...@@ -10,7 +10,7 @@ $formAction = $isEdit
<a href="/organizations/<?= $org['id'] ?>/announcements" class="btn btn-icon btn-ghost"> <a href="/organizations/<?= $org['id'] ?>/announcements" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
<h1><?= $isEdit ? 'تعديل الإعلان' : 'إنشاء إعلان' ?> - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1> <h1><?= $isEdit ? 'تعديل الإعلان' : 'إنشاء إعلان' ?> - <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></h1>
</div> </div>
</div> </div>
...@@ -21,29 +21,30 @@ $formAction = $isEdit ...@@ -21,29 +21,30 @@ $formAction = $isEdit
<div class="form-group"> <div class="form-group">
<label class="form-label">المنظمة</label> <label class="form-label">المنظمة</label>
<select name="org_id" class="form-input" disabled> <select name="org_id" class="form-input" disabled>
<option value="<?= $org['id'] ?>" selected><?= View::e($org['name_ar'] ?? $org['name']) ?></option> <option value="<?= $org['id'] ?>" selected><?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></option>
</select> </select>
<input type="hidden" name="org_id" value="<?= $org['id'] ?>"> <input type="hidden" name="org_id" value="<?= $org['id'] ?>">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">العنوان (English) <span class="text-danger">*</span></label> <label class="form-label">العنوان (عربي) <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-input" dir="ltr" value="<?= View::e($announcement['title'] ?? '') ?>" required> <input type="text" name="title_ar" class="form-input" maxlength="120" value="<?= View::e($announcement['title_ar'] ?? '') ?>" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">العنوان (عربي)</label> <label class="form-label">العنوان (English)</label>
<input type="text" name="title_ar" class="form-input" value="<?= View::e($announcement['title_ar'] ?? '') ?>"> <input type="text" name="title" class="form-input" dir="ltr" maxlength="120" value="<?= View::e(($announcement['title'] ?? '') !== ($announcement['title_ar'] ?? null) ? ($announcement['title'] ?? '') : '') ?>">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">المحتوى (English)</label> <label class="form-label">المحتوى (عربي) <span class="text-danger">*</span></label>
<textarea name="content" class="form-input" rows="5" dir="ltr"><?= View::e($announcement['content'] ?? '') ?></textarea> <textarea name="content_ar" class="form-input" rows="5" maxlength="4000" required><?= View::e($announcement['content_ar'] ?? '') ?></textarea>
<span class="form-hint">أول 140 حرفًا تصل للأعضاء في الإشعار عند النشر</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">المحتوى (عربي)</label> <label class="form-label">المحتوى (English)</label>
<textarea name="content_ar" class="form-input" rows="5"><?= View::e($announcement['content_ar'] ?? '') ?></textarea> <textarea name="content" class="form-input" rows="5" dir="ltr" maxlength="4000"><?= View::e(($announcement['content'] ?? '') !== ($announcement['content_ar'] ?? null) ? ($announcement['content'] ?? '') : '') ?></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -85,16 +86,28 @@ $formAction = $isEdit ...@@ -85,16 +86,28 @@ $formAction = $isEdit
<div class="mb-3"> <div class="mb-3">
<img src="<?= View::e($announcement['image_url']) ?>" alt="صورة الإعلان" style="max-width: 200px; border-radius: 8px;"> <img src="<?= View::e($announcement['image_url']) ?>" alt="صورة الإعلان" style="max-width: 200px; border-radius: 8px;">
</div> </div>
<label class="flex items-center gap-2 text-sm mb-2"><input type="checkbox" name="remove_image" value="1"> <span>إزالة الصورة الحالية</span></label>
<?php endif; ?> <?php endif; ?>
<input type="file" name="image" class="form-input" accept="image/*"> <input type="file" name="image" class="form-input" accept="image/jpeg,image/png,image/webp">
<span class="form-hint">الحد الأقصى: 10MB. الصيغ المدعومة: JPG, PNG, GIF, WebP</span> <span class="form-hint">الحد الأقصى: 5MB. الصيغ المدعومة: JPG, PNG, WebP</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">تاريخ انتهاء الإعلان</label> <label class="form-label">تاريخ انتهاء الإعلان</label>
<input type="datetime-local" name="expires_at" class="form-input" dir="ltr" value="<?= View::e(!empty($announcement['expires_at']) ? date('Y-m-d\TH:i', strtotime($announcement['expires_at'])) : '') ?>"> <input type="datetime-local" name="expires_at" class="form-input" dir="ltr" value="<?= View::e(Time::toInput($announcement['expires_at'] ?? null)) ?>">
<span class="form-hint">اتركه فارغاً لإعلان بدون انتهاء</span> <span class="form-hint">بتوقيت القاهرة. اتركه فارغاً لإعلان بدون انتهاء</span>
</div>
<?php if (!$isEdit && Auth::hasPermission('org-announcements.update')): ?>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="publish_now" value="1">
<span class="checkbox-mark"></span>
<span class="checkbox-label">انشر الآن وأرسل إشعارًا لكل الأعضاء النشطين</span>
</label>
<span class="form-hint">بدونه يُحفظ الإعلان كمسودة لا يراها الأعضاء</span>
</div> </div>
<?php endif; ?>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<a href="/organizations/<?= $org['id'] ?>" class="btn btn-icon btn-ghost"> <a href="/organizations/<?= $org['id'] ?>" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
<h1>الإعلانات - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1> <h1>الإعلانات - <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></h1>
</div> </div>
<a href="/organizations/<?= $org['id'] ?>/announcements/create" class="btn btn-primary"> <a href="/organizations/<?= $org['id'] ?>/announcements/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
</a> </a>
</div> </div>
<p class="text-sm text-muted mb-4">المنشور يظهر في صفحة المنظمة في اللعبة، ويصل إشعار واحد إلى كل عضو نشط (<?= (int)($activeMembers ?? 0) ?>) عند النشر الأول.</p>
<div class="card mb-5"> <div class="card mb-5">
<form method="GET" class="flex gap-4 items-end flex-wrap"> <form method="GET" class="flex gap-4 items-end flex-wrap">
<div class="form-group mb-0"> <div class="form-group mb-0">
...@@ -84,9 +86,9 @@ ...@@ -84,9 +86,9 @@
<?php foreach ($announcements as $ann): ?> <?php foreach ($announcements as $ann): ?>
<tr> <tr>
<td> <td>
<span class="fw-500"><?= View::e($ann['title_ar'] ?? $ann['title']) ?></span> <span class="fw-500"><?= View::e(($ann['title_ar'] ?? '') ?: $ann['title']) ?></span>
</td> </td>
<td><?= View::e($org['name_ar'] ?? $org['name']) ?></td> <td><?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></td>
<td> <td>
<span class="badge <?= $typeBadges[$ann['type']] ?? 'badge-default' ?>"> <span class="badge <?= $typeBadges[$ann['type']] ?? 'badge-default' ?>">
<?= $typeLabels[$ann['type']] ?? $ann['type'] ?> <?= $typeLabels[$ann['type']] ?? $ann['type'] ?>
...@@ -108,7 +110,7 @@ ...@@ -108,7 +110,7 @@
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="tabular-nums"> <td class="tabular-nums">
<?= !empty($ann['published_at']) ? date('Y-m-d H:i', strtotime($ann['published_at'])) : '-' ?> <?= Time::show($ann['published_at'] ?? null) ?><?php if (!empty($ann['expires_at'])): ?><div class="text-xs text-muted">ينتهي <?= Time::show($ann['expires_at']) ?></div><?php endif; ?>
</td> </td>
<td><?= (int)($ann['view_count'] ?? 0) ?></td> <td><?= (int)($ann['view_count'] ?? 0) ?></td>
<td> <td>
...@@ -117,11 +119,14 @@ ...@@ -117,11 +119,14 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<?php if (!Auth::hasPermission('org-announcements.update')): ?>
<span class="dropdown-item text-muted">النشر والتعديل لمدير المنظمة</span>
<?php else: ?>
<a href="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/edit" class="dropdown-item">تعديل</a> <a href="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php if ($ann['is_draft'] ?? true): ?> <?php if ($ann['is_draft'] ?? true): ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/publish" style="margin:0;"> <form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/publish" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="dropdown-item">نشر</button> <button type="submit" class="dropdown-item" onclick="return confirm('<?= empty($ann['published_at']) ? 'نشر الإعلان وإرسال إشعار لكل الأعضاء النشطين؟' : 'إعادة نشر الإعلان؟ لن يُرسل إشعار جديد.' ?>')">نشر</button>
</form> </form>
<?php else: ?> <?php else: ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/unpublish" style="margin:0;"> <form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/unpublish" style="margin:0;">
...@@ -130,7 +135,8 @@ ...@@ -130,7 +135,8 @@
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/delete', '<?= View::e($ann['title_ar'] ?? $ann['title']) ?>')">حذف</button> <button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/delete', '<?= View::e(($ann['title_ar'] ?? '') ?: $ann['title']) ?>')">حذف</button>
<?php endif; ?>
</div> </div>
</div> </div>
</td> </td>
...@@ -143,7 +149,7 @@ ...@@ -143,7 +149,7 @@
<span><?= $pagination->rangeText() ?></span> <span><?= $pagination->rangeText() ?></span>
<div class="pagination"> <div class="pagination">
<?php foreach ($pagination->pages() as $p): ?> <?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&type=<?= $_GET['type'] ?? '' ?>&is_draft=<?= $_GET['is_draft'] ?? '' ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a> <a href="?page=<?= $p ?>&type=<?= View::e($_GET['type'] ?? '') ?>&is_draft=<?= View::e($_GET['is_draft'] ?? '') ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment