From 28e2fc40773f33a7c24b676bc8b8decbec651576 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 04:56:40 +0300 Subject: [PATCH 01/16] Add files via upload --- captain-definition | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 captain-definition diff --git a/captain-definition b/captain-definition new file mode 100644 index 0000000..debfa9b --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} \ No newline at end of file From e5f106239df185232b792b1bd25a8f0d0ad642e2 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 02:29:30 +0000 Subject: [PATCH 02/16] feat: redesign admin settings UI with categorized visual layout Replace the generic key/value table with a structured form: settings are grouped by category (general, auth, ads, webhook, notifications, FCM), each with Hebrew labels and descriptions. Booleans are toggles, regex rules have separate pattern/replace fields, and a paste-JSON helper auto-fills FCM service account fields. A collapsible "advanced" section preserves manual key/value entry for unknown settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/settings/settings.component.html | 216 ++++++++++-- .../admin/settings/settings.component.scss | 191 +++++++++++ .../admin/settings/settings.component.ts | 207 +++++++++++- .../admin/settings/settings.schema.ts | 308 ++++++++++++++++++ 4 files changed, 881 insertions(+), 41 deletions(-) create mode 100644 frontend/src/app/components/admin/settings/settings.schema.ts diff --git a/frontend/src/app/components/admin/settings/settings.component.html b/frontend/src/app/components/admin/settings/settings.component.html index 80f3eea..0f0817a 100644 --- a/frontend/src/app/components/admin/settings/settings.component.html +++ b/frontend/src/app/components/admin/settings/settings.component.html @@ -1,31 +1,193 @@ - - - הגדרות ערוץ - - - - @for (setting of settings; track setting; let i = $index) { -
- - - -
- } -
- +
+
+ } + + @for (field of cat.fields; track field.key) { + @if (isFieldVisible(field)) { +
+ + @if (field.type === 'boolean') { +
+ + {{ field.label }} + +
+ @if (field.description) { +

{{ field.description }}

+ } + } @else { + + @if (field.description) { +

{{ field.description }}

+ } + + @switch (field.type) { + @case ('textarea') { + + } + @case ('number') { + + } + @case ('password') { + + } + @default { + + } + } + } +
+ } + } +
+
+ } + + + + + החלפות טקסט אוטומטיות + + +

+ ניתן להגדיר ביטויים רגולריים (Regex) שיוחלפו אוטומטית בעת פרסום הודעות חדשות. + בשדה "תבנית" יש להזין את הביטוי הרגולרי, ובשדה "החלפה" את הטקסט החלופי. + לדוגמא: תבנית (.*?\!)(.*) והחלפה **$1**$2 תדגיש כותרות הודעה. +

+ + @if (regexRules.length === 0) { +

לא הוגדרו כללי החלפה.

+ } + + @for (rule of regexRules; track $index; let i = $index) { +
+
+
+ + +
+
+ + +
+
+ +
+ } + + +
+
+ + + + + + הגדרות מתקדמות / מותאמות אישית + + +

+ אזור זה מאפשר להוסיף הגדרות שאינן מופיעות בטופס למעלה (לדוגמא: הגדרות חדשות + שטרם נוספו לממשק). יש להזין שם הגדרה וערך באופן ידני. +

