import React,{useCallback,useEffect,useMemo,useRef,useState}from "react"; import{Link,Routes,Route,Navigate}from "react-router-dom"; import{createClient}from "@supabase/supabase-js"; import "./App.css"; import AssessmentAvatar from "./components/AssessmentAvatar"; const MONITOR_SESSION_KEY = "LS_MONITOR_SESSION_AR"; type MonitorSession ={teacherId:string;teacherName?: string;expiresAt:number}; function getMonitorSession(): MonitorSession | null{try{const raw = localStorage.getItem(MONITOR_SESSION_KEY) || sessionStorage.getItem(MONITOR_SESSION_KEY);if (!raw) return null;const s = JSON.parse(raw);const teacherId = String(s?.teacherId || "").trim();const teacherName = String(s?.teacherName || "").trim();const expiresAt = Number(s?.expiresAt || 0);if (!teacherId || !expiresAt) return null;if (Date.now() >= expiresAt){localStorage.removeItem(MONITOR_SESSION_KEY);sessionStorage.removeItem(MONITOR_SESSION_KEY);return null}return{teacherId,teacherName,expiresAt}}catch{return null}}function saveMonitorSession(session: MonitorSession){localStorage.setItem(MONITOR_SESSION_KEY,JSON.stringify(session));sessionStorage.removeItem(MONITOR_SESSION_KEY)}function lockMonitoringNow(){localStorage.removeItem(MONITOR_SESSION_KEY);sessionStorage.removeItem(MONITOR_SESSION_KEY)}async function verifyTeacherAccessWithDB(nationalId: string){const url = import.meta.env.VITE_SUPABASE_URL as string | undefined;const anon = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;if (!url || !anon){return{ok:false,message: "إعدادات Supabase غير مكتملة"}}const supabase = createClient(url,anon);try{const{data,error}= await supabase .from("teacher_access") .select("national_id, full_name, status") .eq("national_id",nationalId) .maybeSingle();if (error){return{ok:false,message: error.message || "حدث خطأ أثناء التحقق من الهوية"}}if (!data){return{ok:false,message: "الهوية غير مسجلة. الرجاء تسجيل بيانات المعلم أولًا."}}if (data.status === "pending"){return{ok:false,message: "غير مسموح حاليًا. طلبك تحت المراجعة."}}if (data.status === "rejected"){return{ok:false,message: "هذه الهوية غير مصرح لها باستخدام النظام."}}if (data.status === "disabled"){return{ok:false,message: "تم إيقاف صلاحية هذه الهوية. الرجاء التواصل مع الإدارة."}}if (data.status !== "active"){return{ok:false,message: "تعذر التحقق من حالة الهوية."}}return{ok:true,teacherId: String(data.national_id || "").trim(),teacherName: String(data.full_name || "").trim(),}}catch (e: any){return{ok:false,message: e?.message || "حدث خطأ أثناء التحقق من الهوية"}}}async function ensureMonitorUnlockedOrPrompt(): Promise<MonitorSession | null>{const existing = getMonitorSession();if (existing) return existing;const id = (prompt("أدخل رقم السجل المدني للمعلم لتفعيل المراقبة لمدة 10 دقائق") || "").trim();if (!/^\d{10}$/.test(id)){alert("أدخل رقم سجل مدني صحيح من 10 أرقام");return null}const check = await verifyTeacherAccessWithDB(id);if (!check.ok){alert(check.message || "تعذر التحقق من الهوية");return null}const expiresAt = Date.now() + 10 * 60 * 1000;const session: MonitorSession ={teacherId:check.teacherId!,teacherName: check.teacherName || "",expiresAt,}saveMonitorSession(session);return session}const MAGIC_EVALUATION_PROMPT = String.raw`أنت خبير تقويم تربوي وتعليمي شديد الدقة داخل محطة التعلم الذكية، وتعمل بديلاً رقمياً عن المعلم في تقدير أداء الطالب من صورة واحدة أو أكثر. الهدف العام: - إصدار حكم تقويمي تربوي دقيق ومنضبط وعادل على أداء الطالب. - تقييم جميع المواد والأنشطة الممكنة: اللغة العربية، القراءة، الكتابة، الإملاء، الرياضيات، العلوم، المهارات العملية، النماذج، المجسمات، الترتيب، المطابقة، التجارب، التوصيل، والتصنيف. - عدم التخمين إطلاقاً. عند عدم وضوح الدليل يجب إرجاع ERROR بدلاً من إعطاء حكم غير مؤكد. قواعد عملك الإلزامية: 1) اعتمد فقط على ما تراه فعلياً في الصورة. 2) تجاهل الوجوه والخلفية والعناصر غير التعليمية. 3) ركز على الورقة، الكتابة، الأدوات، اليدين، المجسم، النموذج، التوصيلات، والأجزاء التعليمية الظاهرة. 4) إذا كانت الصورة ضبابية أو ناقصة أو لا تكفي للحكم: أرجع ERROR مع تفسير مختصر. 5) لا تخترع سؤالاً أو إجابة غير موجودة. 6) لا تعط درجة كاملة إلا عند وجود دليل بصري قوي وواضح. 7) عند الأنشطة الحسابية: استخرج المسألة ثم حلها داخلياً ثم قارنها بإجابة الطالب. 8) عند الأنشطة اللغوية: افحص شكل الكلمة، ترتيب الحروف، اكتمالها، وسلامة النسخ أو الإملاء بقدر ما يظهر في الصورة. 9) عند العلوم والنماذج والمجسمات: افحص وجود الأجزاء الأساسية، صحة العلاقات بينها، الترتيب، الوظيفة، والمنطق العلمي الظاهر. 10) عند المطابقة أو الترتيب أو التصنيف: احكم فقط من التوافق الظاهر بصرياً. 11) كن صارماً، ثابتاً، ومنصفاً. قواعد إلزامية خاصة بالرياضيات وقراءة الأرقام: 12) يجب تطبيع جميع الأرقام قبل الحكم، واعتبار هذه الأشكال متكافئة رقمياً: ٠١٢٣٤٥٦٧٨٩ = 0123456789 = ۰۱۲۳۴۵۶۷۸۹. 13) لا تعتبر الرقم العربي خطأ لمجرد أنه مكتوب بالشكل العربي أو الهندي أو الفارسي. المهم القيمة العددية نفسها. 14) طبع رموز العمليات قبل الحل، واعتبر الرموز الآتية متكافئة عند الحاجة: × x X * = ضرب، + = جمع، - − – = طرح، ÷ / = قسمة، : قد تستعمل للقسمة حسب السياق. 15) عند تقييم جدول الضرب أو الجمع أو الطرح أو القسمة: اقرأ كل سطر أو خانة على حدة، ثم حوّل الأرقام إلى قيم عددية، ثم احسب الناتج الصحيح داخلياً، ثم قارن الناتج المكتوب بالقيمة الصحيحة فقط. 16) إذا كان شكل الرقم اليدوي ملتبساً مثل ٢ و٣ و٥ أو ٠ و٥ أو ٦ و٧ أو ٨ و٠، فلا تخمّن مباشرة. افحص شكل الخط وسياق السطر والتسلسل العددي قبل الحكم، وإن بقي الالتباس فأرجع ERROR أو NEEDS_FIX بدلاً من قرار خاطئ. 17) في جداول الضرب لا تخلط بين رقم السؤال ورقم الناتج، ولا تفسر العمود أو الصف على أنه قيمة أخرى بدون دليل بصري واضح. 18) إذا كانت الإجابة النهائية صحيحة حسابياً لكن شكل الرقم غير مثالي، فلا تحكم بالخطأ الحسابي؛ اذكر فقط ملاحظة تتعلق بالوضوح إن لزم. 19) عند وجود أكثر من عملية في الورقة، قيّم كل عملية مستقلة ثم ابنِ الدرجة النهائية على مجموع الصحة الظاهرة. 20) إذا كانت المسألة من نوع نسخ أرقام أو مطابقة أعداد، فالمعيار هو مطابقة القيمة والرمز المكتوبين بصرياً بعد التطبيع. خطوات التقييم الإلزامية: أ) حدّد نوع النشاط تلقائياً من الصورة: رياضي / لغوي / علمي / مجسم أو نموذج / ترتيب أو مطابقة / نشاط عام آخر. ب) استخرج الدليل البصري المهم فقط. ج) طبّق معايير المعلم المرسلة لك كما هي. د) إن كانت معايير المعلم عامة جداً، استخدم أفضل تفسير تربوي محافظ دون مبالغة. هـ) امنع الهلوسة تماماً. و) في الرياضيات: طبع الأرقام والرموز أولاً، ثم استخرج المسألة، ثم احسب الناتج الصحيح، ثم قارن بإجابة الطالب. تفسير الدرجات: - 10 = صحيح تماماً أو مطابق بوضوح قوي جداً. - 8-9 = صحيح غالباً مع ملاحظة بسيطة غير جوهرية. - 6-7 = فهم جزئي أو أخطاء محدودة مؤثرة جزئياً. - 4-5 = محاولة ضعيفة أو تنفيذ ناقص بوضوح. - 1-3 = خطأ كبير أو عدم تحقق المطلوب غالباً. - 0 = لا يوجد إنجاز صحيح ظاهر أو النشاط غير مطابق إطلاقاً. قواعد step_status: - OK: يوجد دليل كافٍ والصورة واضحة ويمكن إصدار حكم. - NEEDS_FIX: يوجد عمل ظاهر لكن فيه نقص أو أخطاء أو يحتاج تعديل. - ERROR: الصورة غير واضحة أو غير كافية أو لا يمكن الجزم. شكل المخرجات الإلزامي JSON فقط بدون أي نص زائد:{"score": number,"step_status": "OK" | "NEEDS_FIX" | "ERROR","feedback": "ملاحظة عربية قصيرة واضحة للمعلم","activity_type": "رياضيات | لغة | علوم | نموذج/مجسم | ترتيب/مطابقة | عام","confidence": number,"math_transcription": ["انقل كل سطر رياضي كما ظهر بصرياً"],"math_summary":{"total": number,"correct": number,"wrong": number,"unclear": number},"math_evaluation_mode": "CODE_VERIFY_FOR_MATH"}ضوابط إضافية مهمة: - confidence من 0 إلى 1 - إذا كانت الثقة أقل من .55 فالأصل أن تكون النتيجة ERROR أو NEEDS_FIX لا OK. - feedback يجب أن يكون قصيراً ومباشراً ومبنياً على دليل مرئي فقط. - في النشاط الرياضي: دورك الأول هو النسخ البصري الصارم للسطر الرياضي داخل math_transcription، ثم يحق للنظام البرمجي إعادة التحقق من الصحة حسابياً. لا تفترض أن الرقم إنجليزي إذا كان الشكل عربياً. - لا تكتب أي شرح خارج JSON.`; const ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"; const EXTENDED_ARABIC_INDIC_DIGITS = "۰۱۲۳۴۵۶۷۸۹"; const A4_RATIO = 210 / 297; // portrait width / height const clamp = (value: number,min: number,max: number) => Math.min(max,Math.max(min,value)); const fitA4CropPercent = (box: {x: number; y: number; w: number; h: number}) =>{const safeW = clamp(box.w,5,100);const safeH = clamp(box.h,5,100);const safeX = clamp(box.x,0,100 - safeW);const safeY = clamp(box.y,0,100 - safeH);let w = safeW;let h = safeH;const currentRatio = w / h;if (currentRatio > A4_RATIO){w = h * A4_RATIO}else{h = w / A4_RATIO}const x = clamp(safeX + (safeW - w) / 2,0,100 - w);const y = clamp(safeY + (safeH - h) / 2,0,100 - h);return{x,y,w,h}}; const makeA4CropFromWidth = (x: number,y: number,w: number) =>{const safeW = clamp(w,20,70);const h = safeW / A4_RATIO;const safeH = clamp(h,30,92);const finalW = safeH * A4_RATIO;return{cropW:Math.round(finalW),cropH: Math.round(safeH),cropX: Math.round(clamp(x,0,100 - finalW)),cropY: Math.round(clamp(y,0,100 - safeH)),}}; const normalizeArabicDigits = (value: unknown): string =>{const src = String(value ?? "");let out = "";for (const ch of src){const i1 = ARABIC_INDIC_DIGITS.indexOf(ch);if (i1 >= 0){out += String(i1);continue}const i2 = EXTENDED_ARABIC_INDIC_DIGITS.indexOf(ch);if (i2 >= 0){out += String(i2);continue}out += ch}return out}; const normalizeMathText = (value: unknown): string =>{return normalizeArabicDigits(value) .replace(/[×xX✕✖＊]/g,"*") .replace(/[÷]/g,"/") .replace(/[−–—]/g,"-") .replace(/[٫]/g,".") .replace(/[،]/g,",") .replace(/s+/g," ") .trim()}; const normalizeStepStatus = (value: unknown): "OK" | "NEEDS_FIX" | "ERROR" =>{const v = String(value ?? "").trim().toUpperCase();if (v === "OK") return "OK";if (v === "NEEDS_FIX" || v === "NEEDS-FIX" || v === "NEEDS FIX") return "NEEDS_FIX";return "ERROR"}; const normalizeScoreValue = (value: unknown): number | undefined =>{if (typeof value === "number" && Number.isFinite(value)){return Math.max(0,Math.min(10,value))}const normalized = normalizeMathText(value).replace(/[^0-9\-]/g,"");if (!normalized) return undefined;const parsed = Number(normalized);if (!Number.isFinite(parsed)) return undefined;return Math.max(0,Math.min(10,parsed))}; type MathCheckLine ={raw:string;normalized:string;left:number;right:number;op:"+" | "-" | "*" | "/";studentAnswer:number;expectedAnswer:number;isCorrect:boolean;parseError?: string}; const safeFiniteNumber = (value: unknown): number | undefined =>{if (typeof value === "number" && Number.isFinite(value)) return value;const n = Number(normalizeMathText(value).replace(/[^0-9\-]/g,""));return Number.isFinite(n) ? n : undefined}; const isLikelyMathActivity = (raw: any,lessonContext?: any): boolean =>{const haystack = [raw?.activity_type,raw?.feedback,raw?.task,lessonContext?.lesson,lessonContext?.goal,...(Array.isArray(lessonContext?.rubric) ? lessonContext.rubric : []),] .map((x) => normalizeMathText(x)) .join(" ");return /(رياض|جمع|طرح|قسم|قسمة|ضرب|جدول|عملية|عمليات|معاد|عدد|ارقام|أرقام|math|multiply|addition|subtraction|division)/i.test(haystack)}; const pickMathTranscriptionLines = (raw: any): string[] =>{const buckets: unknown[] = [raw?.math_transcription,raw?.math_lines,raw?.transcription,raw?.ocr_lines,raw?.detected_text,raw?.feedback,];const out: string[] = [];for (const item of buckets){if (Array.isArray(item)){for (const v of item){const s = normalizeMathText(v);if (s) out.push(s)}continue}if (typeof item === "string"){const lines = item .split(/r?n|[؛;,]+/) .map((s) => normalizeMathText(s)) .filter(Boolean);out.push(...lines)}}return Array.from(new Set(out)).filter((line) => /[0-9]/.test(line))}; const parseMathLine = (line: string): MathCheckLine | null =>{const normalized = normalizeMathText(line) .replace(/[=:]/g,"=") .replace(/\(يساوي|ناتجها|الناتج)\/gi,"=") .replace(/s+/g,"") .trim();if (!normalized) return null;const match = normalized.match(/^(-?\d+(?:\.\d+)?)s*([+\-*\/])s*(-?\d+(?:\.\d+)?)s*=s*(-?\d+(?:\.\d+)?)$/);if (!match) return null;const left = Number(match[1]);const op = match[2] as "+" | "-" | "*" | "/";const right = Number(match[3]);const studentAnswer = Number(match[4]);if (![left,right,studentAnswer].every(Number.isFinite)) return null;let expectedAnswer: number;if (op === "+") expectedAnswer = left + right;else if (op === "-") expectedAnswer = left - right;else if (op === "*") expectedAnswer = left * right;else{if (right === 0){return{raw:line,normalized,left,right,op,studentAnswer,expectedAnswer: Number.NaN,isCorrect: false,parseError: "قسمة على صفر",}}expectedAnswer = left / right}const isCorrect = Math.abs(expectedAnswer - studentAnswer) < 1e-9;return{raw:line,normalized,left,right,op,studentAnswer,expectedAnswer,isCorrect,}}; const buildMathFeedback = (checks: MathCheckLine[],unclearCount: number): string =>{const wrong = checks.filter((c) => !c.isCorrect && !c.parseError);const correct = checks.filter((c) => c.isCorrect).length;if (!checks.length){return unclearCount > 0 ? "تعذر نسخ المسائل الرياضية بصرياً بدقة كافية، تحتاج الصورة إلى وضوح أعلى." : "لم يتم العثور على سطور رياضية قابلة للتحقق برمجياً."}if (!wrong.length && unclearCount === 0){return `تم التحقق برمجياً من ${correct}مسألة رياضية وكانت جميعها صحيحة.`}const samples = wrong .slice(0,3) .map((c) => `${c.left}${c.op}${c.right}=${c.studentAnswer} والصحيح ${c.expectedAnswer}`) .join(" | ");if (wrong.length){return `التحقق البرمجي رصد ${wrong.length}خطأ: ${samples}${unclearCount > 0 ? ` | ويوجد ${unclearCount}سطر غير واضح.` : ""}`}return `تم التحقق من ${correct}مسألة صحيحة، لكن يوجد ${unclearCount}سطر غير واضح يحتاج إعادة تصوير.`}; const applyCodeMathVerification = (raw: any,normalizedBase: ObserveResult,lessonContext?: any): ObserveResult =>{if (!isLikelyMathActivity(raw,lessonContext)) return normalizedBase;const lines = pickMathTranscriptionLines(raw);const parsed = lines.map(parseMathLine);const checks = parsed.filter((x): x is MathCheckLine => Boolean(x));const unclearCount = Math.max(0,lines.length - checks.length);if (!checks.length){return{...normalizedBase,math_transcription: lines,math_verified: false,feedback: normalizedBase.feedback || (lines.length ? "لم أستطع تحويل السطور الرياضية إلى عمليات قابلة للتحقق، أعد تصوير الورقة عن قرب." : "لم تصل سطور رياضية قابلة للتحقق من نموذج الذكاء الاصطناعي."),step_status: lines.length ? "NEEDS_FIX" : normalizedBase.step_status,}as ObserveResult}const total = checks.length;const correct = checks.filter((c) => c.isCorrect).length;const wrong = checks.filter((c) => !c.isCorrect).length;const score = Math.max(0,Math.min(10,Number(((correct / total) * 10).toFixed(2))));let step_status: "OK" | "NEEDS_FIX" | "ERROR" = "OK";if (wrong > 0 || unclearCount > 0) step_status = "NEEDS_FIX";if (!correct && (wrong > 0 || unclearCount > 0)) step_status = unclearCount >= total ? "ERROR" : "NEEDS_FIX";return{...normalizedBase,score,step_status,confidence: Math.max(safeFiniteNumber(raw?.confidence) ?? 0,.85),feedback: buildMathFeedback(checks,unclearCount),math_transcription: lines,math_summary:{total,correct,wrong,unclear: unclearCount},math_verified: true,math_evaluation_mode: "CODE_VERIFY_FOR_MATH",}as ObserveResult}; const normalizeObserveResult = (raw: any,lessonContext?: any): ObserveResult =>{if (!raw || typeof raw !== "object"){return{ok:false,error: "استجابة التحليل غير صالحة"}}const ok = Boolean(raw.ok ?? true);const score = normalizeScoreValue(raw.score);const feedback = normalizeMathText(raw.feedback ?? "");const step_status = normalizeStepStatus(raw.step_status);const error = raw.error ? normalizeMathText(raw.error) : undefined;const normalizedBase: ObserveResult ={...raw,ok,score,feedback,step_status,error,}return applyCodeMathVerification(raw,normalizedBase,lessonContext)}; type ObserveResult ={ok:boolean;feedback?: string;score?: number;step_status?: string;error?: string;confidence?: number;activity_type?: string;math_transcription?: string[];math_summary?:{total?: number;correct?: number;wrong?: number;unclear?: number}math_evaluation_mode?: string;math_verified?: boolean;monitor_locked?: boolean;lock_message?: string}; type LearningStyle = "بصري" | "سمعي" | "لمسي" | "حركي" | "تأملي"; type StudentRow ={name:string;student_id:string;style:LearningStyle;enabled:boolean}; type StudentResult ={status:string;feedback:string;score:number | null;lastImg:string;updatedAt:number | null}; type ClassId = string; type ClassState ={// تقسيم الكاميرا gridEnabled: boolean;students:StudentRow[];results:StudentResult[];// لوحة المعلم topic: string;task:string;rubricText:string;deviceSnapshot?: PeripheralSnapshot | null;// قصّ فردي (لما الشبكة غير مفعلة) cropX: number;cropY:number;cropW:number;cropH:number}; type PeripheralSnapshot ={status:"connected" | "disconnected" | "error";source:string;deviceType?: string;sensorName?: string;values:Record<string,number | string | boolean | null>;raw?: any;updatedAt:number}; const PERIPHERAL_SNAPSHOT_KEY = "LS_PERIPHERAL_SNAPSHOT_AR"; const safeRound = (value: number,digits = 2) =>{const factor = Math.pow(10,digits);return Math.round(value * factor) / factor}; const readPeripheralSnapshot = (): PeripheralSnapshot | null =>{try{const raw = localStorage.getItem(PERIPHERAL_SNAPSHOT_KEY);if (!raw) return null;const parsed = JSON.parse(raw);if (!parsed || typeof parsed !== "object") return null;return parsed as PeripheralSnapshot}catch{return null}}; const writePeripheralSnapshot = (snapshot: PeripheralSnapshot | null) =>{try{if (!snapshot) localStorage.removeItem(PERIPHERAL_SNAPSHOT_KEY);else localStorage.setItem(PERIPHERAL_SNAPSHOT_KEY,JSON.stringify(snapshot))}}; const normalizePeripheralPayload = (evt: any): PeripheralSnapshot | null =>{if (!evt || typeof evt !== "object") return null;const values: Record<string,number | string | boolean | null> ={}const copyValue = (key: string,value: any) =>{if (value === undefined) return;if (typeof value === "number" && Number.isFinite(value)){values[key] = safeRound(value);return}if (typeof value === "string" || typeof value === "boolean" || value === null){values[key] = value}}if (evt.values && typeof evt.values === "object" && !Array.isArray(evt.values)){for (const [k,v] of Object.entries(evt.values)) copyValue(String(k),v as any)}const aliases: Array<[string,any]> = [["co2",evt.co2],["co2In",evt.co2In ?? evt.co2_in ?? evt.inlet_co2 ?? evt.co2_before],["co2Out",evt.co2Out ?? evt.co2_out ?? evt.outlet_co2 ?? evt.co2_after],["efficiency",evt.efficiency ?? evt.efficiency_percent ?? evt.filter_efficiency],["temperature",evt.temperature ?? evt.temp],["humidity",evt.humidity],["ppm",evt.ppm],["voltage",evt.voltage],["current",evt.current],["frequency",evt.frequency],["amplitude",evt.amplitude],["connected",evt.connected],];for (const [k,v] of aliases) copyValue(k,v);const status = String(evt.status || evt.connection || (Object.keys(values).length ? "connected" : "disconnected")).toLowerCase();const finalStatus = status.includes("error") ? "error" : status.includes("disconnect") ? "disconnected" : "connected";return{status:finalStatus,source: String(evt.source || evt.transport || evt.port || "webserial"),deviceType: String(evt.deviceType || evt.device_type || evt.type || "peripheral_sensor"),sensorName: String(evt.sensorName || evt.sensor_name || evt.name || evt.sensor || evt.model || "Peripheral Sensor"),values,raw: evt,updatedAt: Date.now(),}}; const peripheralSnapshotToTeacherText = (snapshot: PeripheralSnapshot | null) =>{if (!snapshot){return "لا توجد بيانات حديثة من الأجهزة الطرفية."}const lines = Object.entries(snapshot.values).map(([k,v]) => `${k}: ${String(v)}`);return ["بيانات الأجهزة الطرفية الحالية:",`الحالة: ${snapshot.status}`,`المصدر: ${snapshot.source}`,`نوع الجهاز: ${snapshot.deviceType || "-"}`,`اسم الحساس: ${snapshot.sensorName || "-"}`,...lines,].join("n")}; const peripheralSerialManager ={port:null as any,reader: null as any,keepReading: false,status: "disconnected" as string,log: "" as string,snapshot: readPeripheralSnapshot() as PeripheralSnapshot | null,listeners: new Set<() => void>(),}; const notifyPeripheralManager = () =>{for (const fn of peripheralSerialManager.listeners){try{fn()}}}; const subscribePeripheralManager = (fn: () => void) =>{peripheralSerialManager.listeners.add(fn);return () =>{peripheralSerialManager.listeners.delete(fn)}}; const getPeripheralManagerState = () => ({status: peripheralSerialManager.status,log: peripheralSerialManager.log,snapshot: peripheralSerialManager.snapshot,}); const setPeripheralManagerSnapshot = (snapshot: PeripheralSnapshot | null) =>{peripheralSerialManager.snapshot = snapshot;writePeripheralSnapshot(snapshot);notifyPeripheralManager()}; const pushPeripheralManagerLog = (msg: string,locale: string) =>{const t = new Date().toLocaleTimeString(locale);peripheralSerialManager.log = `[${t}] ${msg}` + peripheralSerialManager.log;notifyPeripheralManager()}; const clearPeripheralManagerLog = () =>{peripheralSerialManager.log = "";notifyPeripheralManager()}; const connectPeripheralSerial = async (opts: {locale: string; unsupportedMessage: string; connectedStatus: string; connectedLog: string; connectedNamePrefix: string; sensorFallbackName: string; eventPrefix: string; failedStatus: string;}) =>{try{const navAny = navigator as any;if (!navAny.serial){alert(opts.unsupportedMessage);return}if (peripheralSerialManager.port && peripheralSerialManager.keepReading){peripheralSerialManager.status = opts.connectedStatus;notifyPeripheralManager();return}const port = await navAny.serial.requestPort();await port.open({baudRate: 115200});peripheralSerialManager.port = port;peripheralSerialManager.keepReading = true;peripheralSerialManager.status = opts.connectedStatus;pushPeripheralManagerLog(opts.connectedLog,opts.locale);const decoder = new (window as any).TextDecoderStream();port.readable.pipeTo(decoder.writable).catch(() => {});const reader = decoder.readable.getReader();peripheralSerialManager.reader = reader;let buffer = "";while (peripheralSerialManager.keepReading){const{value,done}= await reader.read();if (done) break;if (!value) continue;buffer += value;const lines = buffer.split(/r?n/);buffer = lines.pop() || "";for (const line of lines){const trimmed = line.trim();if (!trimmed) continue;try{const evt = JSON.parse(trimmed);const snapshot = normalizePeripheralPayload(evt);if (snapshot){setPeripheralManagerSnapshot(snapshot);const coreValues = Object.entries(snapshot.values) .slice(0,6) .map(([k,v]) => `${k}=${String(v)}`) .join(" | ");peripheralSerialManager.status = `${opts.connectedNamePrefix}${snapshot.sensorName || opts.sensorFallbackName}`;pushPeripheralManagerLog(`${opts.eventPrefix}${snapshot.sensorName || snapshot.deviceType || "sensor"} ${coreValues}`,opts.locale)}else{pushPeripheralManagerLog("EVENT: " + JSON.stringify(evt),opts.locale)}}catch{pushPeripheralManagerLog(trimmed,opts.locale)}}}}catch (e: any){peripheralSerialManager.status = opts.failedStatus;pushPeripheralManagerLog("❌ " + (e?.message || opts.failedStatus),opts.locale)}finally{notifyPeripheralManager()}}; const stopPeripheralSerial = async (opts: {locale: string; disconnectedStatus: string; disconnectedLog: string; fallbackSensorName: string;}) =>{peripheralSerialManager.keepReading = false;peripheralSerialManager.status = opts.disconnectedStatus;try{await peripheralSerialManager.reader?.cancel()}try{await peripheralSerialManager.port?.close()}peripheralSerialManager.reader = null;peripheralSerialManager.port = null;setPeripheralManagerSnapshot({status: "disconnected",source: "webserial",deviceType: peripheralSerialManager.snapshot?.deviceType || "peripheral_sensor",sensorName: peripheralSerialManager.snapshot?.sensorName || opts.fallbackSensorName,values: {},updatedAt: Date.now(),});pushPeripheralManagerLog(opts.disconnectedLog,opts.locale);notifyPeripheralManager()}; const makeDefaultStudents = (): StudentRow[] => Array.from({length: 8}).map((_,i) => ({name: `طالب ${i + 1}`,student_id: "",style: "بصري" as LearningStyle,enabled: true,})); const makeDefaultResults = (): StudentResult[] => Array.from({length: 8}).map(() => ({status: "جاهز",feedback: "",score: null,lastImg: "",updatedAt: null,})); const makeDefaultClassState = (): ClassState => ({gridEnabled: false,students: makeDefaultStudents(),results: makeDefaultResults(),topic: "نشاط تعليمي عام",task: "قيّم أداء الطالب بدقة تربوية كخبير تقويم شامل. حدّد نوع النشاط تلقائياً من الصورة، ثم صحّح العمل وفق المطلوب الظاهر ومعايير المعلم، ولا تخمّن عند عدم الوضوح.",rubricText: ["افهم نوع النشاط أولاً: لغة أو رياضيات أو علوم أو نموذج/مجسم أو ترتيب/مطابقة أو نشاط عام.","استخرج الدليل من الورقة أو العمل الظاهر فقط، وتجاهل الخلفية والوجوه والعناصر غير التعليمية.","طبّق الحكم بدقة وعدل: صحّة الإجابة، اكتمال الخطوات، صحة الترتيب أو التوصيل، وجود الأجزاء الأساسية، ووضوح التنفيذ.","لا تمنح درجة كاملة إلا عند تطابق واضح، وإذا كانت الصورة غير كافية فأرجع ERROR بدلاً من التخمين.","إذا وصلت بيانات من الأجهزة الطرفية مثل الحساسات فاعتمد عليها كأساس رئيسي للتقييم، ولا تشترط ظهور شاشة الحساس داخل الصورة.",].join("n"),deviceSnapshot: null,...makeA4CropFromWidth(24,6,50),}); function EduAIBg(){return (<div aria-hidden style={{position: "fixed",inset: 0,zIndex: -1,overflow: "hidden",background: "radial-gradient(1200px 800px at 20% 10%, rgba(99,102,241,0.18), transparent 60%)," + "radial-gradient(1000px 700px at 80% 20%, rgba(56,189,248,0.16), transparent 55%)," + "radial-gradient(900px 700px at 50% 90%, rgba(34,197,94,0.10), transparent 55%)," + "linear-gradient(180deg, #0b1020 0%, #070b14 100%)",}} > {} <div style={{position: "absolute",inset: "-20%",backgroundImage: "linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px), " + "linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px)",backgroundSize: "60px 60px",transform: "perspective(900px) rotateX(60deg)",transformOrigin: "center",opacity: .35,filter: "blur(0.2px)",}} /> {} <div style={{position: "absolute",width: 560,height: 560,left: "8%",top: "20%",borderRadius: "50%",border: "1px solid rgba(255,255,255,0.18)",boxShadow: "0 0 60px rgba(99,102,241,0.18), inset 0 0 40px rgba(56,189,248,0.12)",transform: "perspective(900px) rotateX(55deg) rotateZ(10deg)",opacity: .75,}} /> <div style={{position: "absolute",width: 420,height: 420,right: "10%",top: "10%",borderRadius: "50%",border: "1px solid rgba(255,255,255,0.14)",boxShadow: "0 0 70px rgba(56,189,248,0.15), inset 0 0 35px rgba(34,197,94,0.10)",transform: "perspective(900px) rotateX(55deg) rotateZ(-12deg)",opacity: .75,}} /> {} <svg width="1200" height="800" viewBox="0 0 1200 800" style={{position: "absolute",inset: 0,margin: "auto",opacity: .55,filter: "drop-shadow(0 10px 25px rgba(0,0,0,0.45))",}} > <g fill="none" stroke="rgba(255,255,255,0.18)" strokeWidth="2"> <path d="M230 260c40-20 80-20 120 0v220c-40-20-80-20-120 0V260z" /> <path d="M350 260c40-20 80-20 120 0v220c-40-20-80-20-120 0V260z" /> <rect x="720" y="250" width="260" height="160" rx="16" /> <path d="M760 290h180M760 325h140M760 360h160" /> <path d="M520 180c-40 0-70 30-70 70 0 20 8 36 18 48-10 12-18 28-18 48 0 40 30 70 70 70 22 0 40-8 52-20 12 12 30 20 52 20 40 0 70-30 70-70 0-20-8-36-18-48 10-12 18-28 18-48 0-40-30-70-70-70-22 0-40 8-52 20-12-12-30-20-52-20z" /> <path d="M520 180v236M592 200v200M664 200v200" /> </g> </svg> <div style={{position: "absolute",inset: 0,background: "radial-gradient(600px 300px at 50% 30%, rgba(255,255,255,0.08), transparent 60%)",opacity: .8,}} /> </div>)}function DevicesPage(){const [deviceUiState,setDeviceUiState] = useState(() => getPeripheralManagerState());useEffect(() => {const sync = () => setDeviceUiState(getPeripheralManagerState()); sync(); const unsubscribe = subscribePeripheralManager(sync); const handleFocus = () => {setDeviceUiState(getPeripheralManagerState());}; window.addEventListener("focus",handleFocus); return () => {unsubscribe(); window.removeEventListener("focus",handleFocus);};},[]);const status = deviceUiState.status || "غير متصل";const log = deviceUiState.log || "";const deviceSnapshot = deviceUiState.snapshot || null;const connectSerial = async () =>{await connectPeripheralSerial({locale: "ar-SA",unsupportedMessage: "WebSerial غير مدعوم – افتح Chrome أو Edge",connectedStatus: "✅ متصل USB (Serial)",connectedLog: "✅ تم الاتصال بالجهاز",connectedNamePrefix: "✅ متصل: ",sensorFallbackName: "حساس",eventPrefix: "EVENT: ",failedStatus: "❌ فشل الاتصال",})}const stopAll = async () =>{await stopPeripheralSerial({locale: "ar-SA",disconnectedStatus: "غير متصل",disconnectedLog: "⛔ تم إيقاف الاتصال",fallbackSensorName: "Peripheral Sensor",})}const clearLog = () => clearPeripheralManagerLog();return (<div style={{maxWidth: 1200,margin: "0 auto",padding: 20}}> <EduAIBg /> <div style={{display: "flex",justifyContent: "space-between",gap: 10,flexWrap: "wrap",alignItems: "center",}} > <h1 style={{color: "#fff",margin: 0}}>🔌 صفحة الأجهزة الطرفية</h1> <Link to="/ar" style={{color: "#fff",textDecoration: "none",fontWeight: 800,padding: "10px 14px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(0,0,0,0.25)",}} > ⬅️ العودة لمحطة التعلم </Link> </div> <div style={{marginTop: 14,background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 14,borderRadius: 16,color: "#fff",}} > <p style={{marginTop: 0}}> الحالة: <b>{status}</b> </p> <div style={{display: "flex",gap: 12,flexWrap: "wrap"}}> <button onClick={connectSerial} style={{fontSize: 18,padding: "10px 16px"}}> 🔌 اتصال USB </button> <button onClick={stopAll} style={{fontSize: 18,padding: "10px 16px"}}> ⛔ إيقاف </button> <button onClick={clearLog} style={{fontSize: 18,padding: "10px 16px"}}> 🧹 مسح السجل </button> </div> <div style={{marginTop: 12,padding: 12,borderRadius: 12,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.25)",minHeight: 220,whiteSpace: "pre-wrap",fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",fontSize: 13,}} > {log || "السجل فارغ... اضغط اتصال USB ثم حرّك الجهاز/أرسل JSON سطر بسطر."} </div> <div style={{marginTop: 12,padding: 12,borderRadius: 12,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(255,255,255,0.06)"}}> <div style={{fontWeight: 900,marginBottom: 8}}>آخر بيانات الجهاز الطرفي</div> <div style={{whiteSpace: "pre-wrap",fontSize: 13,opacity: .95}}> {peripheralSnapshotToTeacherText(deviceSnapshot)} </div> </div> <p style={{marginTop: 10,fontSize: 13,opacity: .9}}> ملاحظة: WebSerial يعمل في Chrome/Edge فقط، وغالبًا يحتاج ضغط زر “اتصال USB” ثم اختيار المنفذ. </p> </div> </div>)}function MonitoringPage(){const [snap,setSnap] = useState<any>(null);// ✅ قفل شاشة المراقبة برقم هوية + مؤقت 10 دقائق (جلسة في sessionStorage) const [isUnlocked,setIsUnlocked] = useState(false);const [expiresAt,setExpiresAt] = useState<number | null>(null);const [teacherId,setTeacherId] = useState("");// استرجاع الجلسة عند فتح الصفحة useEffect(() => {const session = getMonitorSession(); if (session) {setIsUnlocked(true); setExpiresAt(session.expiresAt); setTeacherId(session.teacherId || "");} else {setIsUnlocked(false); setExpiresAt(null); setTeacherId(""); setSnap(null);}},[]);// الإقفال التلقائي عند انتهاء المؤقت useEffect(() => {if (!isUnlocked || !expiresAt) return; const ms = expiresAt - Date.now(); const t = window.setTimeout(() => {setIsUnlocked(false); setExpiresAt(null); setTeacherId(""); setSnap(null); lockMonitoringNow(); alert("انتهى وقت المراقبة (10 دقائق).");},Math.max(0,ms)); return () => window.clearTimeout(t);},[isUnlocked,expiresAt]);async function unlockMonitoring(){const session = await ensureMonitorUnlockedOrPrompt();if (!session) return;setIsUnlocked(true);setExpiresAt(session.expiresAt);setTeacherId(session.teacherId || "")}function lockNow(){setIsUnlocked(false);setExpiresAt(null);setTeacherId("");setSnap(null);lockMonitoringNow()}// ✅ قراءة بيانات المراقبة فقط إذا كانت الشاشة مفتوحة useEffect(() => {if (!isUnlocked) return; const params = new URLSearchParams(window.location.search); const cid = (params.get("classId") || "").trim(); const key = cid ? `LS_MONITOR_${cid}` : ""; const read = () => {if (!key) return; const raw = localStorage.getItem(key); if (!raw) return; try {const obj = JSON.parse(raw); setSnap({classId: cid,...obj});} catch {}}; read(); const t = window.setInterval(read,30000); return () => window.clearInterval(t);},[isUnlocked]);const BOX = 380;// ~10cm @ 96dpi const gradeOptionsLocal = [{label: "أول ابتدائي",value: 1},{label: "ثاني ابتدائي",value: 2},{label: "ثالث ابتدائي",value: 3},{label: "رابع ابتدائي",value: 4},{label: "خامس ابتدائي",value: 5},{label: "سادس ابتدائي",value: 6},];const styleBadgeLocal = (style: LearningStyle) =>{const map: Record<LearningStyle,string> ={بصري:"👁️ بصري",سمعي: "🎧 سمعي",لمسي: "✋ لمسي",حركي: "🏃 حركي",تأملي: "🧠 تأملي",}return map[style]}// ✅ شاشة القفل (قبل عرض أي بيانات) if (!isUnlocked){return (<div style={{maxWidth: 900,margin: "0 auto",padding: 16}}> <EduAIBg /> <div style={{marginTop: 14,background: "rgba(0,0,0,0.35)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 18,borderRadius: 16,color: "#fff",textAlign: "center",}} > <h1 style={{margin: 0}}>🔒 شاشة المراقبة مقفلة</h1> <div style={{opacity: .9,marginTop: 10}}> يلزم إدخال رقم هوية المعلم لفتح المراقبة لمدة 10 دقائق. </div> <button onClick={() => {void unlockMonitoring();}} style={{marginTop: 14,padding: "12px 16px",borderRadius: 14,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(255,255,255,0.12)",color: "#fff",fontWeight: 900,cursor: "pointer",}} > ✅ فتح المراقبة </button> <div style={{marginTop: 14}}> <Link to=".." style={{color: "#fff",textDecoration: "none",fontWeight: 800,padding: "10px 14px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(0,0,0,0.25)",display: "inline-block",}} > ⬅️ العودة للمحطة </Link> </div> </div> </div>)}const classState: ClassState | null = snap?.classState || null;const students: StudentRow[] = classState?.students || [];const results: StudentResult[] = classState?.results || [];const gridEnabled: boolean = !!classState?.gridEnabled;const remainingSec = expiresAt ? Math.max(0,Math.floor((expiresAt - Date.now()) / 1000)) : 0;const remainingMin = Math.floor(remainingSec / 60);const remainingS = remainingSec % 60;return (<div style={{maxWidth: 1600,margin: "0 auto",padding: 16}}> <EduAIBg /> <div style={{display: "flex",justifyContent: "space-between",gap: 10,flexWrap: "wrap",alignItems: "center"}}> <h1 style={{color: "#fff",margin: 0}}>👀 شاشة المراقبة</h1> <div style={{display: "flex",gap: 8,alignItems: "center",flexWrap: "wrap"}}> <div style={{color: "#fff",padding: "8px 12px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.25)",fontWeight: 800,}} > 👤 المعلم: {teacherId || "—"} | ⏱️ المتبقي: {remainingMin}:{String(remainingS).padStart(2,"0")} </div> <button onClick={lockNow} style={{color: "#fff",fontWeight: 900,padding: "10px 12px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(255,0,0,0.18)",cursor: "pointer",}} > 🔒 إغلاق الآن </button> <Link to=".." style={{color: "#fff",textDecoration: "none",fontWeight: 800,padding: "10px 14px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(0,0,0,0.25)",}} > ⬅️ العودة للمحطة </Link> </div> </div> <div style={{marginTop: 14,background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 14,borderRadius: 16,color: "#fff",}} > <div style={{fontWeight: 900,marginBottom: 8}}> الفصل: <span style={{opacity: .95}}>{snap?.grade ? gradeOptionsLocal[snap.grade - 1]?.label : ""}</span>{" "} <span style={{opacity: .95}}>{snap?.section ? `(${snap.section})` : ""}</span> </div> {!snap ? (<div style={{opacity: .9}}> لا توجد بيانات بعد. افتح المراقبة من لوحة المعلم أو انتظر أول تحديث. </div>) : (<div style={{display: "grid",gap: 12}}> <div style={{fontSize: 13,opacity: .9}}> آخر تحديث:{" "} {snap?.savedAt ? new Date(snap.savedAt).toLocaleTimeString("ar-SA") : "—"} </div> <div style={{display: "grid",gridTemplateColumns: "repeat(auto-fit, minmax(420px, 1fr))",gap: 12,}} > {students.map((st,i) => {const r = results[i] || ({} as any); const show = gridEnabled ? (st.enabled && (st.name || "").trim()) : i === 0; if (!show) return null; return (<div key={i} style={{borderRadius: 16,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.25)",padding: 12,}} > <div style={{display: "flex",justifyContent: "space-between",gap: 10,flexWrap: "wrap"}}> <div style={{fontWeight: 900}}> #{i + 1} — {(st.name || "").trim() || `طالب ${i + 1}`} —{" "} <span style={{opacity: .9}}>{styleBadgeLocal(st.style)}</span> </div> <div style={{fontWeight: 900,opacity: .95}}> {r.status || ""} {typeof r.score === "number" ? (<span style={{marginInlineStart: 10}}> | {r.score}/10</span>) : null} </div> </div> <div style={{marginTop: 8,fontSize: 13,whiteSpace: "pre-wrap",opacity: .95}}> {r.feedback || "—"} </div> <div style={{marginTop: 10,width: "100%",borderRadius: 14,overflow: "hidden",border: "1px solid rgba(255,255,255,0.14)",background: "rgba(0,0,0,0.20)",}} > {r.lastImg ? (<img src={r.lastImg} alt={`student-${i + 1}`} style={{width: "100%",height: BOX,display: "block",objectFit: "cover",}} />) : (<div style={{height: BOX,display: "flex",alignItems: "center",justifyContent: "center",opacity: .85}}> لا توجد صورة بعد </div>)} </div> </div>);})} </div> </div>)} </div> </div>)}export function LearningStation(){return (<Routes> {} <Route path="/" element={<LearningStationHome />} /> {} <Route path="devices" element={<DevicesPage />} /> <Route path="monitoring" element={<MonitoringPage />} /> {} <Route path="*" element={<Navigate to="." replace />} /> </Routes>)}function LearningStationHome(){const videoRef = useRef<HTMLVideoElement | null>(null);const canvasRef = useRef<HTMLCanvasElement | null>(null);const supabase = useMemo(() => {const url = import.meta.env.VITE_SUPABASE_URL as string | undefined; const anon = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined; if (!url || !anon) return null; return createClient(url,anon);},[]);const API_BASE = "https://vite-react-dvyy3bvl6-hatems-projects-projects.vercel.app";const getApiUrl = useCallback((path: string) => {const normalizedPath = path.startsWith("/") ? path : `/${path}`; const host = window.location.hostname; const isDirectVercel = host.endsWith(".vercel.app"); return isDirectVercel ? normalizedPath : `${API_BASE}${normalizedPath}`;},[]);const [isMobile,setIsMobile] = useState(false);// ========================================================= // 🎥 اختيار الكاميرا + زر تبديل (جوال: أمامي/خلفي) (لابتوب: default/الأجهزة المتاحة) // ========================================================= const [videoDevices,setVideoDevices] = useState<MediaDeviceInfo[]>([]);const [selectedDeviceId,setSelectedDeviceId] = useState<string | null>(null);const [facingMode,setFacingMode] = useState<"user" | "environment">("environment");const refreshVideoDevices = useCallback(async () => {try {if (!navigator.mediaDevices?.enumerateDevices) return; const devices = await navigator.mediaDevices.enumerateDevices(); const vids = devices.filter((d) => d.kind === "videoinput"); setVideoDevices(vids);} catch {// تجاهل}},[]);useEffect(() => {refreshVideoDevices(); const anyMD: any = navigator.mediaDevices as any; anyMD?.addEventListener?.("devicechange",refreshVideoDevices); return () => {anyMD?.removeEventListener?.("devicechange",refreshVideoDevices);};},[refreshVideoDevices]);useEffect(() => {// ✅ استجابة تلقائية للجوال const mq = window.matchMedia("(max-width: 768px)"); const apply = () => setIsMobile(mq.matches); apply(); // دعم المتصفحات المختلفة const anyMq: any = mq as any; if (anyMq.addEventListener) {anyMq.addEventListener("change",apply); return () => {anyMq.removeEventListener("change",apply);};} else {anyMq.addListener?.(apply); return () => {anyMq.removeListener?.(apply);};}},[]);const [started,setStarted] = useState(false);const [monitoring,setMonitoring] = useState(false);// ✅ أفاتار التقييم بعد رجوع نتيجة الذكاء الاصطناعي const [avatarScore,setAvatarScore] = useState<number | null>(null);const [avatarVisible,setAvatarVisible] = useState(false);const [avatarStudentName,setAvatarStudentName] = useState("يا بطل");const avatarGender = ((localStorage.getItem("LS_GENDER") || "boys") as "boys" | "girls");const triggerAssessmentAvatar = useCallback((score?: number | null,studentName?: string) => {if (typeof score !== "number") return; setAvatarScore(score); setAvatarStudentName((studentName || "يا بطل").trim() || "يا بطل"); setAvatarVisible(false); window.setTimeout(() => {setAvatarVisible(true);},30);},[]);const [status,setStatus] = useState<string>("");const [peripheralSnapshot,setPeripheralSnapshot] = useState<PeripheralSnapshot | null>(() => readPeripheralSnapshot());// ====== ☁️ حفظ الصور في Supabase مع تقليل استهلاك الباندويدث ====== // ملاحظة: كان فيه Throttling قوي (2min) فكان يحفظ أول صورة/صورتين فقط. // الآن: نرفع عند تغيّر الحالة + أرشفة دورية خفيفة، مع ضغط الصورة قبل الرفع. const MIN_UPLOAD_GAP_MS = 15 * 1000;// 15 ثانية حد أدنى بين الرفع لنفس الطالب const ARCHIVE_EVERY_MS = 60 * 1000;// أرشفة كل دقيقة حتى لو ما تغيّرت الحالة // إعدادات ضغط الصورة (حجم أصغر = باندويدث أقل) const UPLOAD_MAX_WIDTH = 960;// قلّلها إلى 640 إذا تبغى أصغر const UPLOAD_JPEG_QUALITY = .65;// .5 أصغر/أقل جودة، .75 أوضح/أكبر type UploadState ={lastStatus?: string;lastUploadAt?: number;lastArchiveAt?: number;pendingStatus?: string;inFlight?: boolean;lastEnqueueAt?: number}type UploadJob ={key:string;frameDataUrl:string;studentName:string;studentId:string;learningType:LearningStyle;statusTag?: string | null;score?: number | null;feedbackText?: string | null;attempts:number;enqueuedAt:number}const uploadStateRef = useRef<Record<string,UploadState>>({});const uploadQueueRef = useRef<UploadJob[]>([]);const uploadProcessingRef = useRef(false);const processUploadQueue = useCallback(async () => {if (uploadProcessingRef.current) return; uploadProcessingRef.current = true; try {while (uploadQueueRef.current.length > 0) {const job = uploadQueueRef.current.shift()!; const st = uploadStateRef.current[job.key] || (uploadStateRef.current[job.key] = {}); st.inFlight = true; try {await uploadAndSaveToSupabase(job.frameDataUrl,job.studentName,job.studentId,job.learningType,job.statusTag,job.score,job.feedbackText); st.lastStatus = job.statusTag ?? undefined; st.lastUploadAt = Date.now(); if ((job.statusTag ?? "learning") === "learning") st.lastArchiveAt = Date.now(); st.pendingStatus = undefined;} catch (err) {job.attempts += 1; if (job.attempts <= 2) {const backoffMs = 500 * Math.pow(2,job.attempts); // 1s,2s await new Promise((r) => setTimeout(r,backoffMs)); uploadQueueRef.current.unshift(job);} else {console.error("Upload job failed after retries:",err); st.pendingStatus = undefined;}} finally {st.inFlight = false;}}} finally {uploadProcessingRef.current = false;}},[]);const enqueueUpload = useCallback((job: Omit<UploadJob,"attempts" | "enqueuedAt">) => {const now = Date.now(); const full: UploadJob = {...job,attempts: 0,enqueuedAt: now}; const q = uploadQueueRef.current; // Queue: ارفع وحدة وحدة (بدون تداخل). لو زاد الطابور كثير نخفف بحذف الأقدم. q.push(full); const MAX_QUEUE = 30; if (q.length > MAX_QUEUE) q.splice(0,q.length - MAX_QUEUE); const st = uploadStateRef.current[job.key] || (uploadStateRef.current[job.key] = {}); st.pendingStatus = job.statusTag ?? undefined; st.lastEnqueueAt = now; if (!uploadProcessingRef.current) void processUploadQueue();},[processUploadQueue]);const getToday = () =>{const d = new Date();const record_date = d.toISOString().slice(0,10);const day_name = new Intl.DateTimeFormat("ar-SA",{weekday: "long"}).format(d);return{record_date,day_name}}const compressDataUrl = useCallback((dataUrl: string,maxWidth = UPLOAD_MAX_WIDTH,jpegQuality = UPLOAD_JPEG_QUALITY) => new Promise<string>((resolve,reject) => {const img = new Image(); img.onload = () => {try {const w = img.width || 1; const h = img.height || 1; const scale = w > maxWidth ? maxWidth / w : 1; const outW = Math.max(1,Math.round(w * scale)); const outH = Math.max(1,Math.round(h * scale)); const c = document.createElement("canvas"); c.width = outW; c.height = outH; const ctx = c.getContext("2d"); if (!ctx) return reject(new Error("No canvas context")); ctx.drawImage(img,0,0,outW,outH); const jpeg = c.toDataURL("image/jpeg",jpegQuality); resolve(jpeg);} catch (e) {reject(e);}}; img.onerror = () => reject(new Error("Image load failed for compression")); img.src = dataUrl;}),[]);const uploadAndSaveToSupabase = async (frameDataUrl: string,studentName: string,studentId: string,learningType: LearningStyle,statusTag?: string | null,score?: number | null,feedbackText?: string | null) =>{// 1) رفع الصورة للـ Storage const up = await fetch(getApiUrl("/api/upload-image"),{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({student_name: studentName,student_id: studentId,image_base64: await compressDataUrl(frameDataUrl),}),});const upJson = await up.json();if (!up.ok) throw new Error(upJson?.error || "Upload failed");const publicUrl = upJson?.public_url || upJson?.publicUrl || upJson?.image_url || upJson?.url || "";const imagePath = upJson?.path || upJson?.filePath || upJson?.file_path || upJson?.storagePath || (() => {const u = String(publicUrl || ""); const marker = "/learning-images/"; const idx = u.indexOf(marker); return idx >= 0 ? decodeURIComponent(u.slice(idx + marker.length).split("?")[0]) : "";})();if (!imagePath) throw new Error("لم يتم العثور على image_path بعد رفع الصورة");// 2) حفظ الرابط القديم في الداتابيس (لأرشفة السجلات السابقة) const{record_date,day_name}= getToday();const save = await fetch(getApiUrl("/api/ai-save"),{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({student_name: studentName,student_id: studentId,student_code: studentId,lang: "ar",image_url: publicUrl,learning_type: learningType,day_name,record_date,status_tag: statusTag ?? null,score: typeof score === "number" ? score : null,feedback_text: (feedbackText ?? "").toString(),}),});const saveJson = await save.json();if (!save.ok) throw new Error(saveJson?.error || "DB save failed");// 3) تحديد label لمحطة التعلم const stationLabel = typeof score === "number" ? score >= 8 ? "correct" : score >= 5 ? "needs_revision" : "incorrect" : "unknown";// 4) إيميل المدرسة حسب الفصل من قاعدة البيانات const schoolEmail = await resolveSchoolEmail(classId);if (!supabase){throw new Error("إعدادات Supabase غير موجودة في الواجهة")}// 5) إدخال سجل التقييم لتشغيل الـ webhook ثم smooth-api const{error:insertError}= await supabase.from("student_evaluations").insert({student_id: studentId,student_name: studentName,school_email: schoolEmail,image_path: imagePath,station_score: typeof score === "number" ? score : 0,station_label: stationLabel,});if (insertError) throw insertError;return{publicUrl,imagePath,schoolEmail,save: saveJson}}const maybeUploadFrame = async (opts: {frame: string; studentName: string; studentId: string; learningType: LearningStyle; statusTag: string; // مثل: OK / ERROR / step_status score?: number | null; feedbackText?: string | null;}) =>{const key = `${(opts.studentId || opts.studentName || "طالب").trim()}__${opts.learningType}`;const now = Date.now();const s = (uploadStateRef.current[key] ||= {});const statusChanged = s.lastStatus !== opts.statusTag;const enoughGap = !s.lastUploadAt || now - s.lastUploadAt >= MIN_UPLOAD_GAP_MS;const timeToArchive = !s.lastArchiveAt || now - s.lastArchiveAt >= ARCHIVE_EVERY_MS;// ✅ ارفع إذا: // - تغيّرت الحالة + مر وقت كافي // - أو وقت الأرشفة الدوري وصل // - أو في حالة خطأ دائمًا (لكن مع احترام gap) const isError = opts.statusTag === "ERROR";const shouldUpload = (statusChanged && enoughGap) || timeToArchive || (isError && enoughGap);if (!shouldUpload) return;enqueueUpload({key,frameDataUrl: opts.frame,studentName: opts.studentName,studentId: opts.studentId,learningType: opts.learningType,statusTag: opts.statusTag,score: opts.score ?? null,feedbackText: opts.feedbackText ?? null,})}// ====== ✅ اختيار البيئة التفاعلية (Typebot) ====== // عدّل الأسماء والروابط حسب بوتاتك const botOptions = useMemo(() => [{label: "بيئة أنماط التعلم التفاعلية للطلاب - نسخة المدارس",url: "https://typebot.co/apbgj1j"},{label: "بيئة أنماط التعلم التفاعلية للطالبات - نسخة المدارس",url: "https://typebot.co/f115kiw"},{label: "بيئة أنماط التعلم التفاعلية للطلاب - بنين",url: "https://typebot.co/bozie9m"},{label: "بيئة أنماط التعلم التفاعلية للطالبات -بنات",url: "https://typebot.co/0h61i1v"},{label: "بيئة اتجاهات بوصلة الشخصية التفاعلية - بنين",url: "https://typebot.co/282shsb"},{label: "بيئة اتجاهات بوصلة الشخصية - بنات",url: "https://typebot.co/oib7tx9"},{label: "بيئة دراسة الذكاء الغالب التفاعلية - بنين- (11-18) سنة",url: "https://typebot.co/11-18-qa9j36z"},{label: "بيئة دراسة النذكاء الغالب التفاعلية - بنات- (11-18) سنة",url: "https://typebot.co/11-18-1-65utzyg"},],[]);const [selectedBotUrl,setSelectedBotUrl] = useState<string>("");const [openMode,setOpenMode] = useState<"embed" | "newtab">("embed");const [embedBotUrl,setEmbedBotUrl] = useState<string>("");const openSelectedBot = () =>{const url = (selectedBotUrl || "").trim();if (!url){alert("اختر البيئة التفاعلية أولاً");return}if (openMode === "newtab"){window.open(url,"_blank","noopener,noreferrer");return}setEmbedBotUrl(url);// نزّل للـ iframe (اختياري) window.setTimeout(() => {document.getElementById("typebot-embed")?.scrollIntoView({behavior: "smooth",block: "start"});},80)}// ====== اختيار الصف/الشعبة ====== const gradeOptions = useMemo(() => [{label: "أول ابتدائي",value: 1},{label: "ثاني ابتدائي",value: 2},{label: "ثالث ابتدائي",value: 3},{label: "رابع ابتدائي",value: 4},{label: "خامس ابتدائي",value: 5},{label: "سادس ابتدائي",value: 6},],[]);const sectionOptions = useMemo(() => [{label: "أ",value: "أ"},{label: "ب",value: "ب"},{label: "ج",value: "ج"},],[]);const [grade,setGrade] = useState<number>(1);const [section,setSection] = useState<string>("أ");const classId: ClassId = useMemo(() => `G${grade}-${section}`,[grade,section]);const DEFAULT_SCHOOL_EMAIL = "hraheem01@gmail.com";// fallback for testing const resolveSchoolEmail = useCallback(async (cid: ClassId) => {if (!supabase) return DEFAULT_SCHOOL_EMAIL; try {const {data,error} = await supabase .from("school_classes") .select("school_email") .eq("class_id",cid) .maybeSingle(); if (error) {console.warn("school_classes lookup failed:",error); return DEFAULT_SCHOOL_EMAIL;} const email = String(data?.school_email || "").trim(); return email || DEFAULT_SCHOOL_EMAIL;} catch (e) {console.warn("resolveSchoolEmail error:",e); return DEFAULT_SCHOOL_EMAIL;}},[supabase]);// ====== تخزين منفصل لكل فصل (بدون تداخل) ====== const [classStates,setClassStates] = useState<Record<ClassId,ClassState>>(() => {const first = makeDefaultClassState(); try {const raw = localStorage.getItem(`LS_MONITOR_${classId}`); if (raw) {const parsed = JSON.parse(raw); if (parsed?.classState) {return {[classId]: {...first,...parsed.classState}};}}} catch {} return {[classId]: first};});const current = useMemo<ClassState>(() => {return classStates[classId] ?? makeDefaultClassState();},[classStates,classId]);const persistForMonitor = (id: ClassId,st: ClassState,g: number,sec: string) =>{try{localStorage.setItem(`LS_MONITOR_${id}`,JSON.stringify({classState: st,grade: g,section: sec,savedAt: Date.now()}))}}const setCurrent = (patch: Partial<ClassState>) =>{setClassStates((prev) => {const base = prev[classId] ?? makeDefaultClassState(); const next = {...base,...patch}; persistForMonitor(classId,next,grade,section); return {...prev,[classId]: next};})}useEffect(() => {const syncPeripheral = () => {const next = readPeripheralSnapshot(); setPeripheralSnapshot(next); setClassStates((prev) => {const base = prev[classId] ?? makeDefaultClassState(); if (JSON.stringify(base.deviceSnapshot || null) === JSON.stringify(next || null)) return prev; const updated = {...base,deviceSnapshot: next}; persistForMonitor(classId,updated,grade,section); return {...prev,[classId]: updated};});}; syncPeripheral(); const intervalId = window.setInterval(syncPeripheral,1000); window.addEventListener("storage",syncPeripheral); return () => {window.clearInterval(intervalId); window.removeEventListener("storage",syncPeripheral);};},[classId,grade,section]);// عند تغيير الفصل: نوقف المراقبة فورًا (عشان ما يصير تداخل) useEffect(() => {setMonitoring(false); setStatus(""); // نضمن وجود حالة لهذا الفصل setClassStates((prev) => {if (prev[classId]) return prev; const next = makeDefaultClassState(); persistForMonitor(classId,next,grade,section); return {...prev,[classId]: next};}); // eslint-disable-next-line react-hooks/exhaustive-deps},[classId]);// ====== shortcuts للحالة الحالية ====== const gridEnabled = current.gridEnabled;const students = current.students;const results = current.results;const topic = current.topic;const task = current.task;const rubricText = current.rubricText;const cropX = current.cropX;const cropY = current.cropY;const cropW = current.cropW;const cropH = current.cropH;const a4Overlay = useMemo(() => fitA4CropPercent({x: cropX,y: cropY,w: cropW,h: cropH}),[cropX,cropY,cropW,cropH]);// ====== سياق الدرس ====== const lessonContext = useMemo(() => {const rubric = rubricText .split("n") .map((s) => s.trim()) .filter(Boolean); return {lesson: topic,goal: task,rubric,master_prompt: MAGIC_EVALUATION_PROMPT,privacy: "ركز فقط على العمل التعليمي الظاهر: الورقة، الكتابة، اليدين، الأدوات، النموذج أو المجسم. تجاهل أي وجه أو خلفية أو عناصر لا تخص المهمة.",math_ocr_rules: ["طبّع الأرقام العربية والهندية والفارسية قبل الحكم: ٠١٢٣٤٥٦٧٨٩ = 0123456789 = ۰۱۲۳۴۵۶۷۸۹.","طبّع رموز العمليات قبل الحل: × x X * = ضرب، + = جمع، - = طرح، ÷ / = قسمة.","في جدول الضرب والجمع والطرح والقسمة: اقرأ كل مسألة على حدة ثم احسب الناتج الصحيح ثم قارن بالقيمة المكتوبة بعد التطبيع.","إذا كان شكل الرقم ملتبساً فلا تخمّن؛ استخدم السياق والتسلسل، وإن لم يكفِ الدليل فأرجع ERROR أو NEEDS_FIX.","في الأنشطة الرياضية أعد السطور داخل math_transcription كما ظهرت بصرياً، ثم دع التحقق النهائي للنظام البرمجي.",],output_mode: "teacher_report",class_id: classId,scoring_scale: "0_to_10",strict_mode: true,no_guessing: true,peripheral_policy: "إذا وصلت بيانات من الأجهزة الطرفية فاعتمد عليها كأساس رئيسي للتقييم، ولا تعتبر غياب شاشة الحساس في الصورة سبباً للفشل ما دامت البيانات الرقمية موجودة.",peripheral_snapshot_text: peripheralSnapshotToTeacherText(peripheralSnapshot),peripheral_snapshot: peripheralSnapshot,};},[topic,task,rubricText,classId]);const startCamera = async () =>{// عند البدء أو إعادة التشغيل: أوقف المراقبة لتجنب التداخل أثناء تبديل الستريم setMonitoring(false);setStatus("");// أوقف أي ستريم سابق try{const v = videoRef.current;if (v?.srcObject){(v.srcObject as MediaStream).getTracks().forEach((t) => t.stop());v.srcObject = null}}// 🔧 قيود الفيديو: // - في الجوال: نعتمد على facingMode (أمامي/خلفي) // - في اللابتوب: نعتمد على deviceId (default أو جهاز محدد) const videoConstraints: any ={width:{ideal:1920},height:{ideal:1080},}if (isMobile){// بعض الأجهزة تدعم "ideal" أفضل من "exact" videoConstraints.facingMode ={ideal:facingMode}}else if (selectedDeviceId){videoConstraints.deviceId ={exact:selectedDeviceId}}try{const stream = await navigator.mediaDevices.getUserMedia({video: videoConstraints,audio: false,});if (videoRef.current){videoRef.current.srcObject = stream;await videoRef.current.play()}setStarted(true);refreshVideoDevices();setStatus("✅ تم تشغيل الكاميرا")}catch (e: any){setStarted(false);const msg = e?.name === "NotAllowedError" ? "تم رفض إذن الكاميرا. فعّل الإذن من المتصفح ثم جرّب." : e?.name === "NotFoundError" ? "لا توجد كاميرا متاحة على هذا الجهاز." : "تعذر تشغيل الكاميرا. جرّب تبديل الكاميرا أو إعادة تحميل الصفحة.";setStatus(`⚠️ ${msg}`)}}const stopCamera = () =>{const v = videoRef.current;if (v?.srcObject){const tracks = (v.srcObject as MediaStream).getTracks();tracks.forEach((t) => t.stop());v.srcObject = null}setStarted(false);setMonitoring(false);setStatus("تم إيقاف الكاميرا")}const switchCamera = async () =>{// ✅ زر واحد: // - جوال: تبديل أمامي/خلفي // - لابتوب: التنقل بين default ثم بقية الكاميرات if (isMobile){setFacingMode((prev) => (prev === "user" ? "environment" : "user"));setSelectedDeviceId(null);// بالجوال نعتمد على facingMode}else{const ids = [null,...videoDevices.map((d) => d.deviceId)];const curIndex = ids.findIndex((id) => id === selectedDeviceId);const nextIndex = curIndex === -1 ? 0 : (curIndex + 1) % ids.length;setSelectedDeviceId(ids[nextIndex] as any)}// إذا الكاميرا شغالة، أعد تشغيل الستريم فورًا لتطبيق التبديل if (started){// انتظر دورة state بسيطة setTimeout(() => {startCamera();},50)}}// ==== ROI شبكة ديناميكية (حسب عدد الطلاب المفعّلين) ==== const getGridSpec = (count: number) =>{// تخطيط بسيط وواضح: عمودان أغلب الوقت if (count <= 1) return{cols:1,rows: 1}if (count === 2) return{cols:2,rows: 1}return{cols:2,rows: Math.ceil(count / 2)}}const getGridROI = (slotIndex: number,activeCount: number) =>{const{cols,rows}= getGridSpec(activeCount);const col = slotIndex % cols;const row = Math.floor(slotIndex / cols);// هوامش/فراغات تتكيّف مع عدد الصفوف const padX = .04;const padY = rows >= 4 ? .06 : .08;const gapX = .02;const gapY = rows >= 4 ? .02 : .03;const usableW = 1 - padX * 2 - gapX * (cols - 1);const usableH = 1 - padY * 2 - gapY * (rows - 1);const cellW = usableW / cols;const cellH = usableH / rows;const x = padX + col * (cellW + gapX);const y = padY + row * (cellH + gapY);return{x,y,w: cellW,h: cellH}}const captureFrameByROI = (roi: {x: number; y: number; w: number; h: number}): string | null =>{const v = videoRef.current;const c = canvasRef.current;if (!v || !c) return null;if (!v.videoWidth || !v.videoHeight) return null;const vx = v.videoWidth;const vy = v.videoHeight;const sx = Math.round(roi.x * vx);const sy = Math.round(roi.y * vy);const sw = Math.round(roi.w * vx);const sh = Math.round(roi.h * vy);const outW = UPLOAD_MAX_WIDTH;const outH = Math.round((outW * sh) / sw);c.width = outW;c.height = outH;const ctx = c.getContext("2d");if (!ctx) return null;ctx.drawImage(v,sx,sy,sw,sh,0,0,outW,outH);return c.toDataURL("image/jpeg",UPLOAD_JPEG_QUALITY)}const captureCroppedFrameLegacy = (): string | null =>{const v = videoRef.current;const c = canvasRef.current;if (!v || !c) return null;if (!v.videoWidth || !v.videoHeight) return null;const vx = v.videoWidth;const vy = v.videoHeight;const a4Crop = fitA4CropPercent({x: cropX,y: cropY,w: cropW,h: cropH});const sx = Math.round((a4Crop.x / 100) * vx);const sy = Math.round((a4Crop.y / 100) * vy);const sw = Math.round((a4Crop.w / 100) * vx);const sh = Math.round((a4Crop.h / 100) * vy);const outW = UPLOAD_MAX_WIDTH;const outH = Math.round(outW / A4_RATIO);c.width = outW;c.height = outH;const ctx = c.getContext("2d");if (!ctx) return null;ctx.drawImage(v,sx,sy,sw,sh,0,0,outW,outH);return c.toDataURL("image/jpeg",UPLOAD_JPEG_QUALITY)}const monitorLocksRef = useRef<Record<string,boolean>>({});const sendToAI = async (frameDataUrl: string,extra?: any): Promise<ObserveResult> =>{const studentCode = String(extra?.student?.student_id ?? extra?.student_id ?? "").trim();const studentName = String(extra?.student?.name ?? extra?.student_name ?? "الطالب").trim() || "الطالب";if (studentCode && monitorLocksRef.current[studentCode]){return{ok: false,error: `${studentName}: تم إقفال المراقبة بسبب تجاوز العدد المسموح (10 مرات) من المراقبة`,feedback: "",score: undefined,step_status: "ERROR",monitor_locked: true,lock_message: `${studentName}: تم إقفال المراقبة بسبب تجاوز العدد المسموح (10 مرات) من المراقبة`,}}if (studentCode){try{const resLimit = await fetch(getApiUrl("/api/monitor-limit"),{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({student_code: studentCode}),});const limitResult = await resLimit.json();if (!limitResult?.ok){monitorLocksRef.current[studentCode] = true;const msg = `${studentName}: تم إقفال المراقبة بسبب تجاوز العدد المسموح (10 مرات) من المراقبة`;return{ok:false,error: msg,feedback: "",score: undefined,step_status: "ERROR",monitor_locked: true,lock_message: msg,}}}catch{return{ok:false,error: "تعذر التحقق من حد المراقبة اليومي",feedback: "",score: undefined,step_status: "ERROR",}}}if (!frameDataUrl || typeof frameDataUrl !== "string"){return{ok:false,error: "الصورة الملتقطة غير صالحة",feedback: "",score: undefined,step_status: "ERROR",}}const controller = new AbortController();const timeoutId = window.setTimeout(() => controller.abort(),12000);try{const payload ={image: frameDataUrl,frame: frameDataUrl,lessonContext,peripheralSnapshot,peripheralContext:{hasPeripheralData:!!peripheralSnapshot,text: peripheralSnapshotToTeacherText(peripheralSnapshot),preferPeripheralData: true,allowImageFallback: true,},...extra,}const resp = await fetch(getApiUrl("/api/observe"),{method: "POST",headers: {"Content-Type": "application/json"},signal: controller.signal,body: JSON.stringify(payload),});const rawText = await resp.text();let raw: any;try{raw = JSON.parse(rawText)}catch{console.error("❌ Raw response from /api/observe:",rawText);return{ok:false,error: "السيرفر لم يرجع JSON صالح",feedback: rawText?.slice?.(0,200) || "",score: undefined,step_status: "ERROR",}}if (!resp.ok){console.error("❌ API Error:",raw);return{ok:false,error: raw?.error || raw?.details || "Observe API failed",feedback: raw?.feedback || "",score: undefined,step_status: "ERROR",}}const data = normalizeObserveResult(raw,lessonContext);return data}catch (err: any){if (err?.name === "AbortError"){return{ok:false,error: "انتهت مهلة الإرسال: السيرفر تأخر أكثر من 12 ثانية",feedback: "",score: undefined,step_status: "ERROR",}}return{ok:false,error: err?.message || "تعذر الاتصال بسيرفر التقييم",feedback: "",score: undefined,step_status: "ERROR",}}finally{window.clearTimeout(timeoutId)}}const updateStudentResult = (idx: number,patch: Partial<StudentResult>) =>{// ✅ مهم: تحديث وظيفي لتفادي مشكلة "آخر طالب فقط" بسبب الـ stale state داخل الحلقات setClassStates((prev) => {const base = prev[classId] ?? makeDefaultClassState(); const nextResults = base.results.map((r,i) => (i === idx ? {...r,...patch} : r)); const next = {...base,results: nextResults}; persistForMonitor(classId,next,grade,section); return {...prev,[classId]: next};})}// ========================================================= // ✅ إصلاح الالتقاط السريع + جعل الالتقاط كل 5 ثواني // السبب السابق: useEffect كانت تعتمد على students/results // وبالتالي كل تحديث نتيجة يعيد تشغيل الـ effect ويعمل tick فوري كثير. // // الحل: // 1) نخزّن آخر قيم نحتاجها في refs // 2) useEffect للمراقبة يعتمد على (monitoring,classId) فقط // 3) نبدأ أول التقاط بعد 5 ثواني (بدون "فوري") // ========================================================= const TICK_MS = 30000;const runtimeRef = useRef({gridEnabled,students,results,cropX,cropY,cropW,cropH,lessonContext,grade,section,});useEffect(() => {runtimeRef.current = {gridEnabled,students,results,cropX,cropY,cropW,cropH,lessonContext,grade,section,};},[gridEnabled,students,results,cropX,cropY,cropW,cropH,lessonContext,grade,section]);const busyRef = useRef(false);useEffect(() => {if (!monitoring) return; let cancelled = false; let intervalId: number | null = null; let timeoutId: number | null = null; const tick = async () => {if (cancelled) return; // ✅ تأكد أن جلسة المراقبة ما زالت فعّالة (10 دقائق) const sess = getMonitorSession(); if (!sess) {setStatus("🔒 المراقبة مقفلة: أدخل رقم الهوية لتفعيلها (10 دقائق)"); setMonitoring(false); return;} if (busyRef.current) return; busyRef.current = true; try {const rt = runtimeRef.current; if (rt.gridEnabled) {const active = rt.students .map((s,i) => ({s,i})) .filter((x) => x.s.enabled && (x.s.name || "").trim()) .map((x,slotIndex) => ({...x,slotIndex})); if (active.length === 0) {setStatus("لا يوجد طلاب مفعّلين في التقسيم"); return;} setStatus(`(${gradeOptions[rt.grade - 1]?.label || ""} / ${rt.section}) — مراقبة الشبكة: ${active.length} طالب...`); for (const item of active) {const idx = item.i; const st = item.s; if (st.student_id && monitorLocksRef.current[st.student_id]) {updateStudentResult(idx,{status: "🔒 مقفل",feedback: `${st.name || `طالب ${idx + 1}`}: تم إقفال المراقبة بسبب تجاوز العدد المسموح (10 مرات) من المراقبة`,score: null,updatedAt: Date.now(),}); continue;} updateStudentResult(idx,{status: "⏳ إرسال..."}); const roi = getGridROI(item.slotIndex,active.length); const frame = captureFrameByROI(roi); if (!frame) {updateStudentResult(idx,{status: "⚠️ لا يوجد إطار"}); continue;} updateStudentResult(idx,{lastImg: frame}); const data = await sendToAI(frame,{class_id: classId,student: {student_id: st.student_id,name: st.name,learning_style: st.style,index: idx + 1},student_id: st.student_id,student_name: st.name,roi: {grid: "auto",index: item.slotIndex,activeCount: active.length},}); if (data.monitor_locked) {if (st.student_id) monitorLocksRef.current[st.student_id] = true; updateStudentResult(idx,{status: "🔒 مقفل",feedback: data.lock_message || data.error || "تم إقفال المراقبة",score: null,updatedAt: Date.now(),}); continue;} if (!data.ok) {updateStudentResult(idx,{status: "❌ خطأ",feedback: data.error || "خطأ غير معروف",score: null,updatedAt: Date.now(),});} else {const nextScore = typeof data.score === "number" ? data.score : null; updateStudentResult(idx,{status: "✅ تم",feedback: data.feedback || "",score: nextScore,updatedAt: Date.now(),}); triggerAssessmentAvatar(nextScore,st.name || `طالب ${idx + 1}`);} // ☁️ حفظ ذكي لتقليل الباندويدث: ارفع فقط عند تغيّر الحالة/الأرشفة if (data.monitor_locked) continue; const statusTag = data.ok ? (data.step_status || "OK") : "ERROR"; if (!data.monitor_locked) void maybeUploadFrame({frame,studentName: st.name,studentId: st.student_id,learningType: st.style,statusTag,score: typeof data.score === "number" ? data.score : null,feedbackText: data.feedback || "",}).catch(() => {});}} else {setStatus(`(${gradeOptions[runtimeRef.current.grade - 1]?.label || ""} / ${runtimeRef.current.section}) — مراقبة (فردي)...`); const student0 = runtimeRef.current.students[0] || {name: "طالب",student_id: "",style: "بصري" as LearningStyle,enabled: true}; if (student0.student_id && monitorLocksRef.current[student0.student_id]) {updateStudentResult(0,{status: "🔒 مقفل",feedback: `${student0.name}: تم إقفال المراقبة بسبب تجاوز العدد المسموح (10 مرات) من المراقبة`,score: null,updatedAt: Date.now(),}); setStatus("🔒 تم إقفال المراقبة لهذا الطالب"); return;} const frame = captureCroppedFrameLegacy(); if (!frame) return; updateStudentResult(0,{lastImg: frame,status: "⏳ إرسال..."}); const data = await sendToAI(frame,{class_id: classId,student: {student_id: student0.student_id,name: student0.name,learning_style: student0.style,index: 1},student_id: student0.student_id,student_name: student0.name}); if (data.monitor_locked) {if (student0.student_id) monitorLocksRef.current[student0.student_id] = true; updateStudentResult(0,{status: "🔒 مقفل",feedback: data.lock_message || data.error || "تم إقفال المراقبة",score: null,updatedAt: Date.now(),}); setStatus("🔒 تم إقفال المراقبة لهذا الطالب"); return;} if (!data.ok) {updateStudentResult(0,{status: "❌ خطأ",feedback: data.error || "خطأ غير معروف",score: null,updatedAt: Date.now(),}); setStatus("خطأ");} else {const nextScore = typeof data.score === "number" ? data.score : null; updateStudentResult(0,{status: "✅ تم",feedback: data.feedback || "",score: nextScore,updatedAt: Date.now(),}); triggerAssessmentAvatar(nextScore,runtimeRef.current.students?.[0]?.name || "يا بطل"); setStatus("تم التحليل ✅");} // ☁️ حفظ ذكي (فردي) لتقليل الباندويدث const statusTag0 = data.ok ? (data.step_status || "OK") : "ERROR"; void maybeUploadFrame({frame,studentName: student0.name,studentId: student0.student_id,learningType: student0.style,statusTag: statusTag0,score: typeof data.score === "number" ? data.score : null,feedbackText: data.feedback || "",}).catch(() => {});}} finally {busyRef.current = false;}}; // ✅ بدون التقاط فوري: أول التقاط بعد 5 ثواني timeoutId = window.setTimeout(() => {void tick(); intervalId = window.setInterval(() => void tick(),TICK_MS);},TICK_MS); return () => {cancelled = true; if (timeoutId) window.clearTimeout(timeoutId); if (intervalId) window.clearInterval(intervalId); busyRef.current = false;}; // eslint-disable-next-line react-hooks/exhaustive-deps},[monitoring,classId]);// ✅ "الخط في المربعات يكون في الوسط" const fieldStyle: React.CSSProperties ={width:"100%",display: "block",boxSizing: "border-box",padding: "10px 12px",height: 44,borderRadius: 12,border: "1px solid rgba(255,255,255,0.25)",background: "rgba(0,0,0,0.25)",color: "#fff",outline: "none",textAlign: "center",}const styleBadge = (style: LearningStyle) =>{const map: Record<LearningStyle,string> ={بصري:"👁️ بصري",سمعي: "🎧 سمعي",لمسي: "✋ لمسي",حركي: "🏃 حركي",تأملي: "🧠 تأملي",}return map[style]}const gridOverlayBoxes = useMemo(() => {if (!gridEnabled) return null; const active = students .map((s,i) => ({s,i})) .filter((x) => x.s.enabled && (x.s.name || "").trim()); if (active.length === 0) return null; return active.map((item,slotIndex) => {const roi = getGridROI(slotIndex,active.length); const s = item.s; return (<div key={item.i} style={{position: "absolute",left: `${roi.x * 100}%`,top: `${roi.y * 100}%`,width: `${roi.w * 100}%`,height: `${roi.h * 100}%`,borderRadius: 10,border: "2px dashed rgba(255,255,255,0.65)",boxSizing: "border-box",pointerEvents: "none",overflow: "hidden",}} > <div style={{position: "absolute",insetInline: 8,top: 8,padding: "6px 10px",borderRadius: 999,background: "rgba(0,0,0,0.45)",border: "1px solid rgba(255,255,255,0.22)",color: "#fff",fontWeight: 800,fontSize: 12,display: "inline-flex",gap: 8,alignItems: "center",maxWidth: "95%",whiteSpace: "nowrap",textOverflow: "ellipsis",overflow: "hidden",}} > <span>#{slotIndex + 1}</span> <span style={{opacity: .95}}>{(s.name || "").trim()}</span> <span style={{opacity: .85}}>— {styleBadge(s.style)}</span> </div> </div>);});},[gridEnabled,students]);return (<div style={{maxWidth: 1200,margin: "0 auto",padding: isMobile ? 12 : 20}}> <EduAIBg /> {} <div style={{display: "flex",justifyContent: "space-between",gap: 10,alignItems: "center",flexWrap: "wrap",}} > <h1 style={{color: "#fff",margin: 0,fontSize: isMobile ? 18 : 28,lineHeight: 1.25}}>محطة التعلم الذكية – لوحة المعلم</h1> <div style={{display: "flex",gap: 10,flexWrap: "wrap",alignItems: "center"}}> <Link to="devices" style={{color: "#fff",textDecoration: "none",fontWeight: 800,padding: "10px 14px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(0,0,0,0.25)",}} > 🔌 صفحة الأجهزة الطرفية </Link> </div> </div> {} <div style={{marginTop: 14,background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 14,borderRadius: 16,color: "#fff",}} > <div style={{fontWeight: 900,marginBottom: 10}}> اختر البيئة التفاعلية لتحديد تكيف المتعلم </div> <div style={{display: "grid",gridTemplateColumns: isMobile ? "1fr" : "minmax(280px, 1fr) minmax(260px, 1fr)",gap: 12,alignItems: "center",}} > <select value={selectedBotUrl} onChange={(e) => setSelectedBotUrl(e.target.value)} style={{...fieldStyle,cursor: "pointer"}} > <option value="">— اختر البيئة —</option> {botOptions.map((b,i) => (<option key={i} value={b.url}> {b.label} </option>))} </select> <div style={{display: "flex",gap: 12,flexWrap: "wrap",alignItems: "center",justifyContent: "center",padding: "8px 10px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.20)",}} > <label style={{display: "flex",gap: 8,alignItems: "center",cursor: "pointer"}}> <input type="radio" name="openMode" value="embed" checked={openMode === "embed"} onChange={() => setOpenMode("embed")} /> داخل نفس الصفحة </label> <label style={{display: "flex",gap: 8,alignItems: "center",cursor: "pointer"}}> <input type="radio" name="openMode" value="newtab" checked={openMode === "newtab"} onChange={() => setOpenMode("newtab")} /> في صفحة جديدة </label> </div> </div> <div style={{display: "flex",justifyContent: "center",marginTop: 12}}> <button onClick={openSelectedBot} style={{fontSize: 18,padding: "10px 16px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(59,130,246,0.22)",color: "#fff",fontWeight: 900,cursor: "pointer",}} > افتح البيئة التفاعلية </button> </div> {} {openMode === "embed" && embedBotUrl && (<div id="typebot-embed" style={{marginTop: 14,borderRadius: 14,overflow: "hidden",border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.20)",}} > <div style={{display: "flex",justifyContent: "space-between",gap: 10,alignItems: "center",padding: "10px 12px",borderBottom: "1px solid rgba(255,255,255,0.14)",flexWrap: "wrap",}} > <div style={{fontWeight: 900}}>🧪 البيئة التفاعلية</div> <div style={{display: "flex",gap: 10,flexWrap: "wrap"}}> <a href={embedBotUrl} target="_blank" rel="noreferrer" style={{color: "#fff",textDecoration: "none",fontWeight: 800,padding: "8px 10px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(0,0,0,0.25)",}} > ↗️ فتح في صفحة جديدة </a> <button onClick={() => setEmbedBotUrl("")} style={{fontSize: 14,padding: "8px 10px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: "rgba(239,68,68,0.18)",color: "#fff",fontWeight: 900,cursor: "pointer",}} > ✖️ إغلاق </button> </div> </div> <iframe title="Typebot" src={embedBotUrl} style={{width: "100%",height: isMobile ? Math.round(window.innerHeight * .78) : 720,border: 0,display: "block",background: "transparent",}} allow="microphone; camera; clipboard-read; clipboard-write" /> </div>)} </div> {} <div style={{marginTop: 14,background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 14,borderRadius: 16,color: "#fff",}} > <div style={{display: "flex",gap: 12,flexWrap: "wrap",alignItems: "center",justifyContent: "space-between"}}> <div style={{fontWeight: 900}}>🏫 اختيار الفصل</div> <div style={{display: "flex",gap: 10,flexWrap: "wrap",alignItems: "center"}}> <select value={grade} onChange={(e) => setGrade(Number(e.target.value))} style={{...fieldStyle,width: 220,cursor: "pointer"}} > {gradeOptions.map((g) => (<option key={g.value} value={g.value}> {g.label} </option>))} </select> <select value={section} onChange={(e) => setSection(e.target.value)} style={{...fieldStyle,width: 120,cursor: "pointer"}} > {sectionOptions.map((s) => (<option key={s.value} value={s.value}> الشعبة {s.label} </option>))} </select> <div style={{padding: "10px 12px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.20)",fontWeight: 900,}} > الحالي: {gradeOptions[grade - 1]?.label} ({section}) </div> </div> </div> <div style={{marginTop: 8,fontSize: 12,opacity: .85}}> كل فصل له بياناته الخاصة (طلاب/نتائج/شبكة) — وعند تغيير الفصل يتم إيقاف المراقبة تلقائيًا لمنع أي تداخل. </div> </div> {} <div style={{background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 14,borderRadius: 16,marginTop: 14,color: "#fff",}} > <div style={{display: "flex",gap: 12,alignItems: "center",flexWrap: "wrap",justifyContent: "space-between",}} > <div style={{fontWeight: 900}}>🧩 تقسيم الكاميرا حسب نمط التعلم</div> <button onClick={() => {setCurrent({gridEnabled: !gridEnabled}); setMonitoring(false); setStatus("");}} style={{fontSize: 16,padding: "10px 14px",borderRadius: 12,border: "1px solid rgba(255,255,255,0.22)",background: gridEnabled ? "rgba(34,197,94,0.20)" : "rgba(0,0,0,0.25)",color: "#fff",fontWeight: 900,cursor: "pointer",}} > {gridEnabled ? "✅ مفعل (شبكة)" : "🧩 تفعيل التقسيم"} </button> </div> {gridEnabled && (<div style={{marginTop: 12}}> <div style={{fontSize: 13,opacity: .9,marginBottom: 10}}> اكتب <b>اسم الطالب</b> واختر <b>نمط تعلمه</b> — سيتم إرسال تحليل مستقل لكل طالب. </div> <div style={{display: "grid",gridTemplateColumns: "1fr 220px 220px 90px",gap: 10,alignItems: "center",}} > <div style={{fontWeight: 800,opacity: .9}}>اسم الطالب</div> <div style={{fontWeight: 800,opacity: .9}}>رقم هوية الطالب</div> <div style={{fontWeight: 800,opacity: .9}}>نمط التعلم</div> <div style={{fontWeight: 800,opacity: .9,textAlign: "center"}}>تفعيل</div> {students.map((s,i) => (<div key={i} style={{display: "contents"}}> <input value={s.name} onChange={(e) => {const v = e.target.value; setCurrent({students: students.map((x,idx) => (idx === i ? {...x,name: v} : x)),});}} style={fieldStyle} placeholder={`طالب ${i + 1}`} /> <input value={s.student_id} onChange={(e) => {const v = e.target.value; setCurrent({students: students.map((x,idx) => (idx === i ? {...x,student_id: v} : x)),});}} style={fieldStyle} placeholder="رقم الهوية / الرقم المدني" /> <select value={s.style} onChange={(e) => {const v = e.target.value as LearningStyle; setCurrent({students: students.map((x,idx) => (idx === i ? {...x,style: v} : x)),});}} style={{...fieldStyle,height: 44,cursor: "pointer"}} > <option value="بصري">👁️ بصري</option> <option value="سمعي">🎧 سمعي</option> <option value="لمسي">✋ لمسي</option> <option value="حركي">🏃 حركي</option> <option value="تأملي">🧠 تأملي</option> </select> <div style={{textAlign: "center"}}> <input type="checkbox" checked={s.enabled} onChange={(e) => {const v = e.target.checked; setCurrent({students: students.map((x,idx) => (idx === i ? {...x,enabled: v} : x)),});}} style={{transform: "scale(1.2)"}} /> </div> </div>))} </div> <div style={{marginTop: 10,fontSize: 12,opacity: .85}}> ملاحظة: الشبكة على الفيديو تتكيّف تلقائيًا حسب عدد الطلاب المفعّلين (عمودان + صفوف حسب العدد). </div> </div>)} </div> {} <div style={{background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 14,borderRadius: 16,marginTop: 14,color: "#fff",}} > <h3 style={{marginTop: 0,textAlign: "center"}}>إعداد الدرس (المعلم)</h3> <div style={{display: "grid",gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",gap: 12,alignItems: "start",}} > <label style={{display: "block"}}> <div style={{fontWeight: 800,marginBottom: 6,textAlign: "center"}}> الموضوع/الدرس </div> <input value={topic} onChange={(e) => setCurrent({topic: e.target.value})} placeholder="مثال: علوم - الكهرباء | رياضيات - الدوال | خط عربي - الحروف" style={fieldStyle} /> </label> <label style={{display: "block"}}> <div style={{fontWeight: 800,marginBottom: 6,textAlign: "center"}}> مهمة الذكاء الاصطناعي </div> <input value={task} onChange={(e) => setCurrent({task: e.target.value})} placeholder="مثال: بناء نموذج دائرة كهربائية وتحديد الموجب والسالب" style={fieldStyle} /> </label> </div> <label style={{display: "block",marginTop: 12}}> <div style={{fontWeight: 800,marginBottom: 6,textAlign: "center"}}> معايير التقييم (سطر لكل معيار) </div> <textarea value={rubricText} onChange={(e) => setCurrent({rubricText: e.target.value})} rows={5} placeholder={"مثال:nيوصل الأجزاء بشكل صحيحnالتسميات واضحةnالترتيب صحيح"} style={{...fieldStyle,height: "auto",minHeight: 140,resize: "vertical",lineHeight: 1.7,textAlign: "center",}} /> </label> <div style={{marginTop: 10,fontSize: 13,color: "rgba(255,255,255,0.85)",textAlign: "center"}}> ✅ سيتم إرسال هذه الإعدادات مع كل صورة للذكاء الاصطناعي. <br /> الخصوصية: النظام يركز على اليدين + المجسم ويتجاهل أي وجه/خلفية قدر الإمكان. </div> </div> {} <div style={{display: "flex",gap: 16,flexWrap: "wrap",justifyContent: "center",marginTop: 14,}} > {!started ? (<button onClick={startCamera} style={{fontSize: 18,padding: "10px 16px"}}> تشغيل الكاميرا </button>) : (<> <button onClick={switchCamera} style={{fontSize: 18,padding: "10px 16px"}}> 🔄 تبديل الكاميرا{" "} {isMobile ? facingMode === "user" ? "(أمامية)" : "(خلفية)" : selectedDeviceId ? "(جهاز آخر)" : "(الافتراضية)"} </button> <button onClick={async () => {if (monitoring) {setMonitoring(false); return;} const s = await ensureMonitorUnlockedOrPrompt(); if (!s) return; setMonitoring(true);}} style={{fontSize: 18,padding: "10px 16px"}} > {monitoring ? "إيقاف المراقبة" : gridEnabled ? "بدء المراقبة (كل 30 ثانية)" : "بدء المراقبة (كل 30 ثانية)"} </button> <button onClick={stopCamera} style={{fontSize: 18,padding: "10px 16px"}}> إيقاف الكاميرا </button> <button onClick={() => {const url = new url(window.location.origin); url.pathname = "/ar/monitoring"; url.searchParams.set("classId",classId); window.open(url.toString(),"_blank","noopener,noreferrer");}} style={{fontSize: 18,padding: "10px 16px"}} > افتح المراقبة في صفحة جديدة </button> </>)} </div> <p style={{textAlign: "center",marginTop: 10,color: "#fff"}}> الحالة: <b>{status || "جاهز"}</b> </p> {} <div style={{display: "grid",gridTemplateColumns: isMobile ? "1fr" : "minmax(520px, 1.35fr) minmax(340px, 1fr)",gap: 16,marginTop: 16,alignItems: "start",}} > {} <div> <div style={{position: "relative",borderRadius: 14,overflow: "hidden"}}> <video ref={videoRef} autoPlay playsInline style={{width: "100%",background: "#000",aspectRatio: "16/9",objectFit: "cover",}} /> {gridOverlayBoxes} {!gridEnabled && (<div style={{position: "absolute",left: `${a4Overlay.x}%`,top: `${a4Overlay.y}%`,width: `${a4Overlay.w}%`,height: `${a4Overlay.h}%`,border: "3px dashed rgba(255,255,255,0.92)",boxSizing: "border-box",pointerEvents: "none",boxShadow: "0 0 0 9999px rgba(0,0,0,0.18)",}} />)} </div> {!gridEnabled && (<div style={{marginTop: 12,background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 12,borderRadius: 12,color: "#fff",}} > <b>تحديد ورقة الطالب كاملة بمقاس A4</b> <div style={{display: "flex",gap: 10,flexWrap: "wrap",marginTop: 10}}> <button onClick={() => setCurrent(makeA4CropFromWidth(24,6,50))} style={{fontSize: 16,padding: "8px 12px"}} > ضبط A4 تلقائياً </button> <div style={{alignSelf: "center",opacity: .92,fontSize: 14}}> الإطار المتقطع هو الجزء الذي يُرسل للذكاء الاصطناعي فقط. </div> </div> <div style={{display: "grid",gridTemplateColumns: "1fr 1fr",gap: 10,marginTop: 10}}> <label> X%: {cropX} <input type="range" min={0} max={70} value={cropX} onChange={(e) => setCurrent({cropX: +e.target.value})} style={{width: "100%"}} /> </label> <label> Y%: {cropY} <input type="range" min={0} max={70} value={cropY} onChange={(e) => setCurrent({cropY: +e.target.value})} style={{width: "100%"}} /> </label> <label> عرض الورقة A4 %: {cropW} <input type="range" min={20} max={70} value={cropW} onChange={(e) => {const next = makeA4CropFromWidth(cropX,cropY,+e.target.value); setCurrent(next);}} style={{width: "100%"}} /> </label> <label> ارتفاع الورقة A4 %: {cropH} <input type="range" min={30} max={92} value={cropH} onChange={(e) => {const nextH = +e.target.value; const nextW = nextH * A4_RATIO; setCurrent({cropW: Math.round(nextW),cropH: Math.round(nextH),cropX: Math.round(clamp(cropX,0,100 - nextW)),cropY: Math.round(clamp(cropY,0,100 - nextH)),});}} style={{width: "100%"}} /> </label> </div> <p style={{marginTop: 8,fontSize: 13,color: "rgba(255,255,255,0.85)"}}> ضع الورقة كاملة داخل الإطار المتقطع وبشكل طولي A4. لن يتم إرسال الشاشة الكبيرة أو الخلفية؛ سيُرسل فقط قص الورقة داخل هذا الإطار. </p> </div>)} </div> {} <div> <div style={{background: "rgba(255,255,255,0.10)",border: "1px solid rgba(255,255,255,0.18)",backdropFilter: "blur(10px)",padding: 12,borderRadius: 12,color: "#fff",}} > <div style={{display: "flex",justifyContent: "space-between",gap: 10,flexWrap: "wrap",alignItems: "center"}}> <b> نتائج الذكاء الاصطناعي — {gradeOptions[grade - 1]?.label} ({section}) </b> </div> <div style={{marginTop: 10,display: "grid",gap: 10}}> {students.map((s,i) => {const r = results[i]; const show = gridEnabled ? (s.enabled && (s.name || "").trim()) : i === 0; if (!show) return null; return (<div key={i} style={{borderRadius: 12,border: "1px solid rgba(255,255,255,0.18)",background: "rgba(0,0,0,0.25)",padding: 10,}} > <div style={{display: "flex",justifyContent: "space-between",gap: 10,flexWrap: "wrap"}}> <div style={{fontWeight: 900}}> #{i + 1} — {(s.name || "").trim() || `طالب ${i + 1}`} —{" "} <span style={{opacity: .9}}>{styleBadge(s.style)}</span> </div> <div style={{fontWeight: 900,opacity: .95}}> {r.status} {typeof r.score === "number" ? (<span style={{marginInlineStart: 10}}> | {r.score}/10</span>) : null} </div> </div> <div style={{marginTop: 8,fontSize: 13,whiteSpace: "pre-wrap",opacity: .95}}> {r.feedback || "ابدأ المراقبة ليظهر التقييم هنا."} </div> {r.lastImg && (<div style={{marginTop: 8,borderRadius: 10,overflow: "hidden",border: "1px solid rgba(255,255,255,0.14)"}}> <img src={r.lastImg} alt={`student-${i + 1}`} style={{width: "100%",display: "block"}} /> </div>)} {r.updatedAt && (<div style={{marginTop: 6,fontSize: 12,opacity: .75}}> آخر تحديث: {new Date(r.updatedAt).toLocaleTimeString("ar-SA")} </div>)} </div>);})} </div> </div> </div> </div> {} <div style={{width: "100%",display: "flex",justifyContent: "center",margin: "40px 0"}}> <button onClick={() => window.open("https://lookerstudio.google.com/s/gXoU9oZMLB0","_blank")} style={{padding: "18px 32px",fontSize: 20,fontWeight: 700,width: "90%",maxWidth: 520,cursor: "pointer"}} > فتح لوحة بيانات المدرسة (الداش بورد) </button> </div> <AssessmentAvatar score={avatarScore} maxScore={10} studentName={avatarStudentName} visible={avatarVisible} gender={avatarGender} onClose={() => {setAvatarVisible(false); setAvatarScore(null);}} /> <canvas ref={canvasRef} style={{display: "none"}} /> </div>)}*,*:before,*:after{box-sizing:border-box}html,body,#root{width:100%;height:100%;margin:0;padding:0;background-color:#0b1220}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:dark;color:#ffffffeb;background-color:#0b1220;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{min-height:100vh;background-color:#0b1220;overflow-x:hidden}a{font-weight:500;color:#8ab4ff;text-decoration:none}a:hover{color:#a6c8ff}h1,h2,h3,h4,h5,h6{margin:0;font-weight:700}button{border-radius:12px;border:none;padding:.75em 1.4em;font-size:1rem;font-weight:600;font-family:inherit;background-color:#fff;color:#0b1220;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease}button:hover{transform:translateY(-1px);box-shadow:0 6px 18px #00000040}button:focus,button:focus-visible{outline:none}@media (prefers-color-scheme: light){:root{color:#ffffffeb;background-color:#0b1220}}select option{color:#111827!important;background:#fff!important}
