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 [
'organizations/{id}/announcements/{annId}/edit' => ['module' => 'org-announcements', 'action' => 'edit'],
'organizations/{id}/announcements/{annId}/update' => ['module' => 'org-announcements', 'action' => 'update'],
'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'],
];
<?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
{
private Database $db;
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()
{
$this->db = Database::getInstance();
$this->storage = SupabaseStorage::getInstance();
}
public function list(array $params, string $method): void
private function org(string $id): array
{
$orgId = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
$org = preg_match('/^[0-9a-f-]{36}$/i', $id) ? $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]) : null;
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
exit;
}
return $org;
}
$filters = [
'select' => '*',
'org_id' => "eq.{$orgId}",
'order' => 'priority.desc,is_pinned.desc,created_at.desc',
];
if (!empty($_GET['type'])) {
$filters['type'] = "eq.{$_GET['type']}";
}
private function announcement(array $org, string $annId): ?array
{
if (!preg_match('/^[0-9a-f-]{36}$/i', $annId)) return null;
return $this->db->selectOne('org_announcements', ['id' => "eq.{$annId}", 'org_id' => "eq.{$org['id']}"]);
}
if (isset($_GET['is_draft']) && $_GET['is_draft'] !== '') {
$filters['is_draft'] = "eq.{$_GET['is_draft']}";
}
private static function orgName(array $org): string
{
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);
$filters['offset'] = $pagination->offset;
$filters['limit'] = $pagination->perPage;
$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']);
View::render('org-announcements/list', compact('org', 'announcements', 'pagination', 'pageTitle'));
$pageTitle = 'الإعلانات - ' . self::orgName($org);
View::render('org-announcements/list', compact('org', 'announcements', 'pagination', 'activeMembers', 'pageTitle'));
}
public function create(array $params, string $method): void
{
$orgId = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'إنشاء إعلان - ' . ($org['name_ar'] ?? $org['name']);
$org = $this->org($params['id']);
$pageTitle = 'إنشاء إعلان - ' . self::orgName($org);
View::render('org-announcements/form', compact('org', 'pageTitle'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$org = $this->org($params['id']);
$back = "/organizations/{$org['id']}/announcements/create";
$orgId = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) {
Response::error('المنظمة غير موجودة', '/organizations');
$data = $this->fromForm();
if (is_string($data)) {
Response::error($data, $back);
return;
}
$user = Auth::user();
$imageUrl = null;
// 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);
}
$image = $this->uploadImage($org['id']);
if (is_string($image) && !str_starts_with($image, 'http')) {
Response::error($image, $back);
return;
}
$data = [
'org_id' => $orgId,
'title' => trim($_POST['title'] ?? ''),
'title_ar' => trim($_POST['title_ar'] ?? ''),
'content' => trim($_POST['content'] ?? ''),
'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,
'image_url' => $imageUrl,
'author_id' => $user['id'],
'is_draft' => true,
];
$data += ['org_id' => $org['id'], 'author_id' => Auth::user()['id'] ?? null, 'is_draft' => true, 'image_url' => $image];
$row = $this->db->insert('org_announcements', $data);
if (!$row) {
$this->dropImage($image);
Response::error('لم يُحفظ الإعلان: ' . $this->db->lastErrorMessage(), $back);
return;
}
AuditLog::log('create', 'org_announcement', $row['id'] ?? null, null, $data);
$result = $this->db->insert('org_announcements', $data);
AuditLog::log('create', 'org_announcement', $result['id'] ?? null, null, $data);
Response::success('تم إنشاء الإعلان بنجاح', "/organizations/{$orgId}/announcements");
// Publishing needs the update permission (org_manager may only draft).
if (!empty($_POST['publish_now']) && Auth::hasPermission('org-announcements.update')) {
$this->doPublish($org, $row);
return;
}
Response::success('حُفظ الإعلان كمسودة. انشره ليظهر للأعضاء ويصلهم إشعار.', "/organizations/{$org['id']}/announcements");
}
public function edit(array $params, string $method): void
{
$orgId = $params['id'];
$annId = $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}",
]);
$org = $this->org($params['id']);
$announcement = $this->announcement($org, $params['annId'] ?? '');
if (!$announcement) {
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements");
Response::error('الإعلان غير موجود', "/organizations/{$org['id']}/announcements");
return;
}
$pageTitle = 'تعديل الإعلان - ' . ($announcement['title_ar'] ?? $announcement['title']);
$pageTitle = 'تعديل الإعلان - ' . (($announcement['title_ar'] ?? '') ?: $announcement['title']);
View::render('org-announcements/form', compact('org', 'announcement', 'pageTitle'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$orgId = $params['id'];
$annId = $params['annId'];
$old = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
$org = $this->org($params['id']);
$old = $this->announcement($org, $params['annId'] ?? '');
if (!$old) {
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements");
Response::error('الإعلان غير موجود', "/organizations/{$org['id']}/announcements");
return;
}
$back = "/organizations/{$org['id']}/announcements/{$old['id']}/edit";
$data = [
'title' => trim($_POST['title'] ?? ''),
'title_ar' => trim($_POST['title_ar'] ?? ''),
'content' => trim($_POST['content'] ?? ''),
'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);
}
}
$data = $this->fromForm();
if (is_string($data)) {
Response::error($data, $back);
return;
}
$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);
AuditLog::log('update', 'org_announcement', $annId, $old, $data);
Response::success('تم تحديث الإعلان بنجاح', "/organizations/{$orgId}/announcements");
if ($this->db->update('org_announcements', ['id' => "eq.{$old['id']}"], $data) === null) {
$this->dropImage($image);
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
{
Auth::requireCsrf();
$orgId = $params['id'];
$annId = $params['annId'];
$announcement = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
if (!$announcement) {
Response::error('الإعلان غير موجود', "/organizations/{$orgId}/announcements");
$org = $this->org($params['id']);
$ann = $this->announcement($org, $params['annId'] ?? '');
if (!$ann) {
Response::error('الإعلان غير موجود', "/organizations/{$org['id']}/announcements");
return;
}
if (empty($ann['is_draft'])) {
Response::error('الإعلان منشور بالفعل', "/organizations/{$org['id']}/announcements");
return;
}
$this->doPublish($org, $ann);
}
$data = [
'is_draft' => false,
'published_at' => date('c'),
];
$this->db->update('org_announcements', ['id' => "eq.{$annId}"], $data);
AuditLog::log('publish', 'org_announcement', $annId, $announcement, $data);
Response::success('تم نشر الإعلان بنجاح', "/organizations/{$orgId}/announcements");
/** Back to draft: hidden from the org page. Notifications already sent stay in bells. */
public function unpublish(array $params, string $method): void
{
Auth::requireCsrf();
$org = $this->org($params['id']);
$ann = $this->announcement($org, $params['annId'] ?? '');
$back = "/organizations/{$org['id']}/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
{
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'];
$annId = $params['annId'];
$announcement = $this->db->selectOne('org_announcements', [
'id' => "eq.{$annId}",
'org_id' => "eq.{$orgId}",
]);
// ─── Internals ──────────────────────────────────────────────────────
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;
}
$this->db->delete('org_announcements', ['id' => "eq.{$annId}"]);
AuditLog::log('delete', 'org_announcement', $annId, $announcement, null);
Response::success('تم حذف الإعلان', "/organizations/{$orgId}/announcements");
$sent = 0;
if ($first) {
$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
<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>
</a>
<h1><?= $isEdit ? 'تعديل الإعلان' : 'إنشاء إعلان' ?> - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1>
<h1><?= $isEdit ? 'تعديل الإعلان' : 'إنشاء إعلان' ?> - <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></h1>
</div>
</div>
......@@ -21,29 +21,30 @@ $formAction = $isEdit
<div class="form-group">
<label class="form-label">المنظمة</label>
<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>
<input type="hidden" name="org_id" value="<?= $org['id'] ?>">
</div>
<div class="form-group">
<label class="form-label">العنوان (English) <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-input" dir="ltr" value="<?= View::e($announcement['title'] ?? '') ?>" required>
<label class="form-label">العنوان (عربي) <span class="text-danger">*</span></label>
<input type="text" name="title_ar" class="form-input" maxlength="120" value="<?= View::e($announcement['title_ar'] ?? '') ?>" required>
</div>
<div class="form-group">
<label class="form-label">العنوان (عربي)</label>
<input type="text" name="title_ar" class="form-input" value="<?= View::e($announcement['title_ar'] ?? '') ?>">
<label class="form-label">العنوان (English)</label>
<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 class="form-group">
<label class="form-label">المحتوى (English)</label>
<textarea name="content" class="form-input" rows="5" dir="ltr"><?= View::e($announcement['content'] ?? '') ?></textarea>
<label class="form-label">المحتوى (عربي) <span class="text-danger">*</span></label>
<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 class="form-group">
<label class="form-label">المحتوى (عربي)</label>
<textarea name="content_ar" class="form-input" rows="5"><?= View::e($announcement['content_ar'] ?? '') ?></textarea>
<label class="form-label">المحتوى (English)</label>
<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 class="form-group">
......@@ -85,16 +86,28 @@ $formAction = $isEdit
<div class="mb-3">
<img src="<?= View::e($announcement['image_url']) ?>" alt="صورة الإعلان" style="max-width: 200px; border-radius: 8px;">
</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; ?>
<input type="file" name="image" class="form-input" accept="image/*">
<span class="form-hint">الحد الأقصى: 10MB. الصيغ المدعومة: JPG, PNG, GIF, WebP</span>
<input type="file" name="image" class="form-input" accept="image/jpeg,image/png,image/webp">
<span class="form-hint">الحد الأقصى: 5MB. الصيغ المدعومة: JPG, PNG, WebP</span>
</div>
<div class="form-group">
<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'])) : '') ?>">
<span class="form-hint">اتركه فارغاً لإعلان بدون انتهاء</span>
<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>
</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>
<?php endif; ?>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
......
......@@ -3,7 +3,7 @@
<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>
</a>
<h1>الإعلانات - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1>
<h1>الإعلانات - <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></h1>
</div>
<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>
......@@ -11,6 +11,8 @@
</a>
</div>
<p class="text-sm text-muted mb-4">المنشور يظهر في صفحة المنظمة في اللعبة، ويصل إشعار واحد إلى كل عضو نشط (<?= (int)($activeMembers ?? 0) ?>) عند النشر الأول.</p>
<div class="card mb-5">
<form method="GET" class="flex gap-4 items-end flex-wrap">
<div class="form-group mb-0">
......@@ -84,9 +86,9 @@
<?php foreach ($announcements as $ann): ?>
<tr>
<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><?= View::e($org['name_ar'] ?? $org['name']) ?></td>
<td><?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></td>
<td>
<span class="badge <?= $typeBadges[$ann['type']] ?? 'badge-default' ?>">
<?= $typeLabels[$ann['type']] ?? $ann['type'] ?>
......@@ -108,7 +110,7 @@
<?php endif; ?>
</td>
<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><?= (int)($ann['view_count'] ?? 0) ?></td>
<td>
......@@ -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>
</button>
<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>
<?php if ($ann['is_draft'] ?? true): ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/publish" style="margin:0;">
<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>
<?php else: ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/unpublish" style="margin:0;">
......@@ -130,7 +135,8 @@
</form>
<?php endif; ?>
<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>
</td>
......@@ -143,7 +149,7 @@
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?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; ?>
</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