nodebb-plugin-gemach-directory 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # nodebb-plugin-gemach-directory
2
+
3
+ רשימה מסודרת של גמ"חים בפורום - מסוננת לפי עיר וקטגוריה, עם טופס הוספה
4
+ ותור אישור למנהל (שום גמ"ח לא מוצג עד שמאשרים אותו).
5
+
6
+ ## מה זה כולל
7
+
8
+ - **בשרת** (`library.js`, מותקן כפלאגין) - אחסון הגמ"חים, תור אישור,
9
+ התראה למנהלים כשמוצע גמ"ח חדש.
10
+ - **בדפדפן** (`static/lib/gemach-directory-client.js`, מודבק ב-Custom JS) -
11
+ הצגת הרשימה, טופס ההוספה, ופאנל האישור שרואים רק מנהלים.
12
+
13
+ ## התקנה - שלב 1: הפלאגין בשרת
14
+
15
+ 1. פרסמו את התיקייה הזו ל-npm (`npm publish --access public` מתוך תיקיית
16
+ הפלאגין - כמו שכבר עשיתם לפלאגין הביטוח).
17
+ 2. ב-ACP → הרחבות → תוספים → מציאת תוספים - חפשו `gemach-directory`,
18
+ התקינו, הפעילו, ואז "בניה מחדש והפעלה מחדש".
19
+
20
+ ## התקנה - שלב 2: קוד הלקוח
21
+
22
+ מדביקים את כל התוכן של `static/lib/gemach-directory-client.js` ב-ACP →
23
+ מראה חיצוני → תוכן מותאם אישית → Custom JavaScript - **מתחת** לקוד הקיים
24
+ (אשף הרכב, ביטוח וכו'), לא במקומם.
25
+
26
+ ## התקנה - שלב 3: יצירת נושא "רשימת גמחים"
27
+
28
+ הווידג'ט לא זקוק לעמוד/כתובת מיוחדת - הוא מזהה **לפי כותרת הנושא**. פשוט
29
+ פותחים נושא חדש בקטגוריה המתאימה (למשל קטגוריה ייעודית "גמ"חים"), עם
30
+ כותרת שמכילה את המילים **"רשימת גמחים"** (עם או בלי גרשיים - למשל
31
+ "רשימת גמ״חים - לחצו כאן להוספה"), ובגוף הנושא כותבים כל טקסט הסבר
32
+ שרוצים (למשל "כאן תמצאו את כל הגמ"חים הפעילים בקהילה"). הווידג'ט המלא
33
+ (סינון + רשימה + כפתור הוספה) יופיע אוטומטית **מתחת** לטקסט הזה - בלי
34
+ לגעת בו.
35
+
36
+ ## איך זה עובד בפועל
37
+
38
+ 1. כל משתמש מחובר לוחץ "+ הוספת גמ"ח", ממלא שם/עיר/קטגוריה/איש קשר/תיאור.
39
+ 2. ההצעה נשמרת כ"ממתינה" - **לא מוצגת לאף אחד**, כולל למי ששלח אותה.
40
+ 3. כל חברי קבוצת "administrators" מקבלים התראת פעמון על ההצעה החדשה.
41
+ 4. מנהל שנכנס לעמוד הרשימה רואה מעל הרשימה הציבורית פאנל "ממתינים לאישור"
42
+ עם כפתורי אשר/דחה לכל הצעה. אישור מעביר מיידית לרשימה הציבורית, דחייה
43
+ מוחקת לגמרי - אין הצטברות "זבל".
44
+ 5. עיר וקטגוריה: יש רשימה קבועה של ערים נפוצות, עם אפשרות "עיר אחרת..."
45
+ שפותחת שדה חופשי - כך שאף אחד לא "נתקע" אם העיר שלו לא ברשימה.
46
+
47
+ ## התאמה אישית
48
+
49
+ רשימת הערים והקטגוריות מוגדרת בתחילת `gemach-directory-client.js`
50
+ (המשתנים `CITIES` ו-`CATEGORIES`) - אפשר להוסיף/להחליף לפי הצורך, ולפרסם
51
+ מחדש את קוד ה-Custom JS.
package/library.js ADDED
@@ -0,0 +1,176 @@
1
+ /*
2
+ * nodebb-plugin-gemach-directory
3
+ *
4
+ * מה זה עושה:
5
+ * - שומר בשרת רשימת גמ"חים (שם, עיר, קטגוריה, איש קשר, תיאור), עם שני
6
+ * מצבים: "ממתין לאישור" ו-"מאושר". כל משתמש מחובר יכול "להציע" גמ"ח
7
+ * חדש (SocketPlugins.gemachDirectory.submit) - הוא נשמר כ"ממתין" ולא
8
+ * מוצג לאף אחד עד שמנהל מאשר אותו.
9
+ * - כשמוצע גמ"ח חדש, נשלחת התראת NodeBB אמיתית (פעמון) לכל חברי קבוצת
10
+ * "administrators" - כדי שמנהל ידע שיש משהו ממתין לאישור.
11
+ * - אישור/דחייה (SocketPlugins.gemachDirectory.approve/reject) פתוחים רק
12
+ * למנהלים (נבדק בשרת, לא רק בממשק) - אישור מעביר את הגמ"ח לרשימה
13
+ * הציבורית באופן מיידי, דחייה מוחקת אותו לגמרי (בלי לצבור "זבל").
14
+ * - הרשימה הציבורית (SocketPlugins.gemachDirectory.listApproved) פתוחה
15
+ * לכולם, כולל גולשים לא-מחוברים.
16
+ *
17
+ * חלק הלקוח (הצגת הרשימה, הטופס להוספה, פאנל האישור למנהל) *לא* נמצא כאן -
18
+ * הוא קובץ נפרד (gemach-directory-client.js) שמודבק ב-Custom JS של הפורום,
19
+ * באותה שיטה שכבר עובדת בפורום הזה. כאן יש רק את חלק השרת: אחסון + לוגיקת
20
+ * אישור - כי זה מה שדורש גישת שרת (SSH/npm install) ולא ניתן לעשות
21
+ * מ-Custom JS בלבד.
22
+ */
23
+ 'use strict';
24
+
25
+ const db = require.main.require('./src/database');
26
+ const user = require.main.require('./src/user');
27
+ const groups = require.main.require('./src/groups');
28
+ const notifications = require.main.require('./src/notifications');
29
+ const SocketPlugins = require.main.require('./src/socket.io/plugins');
30
+
31
+ const COUNTER_OBJECT = 'gemachDirectory:counters';
32
+ const PENDING_SET = 'gemachDirectory:pending';
33
+ const APPROVED_SET = 'gemachDirectory:approved';
34
+ const GEMACH_KEY = id => `gemachDirectory:item:${id}`;
35
+
36
+ const MAX_LENGTHS = {
37
+ name: 120,
38
+ city: 60,
39
+ category: 60,
40
+ contact: 120,
41
+ description: 500,
42
+ };
43
+
44
+ const plugin = {};
45
+
46
+ plugin.init = async function () {
47
+ registerSocketHandlers();
48
+ console.log('[nodebb-plugin-gemach-directory] נטען בהצלחה');
49
+ };
50
+
51
+ function registerSocketHandlers() {
52
+ SocketPlugins.gemachDirectory = SocketPlugins.gemachDirectory || {};
53
+
54
+ // כל משתמש מחובר יכול להציע גמ"ח - נשמר כ"ממתין" בלבד, לא מוצג לאף אחד
55
+ // עד שמנהל מאשר. socket.uid מגיע מ-NodeBB עצמו, אי אפשר לזייף אותו.
56
+ SocketPlugins.gemachDirectory.submit = async function (socket, data) {
57
+ requireLogin(socket);
58
+
59
+ const name = sanitizeText(data && data.name, MAX_LENGTHS.name);
60
+ const city = sanitizeText(data && data.city, MAX_LENGTHS.city);
61
+ const category = sanitizeText(data && data.category, MAX_LENGTHS.category);
62
+ const contact = sanitizeText(data && data.contact, MAX_LENGTHS.contact);
63
+ const description = sanitizeText(data && data.description, MAX_LENGTHS.description);
64
+
65
+ if (!name || !city || !category || !contact) {
66
+ throw new Error('[[error:invalid-data]]');
67
+ }
68
+
69
+ const id = await db.incrObjectField(COUNTER_OBJECT, 'nextId');
70
+ const createdAt = Date.now();
71
+ const gemach = {
72
+ id,
73
+ name,
74
+ city,
75
+ category,
76
+ contact,
77
+ description,
78
+ status: 'pending',
79
+ submittedBy: socket.uid,
80
+ createdAt,
81
+ };
82
+
83
+ await db.setObject(GEMACH_KEY(id), gemach);
84
+ await db.sortedSetAdd(PENDING_SET, createdAt, id);
85
+
86
+ await notifyAdmins(gemach);
87
+
88
+ return { ok: true };
89
+ };
90
+
91
+ // רשימת הגמ"חים המאושרים - פתוח לכולם, גם גולשים לא-מחוברים.
92
+ SocketPlugins.gemachDirectory.listApproved = async function () {
93
+ return getGemachsFromSet(APPROVED_SET, true);
94
+ };
95
+
96
+ // רשימת הגמ"חים הממתינים לאישור - מנהלים בלבד.
97
+ SocketPlugins.gemachDirectory.listPending = async function (socket) {
98
+ await requireAdmin(socket);
99
+ return getGemachsFromSet(PENDING_SET, false);
100
+ };
101
+
102
+ // אישור גמ"ח - מנהלים בלבד. מעביר מיידית לרשימה הציבורית.
103
+ SocketPlugins.gemachDirectory.approve = async function (socket, data) {
104
+ await requireAdmin(socket);
105
+ const id = data && data.id;
106
+ if (!id) throw new Error('[[error:invalid-data]]');
107
+
108
+ await db.sortedSetRemove(PENDING_SET, id);
109
+ await db.sortedSetAdd(APPROVED_SET, Date.now(), id);
110
+ await db.setObjectField(GEMACH_KEY(id), 'status', 'approved');
111
+
112
+ return { ok: true };
113
+ };
114
+
115
+ // דחיית גמ"ח - מנהלים בלבד. מוחק לגמרי, בלי להשאיר "זבל".
116
+ SocketPlugins.gemachDirectory.reject = async function (socket, data) {
117
+ await requireAdmin(socket);
118
+ const id = data && data.id;
119
+ if (!id) throw new Error('[[error:invalid-data]]');
120
+
121
+ await db.sortedSetRemove(PENDING_SET, id);
122
+ await db.delete(GEMACH_KEY(id));
123
+
124
+ return { ok: true };
125
+ };
126
+ }
127
+
128
+ async function getGemachsFromSet(setKey, newestFirst) {
129
+ const ids = newestFirst ?
130
+ await db.getSortedSetRevRange(setKey, 0, -1) :
131
+ await db.getSortedSetRange(setKey, 0, -1);
132
+ if (!ids.length) return [];
133
+ const gemachs = await db.getObjects(ids.map(GEMACH_KEY));
134
+ return gemachs.filter(Boolean);
135
+ }
136
+
137
+ async function notifyAdmins(gemach) {
138
+ const adminUids = await groups.getMembers('administrators', 0, -1);
139
+ if (!adminUids || !adminUids.length) return;
140
+
141
+ const notification = await notifications.create({
142
+ type: 'gemach-directory-pending',
143
+ // nid כולל את מזהה הגמ"ח - כך שכל הצעה חדשה היא התראה "חדשה" נפרדת
144
+ nid: `gemach-directory:${gemach.id}`,
145
+ bodyShort: `גמ"ח חדש ממתין לאישור: ${gemach.name} (${gemach.city})`,
146
+ path: '/',
147
+ from: gemach.submittedBy,
148
+ });
149
+ await notifications.push(notification, adminUids);
150
+ }
151
+
152
+ function requireLogin(socket) {
153
+ if (!socket.uid) {
154
+ throw new Error('[[error:not-logged-in]]');
155
+ }
156
+ }
157
+
158
+ async function requireAdmin(socket) {
159
+ requireLogin(socket);
160
+ const isAdmin = await user.isAdministrator(socket.uid);
161
+ if (!isAdmin) {
162
+ throw new Error('[[error:no-privileges]]');
163
+ }
164
+ }
165
+
166
+ // חיתוך אורך + הסרת תווי בקרה - הגנת שרת בסיסית. ההגנה האמיתית מפני
167
+ // XSS היא ב-escaping בצד הלקוח בזמן הצגה (ראו gemach-directory-client.js),
168
+ // כי הנתונים האלה מוזרקים שם ישירות ל-innerHTML.
169
+ function sanitizeText(value, maxLength) {
170
+ if (!value || typeof value !== 'string') return '';
171
+ // eslint-disable-next-line no-control-regex
172
+ const stripped = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
173
+ return stripped.slice(0, maxLength);
174
+ }
175
+
176
+ module.exports = plugin;
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "nodebb-plugin-gemach-directory",
3
+ "version": "1.0.0",
4
+ "description": "Server-side storage + admin approval queue for a community gemach directory, filterable by city and category.",
5
+ "main": "library.js",
6
+ "keywords": [
7
+ "nodebb",
8
+ "nodebb-plugin"
9
+ ],
10
+ "nbbpm": {
11
+ "compatibility": "^2.0.0 || ^3.0.0 || ^4.0.0"
12
+ },
13
+ "license": "MIT"
14
+ }
package/plugin.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "nodebb-plugin-gemach-directory",
3
+ "name": "Gemach Directory",
4
+ "description": "Server-side storage + admin approval queue for a community gemach (free-lending) directory, filterable by city and category.",
5
+ "library": "./library.js",
6
+ "hooks": [
7
+ { "hook": "static:app.load", "method": "init" }
8
+ ]
9
+ }
@@ -0,0 +1,346 @@
1
+ /*
2
+ * להדבקה ב: Admin Control Panel -> Appearance -> Custom -> Custom JavaScript
3
+ * (באותה תיבה שבה כבר מודבק קוד "עזרה בקניית רכב" ו"ביטוח רכב" - אפשר
4
+ * להדביק את כל הקבצים ברצף, זה בסדר גמור שיש כמה סקריפטים ב-Custom JS).
5
+ *
6
+ * דורש שהפלאגין nodebb-plugin-gemach-directory מותקן ופעיל בשרת (ראו
7
+ * README.md בתיקיית הפלאגין) - הסקריפט הזה רק בונה את ה-UI ומדבר עם
8
+ * הפלאגין דרך socket.io (plugins.gemachDirectory.*), בלי לגעת ב-DB ישירות.
9
+ *
10
+ * מה זה עושה:
11
+ * 1. פותחים נושא (topic) חדש בפורום, עם כותרת שמכילה בדיוק את המילים
12
+ * "רשימת גמחים" (עם או בלי גרשיים, למשל "רשימת גמ״חים - לחצו להוספה") -
13
+ * הסקריפט מזהה את הנושא הזה לפי הכותרת, ומוסיף בתוך הפוסט הראשון שלו
14
+ * (בלי למחוק את מה שכבר כתוב שם) את כל הווידג'ט: שני תפריטי סינון
15
+ * (עיר וקטגוריה), כפתור "הוספת גמ"ח", ואת רשימת הגמ"חים המאושרים.
16
+ * 2. לחיצה על "הוספת גמ"ח" פותחת טופס קצר (שם, עיר, קטגוריה, איש קשר,
17
+ * תיאור). שליחה שומרת אותו בשרת כ"ממתין לאישור" - הוא *לא* מוצג לאף
18
+ * אחד, כולל מי ששלח אותו, עד שמנהל מאשר.
19
+ * 3. למנהלים בלבד (isAdmin) - מוצג מעל הרשימה הציבורית פאנל "ממתינים
20
+ * לאישור" עם כפתורי אשר/דחה לכל הצעה. אישור מעביר מיידית לרשימה
21
+ * הציבורית, דחייה מוחקת לגמרי.
22
+ *
23
+ * אבטחה: הנתונים שמוזנים ע"י משתמשים (שם/עיר/תיאור וכו') מוצגים כאן דרך
24
+ * escapeHtml לפני הזרקה ל-innerHTML, כדי למנוע החדרת קוד (XSS) דרך שדות
25
+ * חופשיים כמו "עיר - אחר" או "תיאור".
26
+ */
27
+ (function () {
28
+ 'use strict';
29
+
30
+ var CITIES = ['ירושלים', 'בני ברק', 'מודיעין עילית', 'ביתר עילית', 'אלעד', 'אשדוד', 'צפת', 'רכסים'];
31
+ var CATEGORIES = ['כלי עבודה', 'ציוד רפואי', 'ציוד לרכב', 'ציוד לתינוקות', 'ציוד לאירועים', 'ריהוט', 'ספרים ולימוד', 'אחר'];
32
+ var OTHER_VALUE = '__other__';
33
+
34
+ var STYLE_ID = 'gemach-directory-style';
35
+ var APP_ID = 'gemach-directory-app';
36
+ var MODAL_ID = 'gemach-directory-modal';
37
+
38
+ function escapeHtml(str) {
39
+ var div = document.createElement('div');
40
+ div.textContent = str === null || str === undefined ? '' : String(str);
41
+ return div.innerHTML;
42
+ }
43
+
44
+ function getSocket() {
45
+ return (typeof window.socket !== 'undefined') ? window.socket : null;
46
+ }
47
+
48
+ function isAdmin() {
49
+ try {
50
+ return !!(window.app && app.user && app.user.isAdmin);
51
+ } catch (e) {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // ============ זיהוי נושא "רשימת גמחים" לפי הכותרת ============
57
+
58
+ // משאיר רק אותיות עבריות - מוריד כל סוג של גרש/מרכאות (יש כמה תווי
59
+ // יוניקוד שונים שנראים זהים: ", ', ׳, ״, ", ", וכו'), רווחים, מקפים
60
+ // וכל תו אחר. כך ההתאמה לא תלויה בדיוק באיזה תו הקלדנו/הודבק.
61
+ function hebrewLettersOnly(str) {
62
+ return String(str || '').replace(/[^א-ת]/g, '');
63
+ }
64
+
65
+ function isDirectoryTopic() {
66
+ try {
67
+ var title = (window.ajaxify && ajaxify.data && ajaxify.data.title) || '';
68
+ var lettersOnly = hebrewLettersOnly(title);
69
+ // "רשימ" מכסה גם רשימת/רשימה, "גמח" מכסה גם גמ"ח/גמחים/גמ"חים
70
+ return lettersOnly.indexOf('רשימ') !== -1 && lettersOnly.indexOf('גמח') !== -1;
71
+ } catch (e) {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function findFirstPostContent() {
77
+ return document.querySelector('[component="post"] [component="post/content"]') ||
78
+ document.querySelector('[component="topic/content"]');
79
+ }
80
+
81
+ // ============ עיצוב ============
82
+
83
+ function injectStyles() {
84
+ if (document.getElementById(STYLE_ID)) return;
85
+ var css = ''
86
+ + '#' + APP_ID + '{font-family:Rubik,Arial,sans-serif;direction:rtl;margin-top:18px;'
87
+ + 'padding-top:14px;border-top:1px solid #e9e3d8;}'
88
+ + '#' + APP_ID + ' .gd-toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:16px;}'
89
+ + '#' + APP_ID + ' select{padding:8px 10px;border:1px solid #e9e3d8;border-radius:8px;background:#fff;'
90
+ + 'font-family:inherit;font-size:13.5px;color:#332f28;}'
91
+ + '#' + APP_ID + ' .gd-add-btn{margin-inline-start:auto;padding:9px 18px;border-radius:20px;border:none;'
92
+ + 'background:#4f6b57;color:#fff;font-weight:600;font-size:13.5px;cursor:pointer;font-family:inherit;}'
93
+ + '#' + APP_ID + ' .gd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;}'
94
+ + '#' + APP_ID + ' .gd-card{background:#faf7f2;border:1px solid #e9e3d8;border-radius:12px;padding:14px 16px;}'
95
+ + '#' + APP_ID + ' .gd-card-title{font-family:"Frank Ruhl Libre",serif;font-size:16px;color:#332f28;margin-bottom:4px;}'
96
+ + '#' + APP_ID + ' .gd-card-meta{font-size:12px;color:#867d6e;margin-bottom:8px;}'
97
+ + '#' + APP_ID + ' .gd-card-desc{font-size:13px;color:#5c5346;margin-bottom:8px;line-height:1.5;}'
98
+ + '#' + APP_ID + ' .gd-card-contact{font-size:13px;color:#4f6b57;font-weight:600;}'
99
+ + '#' + APP_ID + ' .gd-empty{color:#867d6e;font-size:13.5px;padding:10px 0;}'
100
+ + '#' + APP_ID + ' .gd-pending{background:#fdf0e2;border:1px solid #f0d5ac;border-radius:12px;'
101
+ + 'padding:14px 16px;margin-bottom:18px;}'
102
+ + '#' + APP_ID + ' .gd-pending h4{margin:0 0 10px;font-size:14px;color:#9a5b1e;}'
103
+ + '#' + APP_ID + ' .gd-pending-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;'
104
+ + 'padding:8px 0;border-bottom:1px solid #f0d5ac;font-size:13px;color:#332f28;}'
105
+ + '#' + APP_ID + ' .gd-pending-row:last-child{border-bottom:none;}'
106
+ + '#' + APP_ID + ' .gd-pending-row .gd-flex{flex:1;min-width:180px;}'
107
+ + '#' + APP_ID + ' .gd-btn-ok{background:#4f6b57;color:#fff;border:none;border-radius:6px;padding:6px 12px;'
108
+ + 'font-size:12.5px;cursor:pointer;font-family:inherit;}'
109
+ + '#' + APP_ID + ' .gd-btn-no{background:#fff;color:#a14444;border:1px solid #e0b8b8;border-radius:6px;'
110
+ + 'padding:6px 12px;font-size:12.5px;cursor:pointer;font-family:inherit;}'
111
+ + '#' + MODAL_ID + '-overlay{position:fixed;inset:0;background:rgba(30,26,20,.5);z-index:2000;'
112
+ + 'display:flex;align-items:center;justify-content:center;padding:20px;}'
113
+ + '#' + MODAL_ID + '{background:#fff;border-radius:16px;padding:26px;max-width:420px;width:100%;'
114
+ + 'font-family:Rubik,Arial,sans-serif;direction:rtl;max-height:90vh;overflow:auto;}'
115
+ + '#' + MODAL_ID + ' h3{font-family:"Frank Ruhl Libre",serif;margin:0 0 16px;font-size:20px;color:#332f28;}'
116
+ + '#' + MODAL_ID + ' label{display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:#332f28;}'
117
+ + '#' + MODAL_ID + ' .gd-field{margin-bottom:14px;}'
118
+ + '#' + MODAL_ID + ' input[type=text],#' + MODAL_ID + ' select,#' + MODAL_ID + ' textarea{width:100%;'
119
+ + 'padding:10px 12px;border:1px solid #e9e3d8;border-radius:8px;font-family:inherit;font-size:14px;'
120
+ + 'box-sizing:border-box;color:#332f28;}'
121
+ + '#' + MODAL_ID + ' textarea{resize:vertical;min-height:70px;}'
122
+ + '#' + MODAL_ID + ' .gd-actions{display:flex;gap:10px;margin-top:18px;}'
123
+ + '#' + MODAL_ID + ' .gd-submit{flex:1;padding:11px;border-radius:10px;border:none;background:#4f6b57;'
124
+ + 'color:#fff;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;}'
125
+ + '#' + MODAL_ID + ' .gd-cancel{padding:11px 18px;border-radius:10px;border:1px solid #e9e3d8;'
126
+ + 'background:#fff;color:#867d6e;font-size:14px;cursor:pointer;font-family:inherit;}'
127
+ + '#' + MODAL_ID + ' .gd-status{font-size:13px;margin-top:10px;min-height:16px;}'
128
+ + '#' + MODAL_ID + ' .gd-status.ok{color:#4f7a3b;}'
129
+ + '#' + MODAL_ID + ' .gd-status.err{color:#a14444;}';
130
+ var style = document.createElement('style');
131
+ style.id = STYLE_ID;
132
+ style.textContent = css;
133
+ document.head.appendChild(style);
134
+ }
135
+
136
+ // ============ בניית תוכן ============
137
+
138
+ function optionsHTML(list, includeOtherOnly) {
139
+ return list.map(function (item) {
140
+ return '<option value="' + escapeHtml(item) + '">' + escapeHtml(item) + '</option>';
141
+ }).join('');
142
+ }
143
+
144
+ function buildAppShellHTML() {
145
+ return ''
146
+ + '<div class="gd-toolbar">'
147
+ + '<select id="gd_filter_city"><option value="">כל הערים</option>' + optionsHTML(CITIES) + '</select>'
148
+ + '<select id="gd_filter_category"><option value="">כל הקטגוריות</option>' + optionsHTML(CATEGORIES) + '</select>'
149
+ + '<button type="button" class="gd-add-btn" id="gd_open_add">+ הוספת גמ"ח</button>'
150
+ + '</div>'
151
+ + '<div id="gd_admin_pending"></div>'
152
+ + '<div id="gd_list" class="gd-grid"><div class="gd-empty">טוען...</div></div>';
153
+ }
154
+
155
+ function buildModalHTML() {
156
+ return ''
157
+ + '<h3>הוספת גמ"ח</h3>'
158
+ + '<div class="gd-field"><label>שם הגמ"ח</label><input type="text" id="gd_f_name" maxlength="120"></div>'
159
+ + '<div class="gd-field"><label>עיר</label>'
160
+ + '<select id="gd_f_city"><option value="">בחרו עיר</option>' + optionsHTML(CITIES)
161
+ + '<option value="' + OTHER_VALUE + '">עיר אחרת...</option></select>'
162
+ + '<input type="text" id="gd_f_city_other" placeholder="הקלידו את שם העיר" style="margin-top:8px;display:none;">'
163
+ + '</div>'
164
+ + '<div class="gd-field"><label>קטגוריה</label>'
165
+ + '<select id="gd_f_category"><option value="">בחרו קטגוריה</option>' + optionsHTML(CATEGORIES) + '</select></div>'
166
+ + '<div class="gd-field"><label>איש קשר / טלפון</label><input type="text" id="gd_f_contact" maxlength="120"></div>'
167
+ + '<div class="gd-field"><label>תיאור קצר (רשות)</label><textarea id="gd_f_description" maxlength="500"></textarea></div>'
168
+ + '<div class="gd-actions">'
169
+ + '<button type="button" class="gd-submit" id="gd_f_submit">שליחה לאישור</button>'
170
+ + '<button type="button" class="gd-cancel" id="gd_f_cancel">ביטול</button>'
171
+ + '</div>'
172
+ + '<div class="gd-status" id="gd_f_status"></div>';
173
+ }
174
+
175
+ function gemachCardHTML(g) {
176
+ return ''
177
+ + '<div class="gd-card" data-city="' + escapeHtml(g.city) + '" data-category="' + escapeHtml(g.category) + '">'
178
+ + '<div class="gd-card-title">' + escapeHtml(g.name) + '</div>'
179
+ + '<div class="gd-card-meta">' + escapeHtml(g.city) + ' · ' + escapeHtml(g.category) + '</div>'
180
+ + (g.description ? '<div class="gd-card-desc">' + escapeHtml(g.description) + '</div>' : '')
181
+ + '<div class="gd-card-contact">📞 ' + escapeHtml(g.contact) + '</div>'
182
+ + '</div>';
183
+ }
184
+
185
+ function pendingRowHTML(g) {
186
+ return ''
187
+ + '<div class="gd-pending-row" data-id="' + escapeHtml(g.id) + '">'
188
+ + '<span class="gd-flex"><b>' + escapeHtml(g.name) + '</b> · ' + escapeHtml(g.city) + ' · ' + escapeHtml(g.category) + '</span>'
189
+ + '<button type="button" class="gd-btn-ok" data-action="approve">אשר</button>'
190
+ + '<button type="button" class="gd-btn-no" data-action="reject">דחה</button>'
191
+ + '</div>';
192
+ }
193
+
194
+ // ============ לוגיקה ============
195
+
196
+ var currentGemachs = [];
197
+
198
+ function renderList(root) {
199
+ var listEl = root.querySelector('#gd_list');
200
+ var cityFilter = root.querySelector('#gd_filter_city').value;
201
+ var categoryFilter = root.querySelector('#gd_filter_category').value;
202
+
203
+ var filtered = currentGemachs.filter(function (g) {
204
+ return (!cityFilter || g.city === cityFilter) && (!categoryFilter || g.category === categoryFilter);
205
+ });
206
+
207
+ if (!filtered.length) {
208
+ listEl.innerHTML = '<div class="gd-empty">אין עדיין גמ"חים תואמים לסינון שבחרתם.</div>';
209
+ return;
210
+ }
211
+ listEl.innerHTML = filtered.map(gemachCardHTML).join('');
212
+ }
213
+
214
+ function loadApprovedList(root, socket) {
215
+ socket.emit('plugins.gemachDirectory.listApproved', {}, function (err, gemachs) {
216
+ if (err) {
217
+ root.querySelector('#gd_list').innerHTML = '<div class="gd-empty">שגיאה בטעינת הרשימה.</div>';
218
+ return;
219
+ }
220
+ currentGemachs = gemachs || [];
221
+ renderList(root);
222
+ });
223
+ }
224
+
225
+ function loadPendingPanel(root, socket) {
226
+ if (!isAdmin()) return;
227
+ var panel = root.querySelector('#gd_admin_pending');
228
+
229
+ socket.emit('plugins.gemachDirectory.listPending', {}, function (err, pending) {
230
+ if (err || !pending || !pending.length) {
231
+ panel.innerHTML = '';
232
+ return;
233
+ }
234
+ panel.innerHTML = ''
235
+ + '<div class="gd-pending"><h4>ממתינים לאישור (' + pending.length + ')</h4>'
236
+ + pending.map(pendingRowHTML).join('') + '</div>';
237
+
238
+ panel.querySelectorAll('.gd-pending-row button').forEach(function (btn) {
239
+ btn.addEventListener('click', function () {
240
+ var row = btn.closest('.gd-pending-row');
241
+ var id = row.getAttribute('data-id');
242
+ var action = btn.getAttribute('data-action');
243
+ socket.emit('plugins.gemachDirectory.' + action, { id: id }, function (err2) {
244
+ if (err2) return;
245
+ row.remove();
246
+ if (!panel.querySelector('.gd-pending-row')) panel.innerHTML = '';
247
+ if (action === 'approve') loadApprovedList(root, socket);
248
+ });
249
+ });
250
+ });
251
+ });
252
+ }
253
+
254
+ function openAddModal(root, socket) {
255
+ if (document.getElementById(MODAL_ID + '-overlay')) return;
256
+
257
+ var overlay = document.createElement('div');
258
+ overlay.id = MODAL_ID + '-overlay';
259
+ var modal = document.createElement('div');
260
+ modal.id = MODAL_ID;
261
+ modal.innerHTML = buildModalHTML();
262
+ overlay.appendChild(modal);
263
+ document.body.appendChild(overlay);
264
+
265
+ overlay.addEventListener('click', function (e) {
266
+ if (e.target === overlay) overlay.remove();
267
+ });
268
+ modal.querySelector('#gd_f_cancel').addEventListener('click', function () {
269
+ overlay.remove();
270
+ });
271
+
272
+ modal.querySelector('#gd_f_city').addEventListener('change', function () {
273
+ var otherInput = modal.querySelector('#gd_f_city_other');
274
+ otherInput.style.display = this.value === OTHER_VALUE ? 'block' : 'none';
275
+ });
276
+
277
+ modal.querySelector('#gd_f_submit').addEventListener('click', function () {
278
+ var statusEl = modal.querySelector('#gd_f_status');
279
+ var citySelect = modal.querySelector('#gd_f_city').value;
280
+ var city = citySelect === OTHER_VALUE ?
281
+ modal.querySelector('#gd_f_city_other').value.trim() : citySelect;
282
+
283
+ var data = {
284
+ name: modal.querySelector('#gd_f_name').value.trim(),
285
+ city: city,
286
+ category: modal.querySelector('#gd_f_category').value,
287
+ contact: modal.querySelector('#gd_f_contact').value.trim(),
288
+ description: modal.querySelector('#gd_f_description').value.trim(),
289
+ };
290
+
291
+ if (!data.name || !data.city || !data.category || !data.contact) {
292
+ statusEl.className = 'gd-status err';
293
+ statusEl.textContent = 'נא למלא שם, עיר, קטגוריה ואיש קשר.';
294
+ return;
295
+ }
296
+
297
+ statusEl.className = 'gd-status';
298
+ statusEl.textContent = 'שולח...';
299
+ socket.emit('plugins.gemachDirectory.submit', data, function (err) {
300
+ if (err) {
301
+ statusEl.className = 'gd-status err';
302
+ statusEl.textContent = 'שגיאה בשליחה - נסו שוב.';
303
+ return;
304
+ }
305
+ statusEl.className = 'gd-status ok';
306
+ statusEl.textContent = 'נשלח בהצלחה! הגמ"ח יופיע ברשימה לאחר אישור מנהל.';
307
+ setTimeout(function () { overlay.remove(); }, 1800);
308
+ });
309
+ });
310
+ }
311
+
312
+ function injectDirectory() {
313
+ if (!isDirectoryTopic()) return;
314
+ if (document.getElementById(APP_ID)) return;
315
+
316
+ var socket = getSocket();
317
+ if (!socket) return;
318
+
319
+ var target = findFirstPostContent();
320
+ if (!target) return;
321
+
322
+ injectStyles();
323
+
324
+ var app = document.createElement('div');
325
+ app.id = APP_ID;
326
+ app.innerHTML = buildAppShellHTML();
327
+ target.appendChild(app);
328
+
329
+ app.querySelector('#gd_filter_city').addEventListener('change', function () { renderList(app); });
330
+ app.querySelector('#gd_filter_category').addEventListener('change', function () { renderList(app); });
331
+ app.querySelector('#gd_open_add').addEventListener('click', function () { openAddModal(app, socket); });
332
+
333
+ loadApprovedList(app, socket);
334
+ loadPendingPanel(app, socket);
335
+ }
336
+
337
+ function onPageChange() {
338
+ injectDirectory();
339
+ }
340
+
341
+ if (window.$) {
342
+ $(window).on('action:ajaxify.end', onPageChange);
343
+ }
344
+ document.addEventListener('DOMContentLoaded', onPageChange);
345
+ onPageChange();
346
+ })();