- - + @if (extraSettings.length === 0) { +

אין הגדרות מותאמות אישית.

+ } + + @for (s of extraSettings; track $index; let i = $index) { +
+ + + +
+ } + + +
+
+
- - - - \ No newline at end of file + + diff --git a/frontend/src/app/components/admin/settings/settings.component.scss b/frontend/src/app/components/admin/settings/settings.component.scss index e69de29..819ebcb 100644 --- a/frontend/src/app/components/admin/settings/settings.component.scss +++ b/frontend/src/app/components/admin/settings/settings.component.scss @@ -0,0 +1,191 @@ +:host { + display: block; + direction: rtl; +} + +.settings-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 5rem; +} + +.settings-card { + margin-bottom: 0 !important; +} + +.settings-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + + nb-icon { + font-size: 1.25rem; + } +} + +.category-description { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.5; + + code { + background: var(--background-basic-color-2, #f7f9fc); + padding: 0.1rem 0.3rem; + border-radius: 4px; + font-size: 0.85em; + direction: ltr; + display: inline-block; + } +} + +.setting-field { + padding: 0.85rem 0; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + &:last-child { + border-bottom: none; + } + + &.is-toggle { + padding: 0.65rem 0; + } +} + +.field-label { + display: block; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 0.25rem; + color: var(--text-basic-color); + + &.small { + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.2rem; + } +} + +.field-description { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.4; +} + +.toggle-row { + display: flex; + align-items: center; + + ::ng-deep nb-toggle .toggle-label { + margin-inline-start: 0.5rem; + } +} + +.toggle-description { + margin-top: 0.35rem; + margin-inline-start: 3.25rem; +} + +.fcm-json-paste { + background: var(--background-basic-color-2, #f7f9fc); + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + + textarea { + direction: ltr; + font-family: monospace; + font-size: 0.85rem; + } + + .error-text { + color: var(--color-danger-default, #ff3d71); + font-size: 0.85rem; + margin-top: 0.4rem; + } +} + +.regex-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding: 0.6rem 0; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + &:last-of-type { + border-bottom: none; + margin-bottom: 0.6rem; + } +} + +.regex-fields { + display: flex; + gap: 0.5rem; + flex: 1; + flex-wrap: wrap; + + .regex-field { + flex: 1; + min-width: 180px; + } +} + +.extra-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; + + input:first-of-type { + flex: 0 0 30%; + } +} + +.empty-state { + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + font-style: italic; + margin: 0 0 0.75rem; +} + +.advanced-accordion { + ::ng-deep nb-accordion-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .advanced-title { + font-weight: 600; + } +} + +.save-bar { + position: sticky; + bottom: 0; + background: var(--background-basic-color-1); + padding: 0.75rem 0; + border-top: 1px solid var(--divider-color, #edf1f7); + display: flex; + justify-content: flex-start; + z-index: 5; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } +} + +hr { + border: none; + border-top: 1px solid var(--divider-color, #edf1f7); + margin: 1rem 0; +} + +.mt-2 { + margin-top: 0.5rem; +} diff --git a/frontend/src/app/components/admin/settings/settings.component.ts b/frontend/src/app/components/admin/settings/settings.component.ts index 5191a11..4bf219f 100644 --- a/frontend/src/app/components/admin/settings/settings.component.ts +++ b/frontend/src/app/components/admin/settings/settings.component.ts @@ -1,26 +1,63 @@ import { Component, OnInit } from '@angular/core'; import { AdminService } from '../../../services/admin.service'; -import { NbButtonModule, NbCardModule, NbToastrService, NbIconModule, NbInputModule } from "@nebular/theme"; +import { + NbButtonModule, + NbCardModule, + NbToastrService, + NbIconModule, + NbInputModule, + NbToggleModule, + NbAccordionModule, + NbTooltipModule, +} from "@nebular/theme"; import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; import { Setting } from '../../../models/setting.model'; +import { + SETTINGS_SCHEMA, + SettingsCategorySchema, + SettingFieldSchema, + FCM_JSON_KEY_MAP, + getAllKnownKeys, +} from './settings.schema'; + +interface RegexRule { + pattern: string; + replace: string; +} + +interface ExtraSetting { + key: string; + value: string; +} @Component({ selector: 'app-settings', imports: [ + CommonModule, NbCardModule, NbButtonModule, NbIconModule, NbInputModule, - FormsModule -], + NbToggleModule, + NbAccordionModule, + NbTooltipModule, + FormsModule, + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss' }) export class SettingsComponent implements OnInit { - settings: Setting[] = []; + schema: SettingsCategorySchema[] = SETTINGS_SCHEMA; + values: Record = {}; + regexRules: RegexRule[] = []; + extraSettings: ExtraSetting[] = []; + setInProgress: boolean = false; + fcmJsonPaste: string = ''; + fcmJsonError: string = ''; constructor( private adminService: AdminService, @@ -29,19 +66,161 @@ export class SettingsComponent implements OnInit { ngOnInit(): void { this.adminService.getSettings() - .then(settings => this.settings = settings || []); + .then(settings => this.loadFromSettings(settings || [])); } - saveSettings() { - this.setInProgress = true; - this.adminService.setSettings(this.settings) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); - this.setInProgress = false; + private loadFromSettings(settings: Setting[]) { + const known = getAllKnownKeys(); + this.values = {}; + this.regexRules = []; + this.extraSettings = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + if (f.type === 'boolean') this.values[f.key] = false; + else this.values[f.key] = ''; + } + } + + for (const s of settings) { + if (s.key === 'regex-replace') { + const raw = String(s.value ?? ''); + if (raw.includes('#')) { + const idx = raw.indexOf('#'); + this.regexRules.push({ + pattern: raw.substring(0, idx), + replace: raw.substring(idx + 1), + }); + } else if (raw) { + this.regexRules.push({ pattern: raw, replace: '' }); + } + continue; + } + + if (known.has(s.key)) { + const field = this.findField(s.key); + if (field?.type === 'boolean') { + this.values[s.key] = this.toBool(s.value); + } else { + this.values[s.key] = s.value === undefined || s.value === null ? '' : String(s.value); + } + } else { + this.extraSettings.push({ + key: s.key, + value: s.value === undefined || s.value === null ? '' : String(s.value), + }); + } + } } - removeSetting(index: number) { - // if (!confirm('האם אתה בטוח שברצונך למחוק את ההגדרה הזו?')) return; - this.settings.splice(index, 1); + private findField(key: string): SettingFieldSchema | undefined { + for (const cat of this.schema) { + const f = cat.fields.find(x => x.key === key); + if (f) return f; + } + return undefined; + } + + private toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v !== 0; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; + } + + isFieldVisible(field: SettingFieldSchema): boolean { + if (!field.hideWhen) return true; + return this.values[field.hideWhen.key] !== field.hideWhen.equals; + } + + addRegexRule() { + this.regexRules.push({ pattern: '', replace: '' }); + } + + removeRegexRule(i: number) { + this.regexRules.splice(i, 1); + } + + addExtraSetting() { + this.extraSettings.push({ key: '', value: '' }); + } + + removeExtraSetting(i: number) { + this.extraSettings.splice(i, 1); + } + + applyFcmJsonPaste() { + this.fcmJsonError = ''; + if (!this.fcmJsonPaste.trim()) return; + let parsed: any; + try { + parsed = JSON.parse(this.fcmJsonPaste); + } catch (e) { + this.fcmJsonError = 'JSON לא תקין'; + return; + } + + let count = 0; + for (const [jsonKey, settingKey] of Object.entries(FCM_JSON_KEY_MAP)) { + if (parsed[jsonKey] !== undefined) { + this.values[settingKey] = String(parsed[jsonKey]); + count++; + } + } + + if (count === 0) { + this.fcmJsonError = 'לא נמצאו שדות מוכרים בקובץ ה-JSON'; + return; + } + + this.fcmJsonPaste = ''; + this.tostService.success('', `${count} שדות מולאו אוטומטית מה-JSON`); + } + + private buildSettingsArray(): Setting[] { + const out: Setting[] = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + const v = this.values[f.key]; + if (f.type === 'boolean') { + if (v === true) out.push({ key: f.key, value: '1' as any }); + } else if (f.type === 'number') { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } else { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } + } + } + + for (const r of this.regexRules) { + const p = (r.pattern || '').trim(); + if (!p) continue; + out.push({ key: 'regex-replace', value: `${p}#${r.replace ?? ''}` as any }); + } + + for (const e of this.extraSettings) { + const k = (e.key || '').trim(); + if (!k) continue; + out.push({ key: k, value: e.value ?? '' as any }); + } + + return out; + } + + saveSettings() { + this.setInProgress = true; + const payload = this.buildSettingsArray(); + this.adminService.setSettings(payload) + .then(() => this.tostService.success('', 'השינויים נשמרו בהצלחה!')) + .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינויים')) + .finally(() => this.setInProgress = false); } } diff --git a/frontend/src/app/components/admin/settings/settings.schema.ts b/frontend/src/app/components/admin/settings/settings.schema.ts new file mode 100644 index 0000000..56b7897 --- /dev/null +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -0,0 +1,308 @@ +export type SettingFieldType = 'boolean' | 'text' | 'number' | 'url' | 'textarea' | 'password'; + +export interface SettingFieldSchema { + key: string; + label: string; + description?: string; + type: SettingFieldType; + placeholder?: string; + default?: string | number | boolean; + hideWhen?: { key: string; equals: any }; +} + +export interface SettingsCategorySchema { + id: string; + title: string; + icon?: string; + description?: string; + fields: SettingFieldSchema[]; +} + +export const SETTINGS_SCHEMA: SettingsCategorySchema[] = [ + { + id: 'general', + title: 'הגדרות כלליות', + icon: 'settings-2-outline', + fields: [ + { + key: 'custom_title', + label: 'כותרת מותאמת אישית', + description: 'כותרת שתשמש לקידום האתר בתוצאות חיפוש (SEO).', + type: 'text', + placeholder: 'לדוגמא: הערוץ החדשותי שלי', + }, + { + key: 'contact_us', + label: 'קישור ליצירת קשר', + description: 'הזנת קישור תפעיל כפתור "צור קשר" שיפנה לקישור זה.', + type: 'url', + placeholder: 'https://example.com/contact', + }, + { + key: 'max_file_size', + label: 'הגבלת גודל קובץ להעלאה (MB)', + description: 'גודל מקסימלי בקבצים שניתן להעלות לערוץ. ברירת מחדל: 100 MB.', + type: 'number', + placeholder: '100', + default: 100, + }, + ], + }, + { + id: 'auth', + title: 'הזדהות ואבטחה', + icon: 'shield-outline', + fields: [ + { + key: 'require_auth', + label: 'חיוב הזדהות לכניסה לערוץ', + description: 'משתמשים יחויבו להתחבר לפני שיוכלו לצפות בערוץ.', + type: 'boolean', + }, + { + key: 'require_auth_for_view_files', + label: 'חיוב הזדהות לצפייה בתמונות וסרטונים', + description: 'גם אם הערוץ פתוח לצפייה, ניתן לחייב הזדהות לפני צפייה בקבצים.', + type: 'boolean', + }, + { + key: 'api_secret_key', + label: 'מפתח API ליבוא הודעות', + description: 'מפתח סודי שיש לכלול בכותרת X-API-Key בעת קריאות יבוא הודעות.', + type: 'password', + placeholder: 'מפתח סודי חזק', + }, + ], + }, + { + id: 'views', + title: 'מונה צפיות', + icon: 'eye-outline', + fields: [ + { + key: 'count_views', + label: 'הפעלת ספירת צפיות בהודעות', + description: 'מציג ליד כל הודעה את מספר הצפיות שנספרו עבורה.', + type: 'boolean', + }, + ], + }, + { + id: 'ads', + title: 'פרסומות', + icon: 'pricetags-outline', + fields: [ + { + key: 'ad-iframe-src', + label: 'קישור HTML של פרסומת להטמעה', + description: 'הכנסת קישור תפעיל הצגת מסגרת פרסומת בערוץ.', + type: 'url', + placeholder: 'https://ad.example.com/banner.html', + }, + { + key: 'ad-iframe-width', + label: 'רוחב חלון הפרסומת (פיקסלים)', + description: 'רוחב מומלץ: 300.', + type: 'number', + placeholder: '300', + }, + ], + }, + { + id: 'webhook', + title: 'וובהוק (Webhook)', + icon: 'link-2-outline', + description: 'שליחת התראה לשרת חיצוני בעת יצירה, עדכון או מחיקה של הודעות.', + fields: [ + { + key: 'webhook_url', + label: 'כתובת ה-Webhook', + description: 'כתובת ה-URL שאליה תישלח בקשת POST בעת שינוי בהודעות.', + type: 'url', + placeholder: 'https://example.com/webhook', + }, + { + key: 'webhook_verify_token', + label: 'טוקן אימות', + description: 'טוקן סודי שיישלח עם כל בקשה לאימות שהבקשה הגיעה ממערכת זו (מומלץ).', + type: 'password', + placeholder: 'your-secret-token', + }, + ], + }, + { + id: 'notifications', + title: 'התראות דחיפה (Push)', + icon: 'bell-outline', + description: 'מבוסס על שירות Firebase Cloud Messaging (FCM) של גוגל.', + fields: [ + { + key: 'on_notification', + label: 'הפעלת התראות דחיפה', + description: 'יש להפעיל ולהזין את כל הפרטים מ-Firebase שבהמשך.', + type: 'boolean', + }, + { + key: 'project_domain', + label: 'דומיין הפרויקט (להפניית לחיצה על התראה)', + description: 'כתובת ה-URL שאליה ינותב המשתמש כשילחץ על התראה.', + type: 'url', + placeholder: 'https://example.com', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'vapid', + label: 'מפתח VAPID', + description: 'Cloud Messaging > Web Push certificates > Key pair', + type: 'password', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_api_key', + label: 'apiKey', + description: 'General > SDK setup and configuration > apiKey', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_auth_domain', + label: 'authDomain', + description: 'General > SDK setup and configuration > authDomain', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_project_id', + label: 'projectId', + description: 'General > SDK setup and configuration > projectId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_storage_bucket', + label: 'storageBucket', + description: 'General > SDK setup and configuration > storageBucket', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_messaging_sender_id', + label: 'messagingSenderId', + description: 'General > SDK setup and configuration > messagingSenderId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_app_id', + label: 'appId', + description: 'General > SDK setup and configuration > appId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_measurement_id', + label: 'measurementId', + description: 'General > SDK setup and configuration > measurementId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + ], + }, + { + id: 'fcm_json', + title: 'חשבון שירות FCM (Service Account)', + icon: 'file-text-outline', + description: 'שדות אלה מגיעים מקובץ ה-JSON שנוצר תחת serviceaccounts > Generate new private key. ניתן להדביק את כל קובץ ה-JSON בשדה הראשון לשם מילוי אוטומטי של כל השדות.', + fields: [ + { + key: 'fcm_json_type', + label: 'type', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_project_id', + label: 'project_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_private_key_id', + label: 'private_key_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_private_key', + label: 'private_key', + type: 'textarea', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_email', + label: 'client_email', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_id', + label: 'client_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_auth_uri', + label: 'auth_uri', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_token_uri', + label: 'token_uri', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_auth_provider_x509_cert_url', + label: 'auth_provider_x509_cert_url', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_x509_cert_url', + label: 'client_x509_cert_url', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_universe_domain', + label: 'universe_domain', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + ], + }, +]; + +export const FCM_JSON_KEY_MAP: Record = { + type: 'fcm_json_type', + project_id: 'fcm_json_project_id', + private_key_id: 'fcm_json_private_key_id', + private_key: 'fcm_json_private_key', + client_email: 'fcm_json_client_email', + client_id: 'fcm_json_client_id', + auth_uri: 'fcm_json_auth_uri', + token_uri: 'fcm_json_token_uri', + auth_provider_x509_cert_url: 'fcm_json_auth_provider_x509_cert_url', + client_x509_cert_url: 'fcm_json_client_x509_cert_url', + universe_domain: 'fcm_json_universe_domain', +}; + +export function getAllKnownKeys(): Set { + const keys = new Set(); + for (const cat of SETTINGS_SCHEMA) { + for (const f of cat.fields) keys.add(f.key); + } + keys.add('regex-replace'); + return keys; +} From a8d3a1ef7e5d03c354c7eea569ae927dd07bbc4a Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 02:39:07 +0000 Subject: [PATCH 03/16] chore: trigger redeploy Co-Authored-By: Claude Opus 4.7 (1M context) From 72da5a4b8c8301e0405569e36641fc9dc74f43ab Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 03:14:18 +0000 Subject: [PATCH 04/16] feat: integrate Magnet ad platform with frequency rules and lazy loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated admin tab "שילוב פרסומות ממגנט" where admins paste an HTML/JS embed snippet from the Magnet ad platform. Ads render between chat messages according to configurable rules: either every N messages (with optional minimum time gap) or every N seconds (with optional minimum new-messages gap). Each ad slot is lazy-loaded with IntersectionObserver — the external embed only fires when the user scrolls near it, and re-fires on every re-mount so each viewer (and each scroll) gets a live ad. If the embed produces no DOM content within 5 seconds, the slot collapses silently. Backend exposes a new public GET /api/ads/magnet endpoint and persists the seven magnet_* keys in the existing settings:list store. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/ads.go | 28 ++++ backend/main.go | 1 + backend/settings.go | 28 ++++ .../admin/admin-panel.component.html | 3 + .../components/admin/admin-panel.component.ts | 12 +- .../magnet-ads/magnet-ads.component.html | 116 +++++++++++++++ .../magnet-ads/magnet-ads.component.scss | 89 +++++++++++ .../admin/magnet-ads/magnet-ads.component.ts | 137 +++++++++++++++++ .../channel/chat/chat.component.html | 28 ++-- .../components/channel/chat/chat.component.ts | 18 ++- .../magnet-ad-slot.component.html | 1 + .../magnet-ad-slot.component.scss | 19 +++ .../magnet-ad-slot.component.ts | 126 ++++++++++++++++ .../src/app/services/magnet-ads.service.ts | 138 ++++++++++++++++++ 14 files changed, 731 insertions(+), 13 deletions(-) create mode 100644 frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html create mode 100644 frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss create mode 100644 frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts create mode 100644 frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html create mode 100644 frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss create mode 100644 frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts create mode 100644 frontend/src/app/services/magnet-ads.service.ts diff --git a/backend/ads.go b/backend/ads.go index b868cb4..7d544d0 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -19,3 +19,31 @@ func getAdsSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } + +type MagnetAdsSettings struct { + Enabled bool `json:"enabled"` + Snippet string `json:"snippet"` + Mode string `json:"mode"` + PerMessages int64 `json:"perMessages"` + MinTimeSeconds int64 `json:"minTimeSeconds"` + PerSeconds int64 `json:"perSeconds"` + MinMessagesSinceLast int64 `json:"minMessagesSinceLast"` +} + +func getMagnetAdsSettings(w http.ResponseWriter, r *http.Request) { + settings := MagnetAdsSettings{ + Enabled: settingConfig.MagnetEnabled, + Mode: settingConfig.MagnetMode, + PerMessages: settingConfig.MagnetPerMessages, + MinTimeSeconds: settingConfig.MagnetMinTimeSeconds, + PerSeconds: settingConfig.MagnetPerSeconds, + MinMessagesSinceLast: settingConfig.MagnetMinMessagesSince, + } + + if settings.Enabled { + settings.Snippet = settingConfig.MagnetSnippet + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(settings) +} diff --git a/backend/main.go b/backend/main.go index a33d03c..1bbeac0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -74,6 +74,7 @@ func main() { r.Get("/firebase-messaging-sw.js", getFirebaseMessagingSW) r.Route("/api", func(api chi.Router) { api.Get("/ads/settings", getAdsSettings) + api.Get("/ads/magnet", getMagnetAdsSettings) api.Get("/emojis/list", getEmojisList) api.Get("/channel/notifications-config", getNotificationsConfig) api.Post("/channel/notifications-subscribe", subscribeNotifications) diff --git a/backend/settings.go b/backend/settings.go index c9286a0..4aa3d21 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -41,6 +41,13 @@ type SettingConfig struct { MaxFileSize int64 CustomTitle string ContactUs string + MagnetEnabled bool + MagnetSnippet string + MagnetMode string + MagnetPerMessages int64 + MagnetMinTimeSeconds int64 + MagnetPerSeconds int64 + MagnetMinMessagesSince int64 } type Setting struct { @@ -186,6 +193,27 @@ func (s *Settings) ToConfig() *SettingConfig { case "contact_us": config.ContactUs = setting.GetString() + + case "magnet_enabled": + config.MagnetEnabled = setting.GetBool() + + case "magnet_snippet": + config.MagnetSnippet = setting.GetString() + + case "magnet_mode": + config.MagnetMode = setting.GetString() + + case "magnet_per_messages": + config.MagnetPerMessages = setting.GetInt() + + case "magnet_min_time_seconds": + config.MagnetMinTimeSeconds = setting.GetInt() + + case "magnet_per_seconds": + config.MagnetPerSeconds = setting.GetInt() + + case "magnet_min_messages_since": + config.MagnetMinMessagesSince = setting.GetInt() } } diff --git a/frontend/src/app/components/admin/admin-panel.component.html b/frontend/src/app/components/admin/admin-panel.component.html index 240f739..f4bfbe8 100644 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ b/frontend/src/app/components/admin/admin-panel.component.html @@ -31,6 +31,9 @@ @case (statistics) { } + @case (magnetAds) { + + } } diff --git a/frontend/src/app/components/admin/admin-panel.component.ts b/frontend/src/app/components/admin/admin-panel.component.ts index 9db0b9d..8305815 100644 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ b/frontend/src/app/components/admin/admin-panel.component.ts @@ -6,6 +6,7 @@ import { PrivilegDashboardComponent } from "./privileg-dashboard/privileg-dashbo import { ChannelInfoFormComponent } from "../channel/channel-info-form/channel-info-form.component"; import { ReportsComponent } from "./reports/reports.component"; import { StatisticsComponent } from "./statistics/statistics.component"; +import { MagnetAdsComponent } from "./magnet-ads/magnet-ads.component"; @Component({ selector: 'admin-dashboard', @@ -19,7 +20,8 @@ import { StatisticsComponent } from "./statistics/statistics.component"; PrivilegDashboardComponent, ChannelInfoFormComponent, ReportsComponent, - StatisticsComponent + StatisticsComponent, + MagnetAdsComponent ], templateUrl: './admin-panel.component.html', styleUrls: ['./admin-panel.component.scss'] @@ -34,6 +36,7 @@ export class AdminPanelComponent implements OnInit { readonly closedReports = "closed-reports"; readonly allReports = "all-reports"; readonly statistics = "statistics"; + readonly magnetAds = "magnet-ads"; selectedMenuItem = this.info; @@ -59,6 +62,10 @@ export class AdminPanelComponent implements OnInit { title: "אימוג'ים", icon: 'smiling-face-outline', }, + { + title: 'שילוב פרסומות ממגנט', + icon: 'pricetags-outline', + }, { title: 'דיווחים', icon: 'alert-triangle-outline', @@ -117,6 +124,9 @@ export class AdminPanelComponent implements OnInit { case 'bar-chart-outline': this.selectedMenuItem = this.statistics; break; + case 'pricetags-outline': + this.selectedMenuItem = this.magnetAds; + break; } }); } diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html new file mode 100644 index 0000000..8c7b03a --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -0,0 +1,116 @@ +
+ + + + + שילוב פרסומות ממגנט + + + +

+ מגנט היא פלטפורמת פרסומות חיצונית. ניתן להדביק כאן את קוד ההטמעה (HTML/JS) שקיבלתם + ממגנט, והוא יוצג בין ההודעות בערוץ. ההטמעה היא בצד הגולש בלבד - הפרסומת תיטען רק + כשהגולש גולל ומגיע אליה, וכל פעם נקראת מחדש כך שהתוכן עשוי להשתנות בכל גלילה. + אם אין תשובת שרת מהפרסום - לא יוצג כלום. +

+ +
+ + הפעלת שילוב פרסומות מגנט + +

כיבוי יסיר לחלוטין את הצגת הפרסומות בערוץ.

+
+ +
+ +

+ הדביקו את קוד ההטמעה שקיבלתם ממגנט. הקוד יוטמע כפי שהוא בכל מיקום פרסומת בערוץ + (תגיות <script> ירוצו בכל הצגה). +

+ +
+
+
+ + + + + תדירות הצגה + + + +

בחרו את שיטת התזמון להצגת פרסומות בין ההודעות.

+ +
+ + הצגה לפי כמות הודעות + + + הצגה לפי זמן + +
+ + @if (mode === 'by_messages') { +
+
+ +

בכל X הודעות תוצג פרסומת אחת. לדוגמא: ערך 5 = פרסומת אחרי כל 5 הודעות.

+ +
+ +
+ +

+ גם אם עברו X הודעות, אם הפרסומת הקודמת הוצגה לפני פחות מהזמן הזה - לא תוצג שוב. + הזינו 0 (או השאירו ריק) כדי לבטל את ההחרגה. +

+ +
+
+ } + + @if (mode === 'by_time') { +
+
+ +

פרסומת תוצג בערוץ אחת לכל פרק זמן. לדוגמא: ערך 60 = פרסומת בכל דקה.

+ +
+ +
+ +

+ גם אם עבר הזמן, אם לא נוספו לפחות X הודעות חדשות מאז הפרסומת הקודמת - לא תוצג שוב. + הזינו 0 (או השאירו ריק) כדי לבטל את ההחרגה. +

+ +
+
+ } +
+
+ +
+ +
+ +
diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss new file mode 100644 index 0000000..565068d --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss @@ -0,0 +1,89 @@ +:host { + display: block; + direction: rtl; +} + +.magnet-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 5rem; +} + +.card-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + + nb-icon { + font-size: 1.25rem; + } +} + +.intro { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.5; +} + +.setting-block { + margin-bottom: 1.25rem; + + &:last-child { + margin-bottom: 0; + } + + .bold { + font-weight: 600; + display: block; + margin-bottom: 0.25rem; + } + + .hint { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.4; + } + + textarea { + font-family: monospace; + font-size: 0.85rem; + } + + input[type="number"] { + max-width: 200px; + } +} + +.mode-options { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--divider-color, #edf1f7); +} + +.mode-section { + padding-top: 0.25rem; +} + +.save-bar { + position: sticky; + bottom: 0; + background: var(--background-basic-color-1); + padding: 0.75rem 0; + border-top: 1px solid var(--divider-color, #edf1f7); + display: flex; + justify-content: flex-start; + z-index: 5; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } +} diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts new file mode 100644 index 0000000..21de06c --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -0,0 +1,137 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbRadioModule, + NbToastrService, + NbToggleModule, +} from '@nebular/theme'; +import { AdminService } from '../../../services/admin.service'; +import { Setting } from '../../../models/setting.model'; + +type MagnetMode = 'by_messages' | 'by_time'; + +const MAGNET_KEYS = [ + 'magnet_enabled', + 'magnet_snippet', + 'magnet_mode', + 'magnet_per_messages', + 'magnet_min_time_seconds', + 'magnet_per_seconds', + 'magnet_min_messages_since', +] as const; + +@Component({ + selector: 'app-magnet-ads', + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbInputModule, + NbToggleModule, + NbRadioModule, + ], + templateUrl: './magnet-ads.component.html', + styleUrl: './magnet-ads.component.scss', +}) +export class MagnetAdsComponent implements OnInit { + enabled = false; + snippet = ''; + mode: MagnetMode = 'by_messages'; + perMessages = 5; + minTimeSeconds = 0; + perSeconds = 60; + minMessagesSince = 0; + + otherSettings: Setting[] = []; + inProgress = false; + + constructor( + private adminService: AdminService, + private toast: NbToastrService, + ) {} + + ngOnInit(): void { + this.adminService.getSettings().then(settings => this.load(settings || [])); + } + + private load(settings: Setting[]) { + this.otherSettings = []; + const known = new Set(MAGNET_KEYS); + + for (const s of settings) { + if (!known.has(s.key)) { + this.otherSettings.push(s); + continue; + } + const v = s.value; + switch (s.key) { + case 'magnet_enabled': + this.enabled = this.toBool(v); + break; + case 'magnet_snippet': + this.snippet = v == null ? '' : String(v); + break; + case 'magnet_mode': + this.mode = (String(v) === 'by_time' ? 'by_time' : 'by_messages'); + break; + case 'magnet_per_messages': + this.perMessages = this.toInt(v, 5); + break; + case 'magnet_min_time_seconds': + this.minTimeSeconds = this.toInt(v, 0); + break; + case 'magnet_per_seconds': + this.perSeconds = this.toInt(v, 60); + break; + case 'magnet_min_messages_since': + this.minMessagesSince = this.toInt(v, 0); + break; + } + } + } + + private toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v !== 0; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; + } + + private toInt(v: any, fallback: number): number { + if (v === null || v === undefined || v === '') return fallback; + const n = parseInt(String(v), 10); + return isNaN(n) ? fallback : n; + } + + save() { + this.inProgress = true; + const out: Setting[] = [...this.otherSettings]; + + if (this.enabled) out.push({ key: 'magnet_enabled', value: '1' as any }); + if (this.snippet?.trim()) out.push({ key: 'magnet_snippet', value: this.snippet as any }); + out.push({ key: 'magnet_mode', value: this.mode as any }); + + if (this.mode === 'by_messages') { + if (this.perMessages > 0) out.push({ key: 'magnet_per_messages', value: String(this.perMessages) as any }); + if (this.minTimeSeconds > 0) out.push({ key: 'magnet_min_time_seconds', value: String(this.minTimeSeconds) as any }); + } else { + if (this.perSeconds > 0) out.push({ key: 'magnet_per_seconds', value: String(this.perSeconds) as any }); + if (this.minMessagesSince > 0) out.push({ key: 'magnet_min_messages_since', value: String(this.minMessagesSince) as any }); + } + + this.adminService.setSettings(out) + .then(() => this.toast.success('', 'הגדרות מגנט נשמרו בהצלחה')) + .catch(() => this.toast.danger('', 'שגיאה בשמירת ההגדרות')) + .finally(() => this.inProgress = false); + } +} diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 5ed82b7..18a9f89 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -23,18 +23,24 @@ } - @for (message of messages; track message) { - - - @if (message.id === lastReadMessageId + 1) { -
-
-
לא נקראו
-
-
+ @for (item of items; track item.trackKey) { + @if (item.kind === 'message') { + + + @if (item.message.id === lastReadMessageId + 1) { +
+
+
לא נקראו
+
+
+ } + +
+ } @else { + + + } - -
} @if (messages.length === 0) { diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 5f6200d..99721c5 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -12,6 +12,7 @@ import { NbToastrService } from "@nebular/theme"; import { MessageComponent } from "./message/message.component"; +import { MagnetAdSlotComponent } from "./magnet-ad-slot/magnet-ad-slot.component"; import { firstValueFrom, interval } from 'rxjs'; import { ChatMessage, ChatService } from '../../../services/chat.service'; import { AuthService } from '../../../services/auth.service'; @@ -19,6 +20,7 @@ import { ActivatedRoute } from '@angular/router'; import { NotificationsService } from '../../../services/notifications.service'; import { User } from '../../../models/user.model'; import { AdminService } from '../../../services/admin.service'; +import { ChatItem, MagnetAdsService } from '../../../services/magnet-ads.service'; type LoadMsgOpt = { scrollDown?: boolean; @@ -45,7 +47,8 @@ type ScrollOpt = { NbButtonModule, NbListModule, NbBadgeModule, - MessageComponent + MessageComponent, + MagnetAdSlotComponent ], templateUrl: './chat.component.html', styleUrl: './chat.component.scss' @@ -53,6 +56,7 @@ type ScrollOpt = { export class ChatComponent implements OnInit, OnDestroy { private eventSource!: EventSource; messages: ChatMessage[] = []; + items: ChatItem[] = []; scheduledMessages!: ChatMessage[]; hideScheduledMessages: boolean = false; userInfo?: User; @@ -73,6 +77,7 @@ export class ChatComponent implements OnInit, OnDestroy { private _adminService: AdminService, private toastrService: NbToastrService, private notificationService: NotificationsService, + private magnetAds: MagnetAdsService, private zone: NgZone, private router: ActivatedRoute, ) { @@ -131,6 +136,8 @@ export class ChatComponent implements OnInit, OnDestroy { ngOnInit() { this.chatService.getEmojisList(true); + this.magnetAds.loadSettings().then(() => this.rebuildItems()); + this.initializeMessageListener(); this.keepAliveSSE(); @@ -172,6 +179,7 @@ export class ChatComponent implements OnInit, OnDestroy { if (this.hasNewMessages) break; this.zone.run(() => { this.messages.unshift(message.message); + this.rebuildItems(); this.thereNewMessages = !(message.message.author === this.userInfo?.username); this.setLastReadMessage(message.message.id!.toString()); if (this.userInfo?.privileges?.['writer'] && this.scheduledMessages && message.message.author === "Scheduled") { @@ -187,11 +195,13 @@ export class ChatComponent implements OnInit, OnDestroy { this.messages[index].deleted = true; this.messages[index].last_edit = message.message.last_edit; } + this.rebuildItems(); }); break; }; this.zone.run(() => { this.messages = this.messages.filter(m => m.id !== message.message.id); + this.rebuildItems(); }); break; case 'edit-message': @@ -203,6 +213,7 @@ export class ChatComponent implements OnInit, OnDestroy { // TOTO: Find the closest message to attach the retrieved message to // const closestIndex = this.messages.reduce } + this.rebuildItems(); }); break; case 'reaction': @@ -223,6 +234,10 @@ export class ChatComponent implements OnInit, OnDestroy { clearInterval(this.subLastHeartbeat); } + private rebuildItems() { + this.items = this.magnetAds.buildItems(this.messages); + } + async keepAliveSSE() { clearInterval(this.subLastHeartbeat); this.subLastHeartbeat = interval(10000) @@ -313,6 +328,7 @@ export class ChatComponent implements OnInit, OnDestroy { this.hasOldMessages = response.length >= this.limit; } this.offset = Math.min(...this.messages.map(m => m.id!)); + this.rebuildItems(); setTimeout(() => { opt.messageId && this.scrollToId({ messageId: opt.messageId, smooth: false, mark: opt.mark }); }, 300); diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html new file mode 100644 index 0000000..7372252 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html @@ -0,0 +1 @@ +
diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss new file mode 100644 index 0000000..90da96a --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss @@ -0,0 +1,19 @@ +:host { + display: block; + width: 100%; +} + +.magnet-ad-host { + display: block; + width: 100%; + min-height: 1px; + + &.collapsed { + display: none; + } + + ::ng-deep .magnet-ad-content { + display: block; + width: 100%; + } +} diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts new file mode 100644 index 0000000..4a76045 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts @@ -0,0 +1,126 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, +} from '@angular/core'; +import { MagnetAdsService } from '../../../../services/magnet-ads.service'; + +@Component({ + selector: 'app-magnet-ad-slot', + standalone: true, + templateUrl: './magnet-ad-slot.component.html', + styleUrl: './magnet-ad-slot.component.scss', +}) +export class MagnetAdSlotComponent implements AfterViewInit, OnDestroy { + @Input() slotKey: string = ''; + + @ViewChild('host', { static: true }) hostRef!: ElementRef; + + private observer?: IntersectionObserver; + private rendered = false; + private collapseTimer?: any; + collapsed = false; + + constructor(private magnet: MagnetAdsService) {} + + ngAfterViewInit(): void { + if (typeof IntersectionObserver === 'undefined') { + this.render(); + return; + } + + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !this.rendered) { + this.render(); + this.observer?.disconnect(); + break; + } + } + }, + { rootMargin: '200px 0px' }, + ); + this.observer.observe(this.hostRef.nativeElement); + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + if (this.collapseTimer) clearTimeout(this.collapseTimer); + } + + private render(): void { + if (this.rendered) return; + this.rendered = true; + + const settings = this.magnet.getSettings(); + const snippet = settings?.snippet?.trim(); + if (!snippet) { + this.collapse(); + return; + } + + const host = this.hostRef.nativeElement; + host.innerHTML = ''; + + const container = document.createElement('div'); + container.className = 'magnet-ad-content'; + host.appendChild(container); + + try { + this.injectSnippet(container, snippet); + } catch { + this.collapse(); + return; + } + + this.collapseTimer = setTimeout(() => this.collapseIfEmpty(), 5000); + } + + private injectSnippet(container: HTMLElement, snippet: string): void { + const template = document.createElement('template'); + template.innerHTML = snippet; + + const fragment = template.content; + const scripts: HTMLScriptElement[] = []; + fragment.querySelectorAll('script').forEach((s) => { + scripts.push(s as HTMLScriptElement); + }); + + container.appendChild(fragment); + + for (const oldScript of scripts) { + const newScript = document.createElement('script'); + for (const attr of Array.from(oldScript.attributes)) { + newScript.setAttribute(attr.name, attr.value); + } + if (oldScript.textContent) newScript.textContent = oldScript.textContent; + oldScript.parentNode?.replaceChild(newScript, oldScript); + } + } + + private collapseIfEmpty(): void { + const host = this.hostRef.nativeElement; + const content = host.querySelector('.magnet-ad-content') as HTMLElement | null; + if (!content) { + this.collapse(); + return; + } + const hasMeaningfulContent = + content.children.length > 0 || (content.textContent?.trim().length ?? 0) > 0; + const hasHeight = content.offsetHeight > 0; + + if (!hasMeaningfulContent || !hasHeight) { + this.collapse(); + } + } + + private collapse(): void { + this.collapsed = true; + const host = this.hostRef.nativeElement; + host.innerHTML = ''; + } +} diff --git a/frontend/src/app/services/magnet-ads.service.ts b/frontend/src/app/services/magnet-ads.service.ts new file mode 100644 index 0000000..e49eba8 --- /dev/null +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@angular/core'; +import { ChatMessage } from './chat.service'; + +export interface MagnetSettings { + enabled: boolean; + snippet: string; + mode: 'by_messages' | 'by_time' | string; + perMessages: number; + minTimeSeconds: number; + perSeconds: number; + minMessagesSinceLast: number; +} + +export type ChatItem = + | { kind: 'message'; message: ChatMessage; trackKey: string } + | { kind: 'ad'; trackKey: string }; + +@Injectable({ providedIn: 'root' }) +export class MagnetAdsService { + private settings: MagnetSettings | null = null; + private settingsPromise: Promise | null = null; + + loadSettings(force = false): Promise { + if (this.settings && !force) return Promise.resolve(this.settings); + if (this.settingsPromise && !force) return this.settingsPromise; + + this.settingsPromise = fetch('/api/ads/magnet') + .then(r => r.ok ? r.json() : null) + .then((data: MagnetSettings | null) => { + this.settings = data; + return data; + }) + .catch(() => null); + + return this.settingsPromise; + } + + getSettings(): MagnetSettings | null { + return this.settings; + } + + /** + * Build a list of chat items (messages + ad slots) for the chat component. + * Input: messages array as held in chat.component (newest at index 0). + * Output: items in the same order (newest first), with ad slots interleaved + * according to the configured rules. The list is rendered by a flex-column-reverse + * container, so ad slots appear visually between messages in chronological order. + */ + buildItems(messages: ChatMessage[]): ChatItem[] { + if (!messages || messages.length === 0) return []; + + const s = this.settings; + if (!s || !s.enabled || !s.snippet?.trim()) { + return messages.map(m => ({ + kind: 'message', + message: m, + trackKey: `m-${m.id}`, + } as ChatItem)); + } + + const chrono = [...messages].reverse(); + const out: ChatItem[] = []; + + if (s.mode === 'by_time') { + out.push(...this.buildByTime(chrono, s)); + } else { + out.push(...this.buildByMessages(chrono, s)); + } + + return out.reverse(); + } + + private buildByMessages(chrono: ChatMessage[], s: MagnetSettings): ChatItem[] { + const per = Math.max(1, s.perMessages || 5); + const minTime = Math.max(0, s.minTimeSeconds || 0); + const out: ChatItem[] = []; + + let countSinceLast = 0; + let lastAdTime: number | null = null; + + for (const m of chrono) { + out.push({ kind: 'message', message: m, trackKey: `m-${m.id}` }); + countSinceLast++; + + if (countSinceLast >= per) { + const msgTime = this.toEpoch(m.timestamp); + const enoughTimePassed = !minTime || lastAdTime === null || + (msgTime !== null && (msgTime - lastAdTime) >= minTime * 1000); + + if (enoughTimePassed) { + out.push({ kind: 'ad', trackKey: `ad-after-${m.id}` }); + countSinceLast = 0; + if (msgTime !== null) lastAdTime = msgTime; + } + } + } + + return out; + } + + private buildByTime(chrono: ChatMessage[], s: MagnetSettings): ChatItem[] { + const per = Math.max(1, s.perSeconds || 60); + const minMsgs = Math.max(0, s.minMessagesSinceLast || 0); + const out: ChatItem[] = []; + + let lastAdTime: number | null = null; + let msgsSinceLast = 0; + + for (const m of chrono) { + out.push({ kind: 'message', message: m, trackKey: `m-${m.id}` }); + msgsSinceLast++; + + const msgTime = this.toEpoch(m.timestamp); + if (msgTime === null) continue; + + if (lastAdTime === null) { + lastAdTime = msgTime; + continue; + } + + const elapsed = (msgTime - lastAdTime) / 1000; + if (elapsed >= per && msgsSinceLast >= minMsgs) { + out.push({ kind: 'ad', trackKey: `ad-after-${m.id}` }); + lastAdTime = msgTime; + msgsSinceLast = 0; + } + } + + return out; + } + + private toEpoch(ts: any): number | null { + if (!ts) return null; + if (ts instanceof Date) return ts.getTime(); + const t = new Date(ts).getTime(); + return isNaN(t) ? null : t; + } +} From 925d900e627f2cf9cc9b7a5ec3fa7c573a789b95 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 03:24:26 +0000 Subject: [PATCH 05/16] feat: add Magnet click & earnings stats panel in admin Add a publisher API key field and a stats viewer in the Magnet admin tab. A new admin-protected endpoint GET /api/admin/magnet/stats proxies the request to Magnet's publisher-stats function so the API key never reaches the browser. The stats panel shows clicks and earnings (today / week / month) in two columns with currency formatting, displays the site domain, and has a refresh button with a last-updated timestamp. Errors from Magnet (400 invalid key, 404 unapproved site) are surfaced in Hebrew. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/ads.go | 42 +++++++ backend/main.go | 1 + backend/settings.go | 4 + .../magnet-ads/magnet-ads.component.html | 114 ++++++++++++++++++ .../magnet-ads/magnet-ads.component.scss | 107 ++++++++++++++++ .../admin/magnet-ads/magnet-ads.component.ts | 72 +++++++++++ 6 files changed, 340 insertions(+) diff --git a/backend/ads.go b/backend/ads.go index 7d544d0..bbda9e3 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "io" "net/http" + "net/url" + "time" ) type AdsSettings struct { @@ -47,3 +50,42 @@ func getMagnetAdsSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } + +const magnetStatsURL = "https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/publisher-stats" + +var magnetStatsClient = &http.Client{Timeout: 15 * time.Second} + +func getMagnetStats(w http.ResponseWriter, r *http.Request) { + apiKey := settingConfig.MagnetApiKey + if apiKey == "" { + http.Error(w, `{"error":"missing_api_key","message":"Magnet API key is not configured"}`, http.StatusBadRequest) + return + } + + q := url.Values{} + q.Set("k", apiKey) + reqURL := magnetStatsURL + "?" + q.Encode() + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reqURL, nil) + if err != nil { + http.Error(w, `{"error":"request_build_failed"}`, http.StatusInternalServerError) + return + } + + resp, err := magnetStatsClient.Do(req) + if err != nil { + http.Error(w, `{"error":"upstream_unreachable"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, `{"error":"upstream_read_failed"}`, http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} diff --git a/backend/main.go b/backend/main.go index 1bbeac0..956364a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -104,6 +104,7 @@ func main() { protected.Post("/privilegs-users/set", protectedWithPrivilege(Admin, setPrivilegeUsers)) protected.Get("/settings/get", protectedWithPrivilege(Admin, getSettings)) protected.Post("/settings/set", protectedWithPrivilege(Admin, setSettings)) + protected.Get("/magnet/stats", protectedWithPrivilege(Admin, getMagnetStats)) protected.Get("/reports/get", protectedWithPrivilege(Admin, getReports)) protected.Post("/reports/set", protectedWithPrivilege(Admin, setReports)) }) diff --git a/backend/settings.go b/backend/settings.go index 4aa3d21..aae2e46 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -48,6 +48,7 @@ type SettingConfig struct { MagnetMinTimeSeconds int64 MagnetPerSeconds int64 MagnetMinMessagesSince int64 + MagnetApiKey string } type Setting struct { @@ -214,6 +215,9 @@ func (s *Settings) ToConfig() *SettingConfig { case "magnet_min_messages_since": config.MagnetMinMessagesSince = setting.GetInt() + + case "magnet_api_key": + config.MagnetApiKey = setting.GetString() } } diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html index 8c7b03a..cdce902 100644 --- a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -40,6 +40,31 @@ + + + + מפתח API למגנט + + +
+ +

+ מפתח שניתן לכם ע"י מגנט. נדרש לצפייה בנתוני הקלקות ותגמולים. שמירת ההגדרות + תשמור גם את המפתח. המפתח לא נחשף לצד הלקוח - הקריאה למגנט מתבצעת מהשרת. +

+ +
+
+
+ @@ -106,6 +131,95 @@ + + + + נתוני הקלקות ותגמולים + + + +

+ נתונים אלו מתקבלים ישירות ממגנט בזמן אמת לפי המפתח שמוגדר למעלה. + רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ, בש"ח. + "היום" מתחיל מ-00:00 שעון ישראל. "שבוע"/"חודש" = 7/30 ימים אחורה. + מגנט שומר תוצאות במטמון לכ-30 שניות. +

+ +
+ @if (!stats && !statsError) { + + } @else { + + @if (statsLoadedAt) { + עודכן: {{ statsLoadedAt | date:'HH:mm:ss' }} + } + } +
+ + @if (statsError) { +
+ + {{ statsError }} +
+ } + + @if (stats && !statsError) { + @if (stats.site?.domain) { +
+ אתר: + {{ stats.site?.domain }} +
+ } + +
+
+
+ + הקלקות +
+
+ היום + {{ formatNumber(stats.clicks?.today) }} +
+
+ שבוע אחרון + {{ formatNumber(stats.clicks?.week) }} +
+
+ חודש אחרון + {{ formatNumber(stats.clicks?.month) }} +
+
+ +
+
+ + תגמולים ({{ stats.currency || 'ILS' }} - ללא מע"מ) +
+
+ היום + {{ formatMoney(stats.earnings?.today, stats.currency) }} +
+
+ שבוע אחרון + {{ formatMoney(stats.earnings?.week, stats.currency) }} +
+
+ חודש אחרון + {{ formatMoney(stats.earnings?.month, stats.currency) }} +
+
+
+ } +
+
+