/
ivan2mit
/
demo_script
Обзор
Документация
Войти
/
ivan2mit
/
demo_script
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
ApplicationScriptCard.jsx
382 строки
14 KB
power_ivan
Create: ApplicationScriptCard.jsx, script-block.html
02 июл 2026, 01:23
Верифицирован
02 июл 2026, 01:23
c73c584
Код
Авторство
О чём код?
import React, { useState, useRef, useEffect, useMemo } from "react"; import { Copy, Check, Pencil, Save, X, RotateCcw } from "lucide-react"; const STORAGE_KEY = "applicationScriptTemplate"; const DEFAULT_TEMPLATE = `Добрый день, #1! Меня зовут #2, я педагог дополнительного образования гимназии Сириус. Вы подали заявку на программу "#3", в группе будет обучаться #4 Занятия будут проходить #5. Все летние программы проводятся на платной основе. Стоимость 1 академического часа (40 минут) #6 руб. Данный интенсив рассчитан на #7 часов. Суммарная стоимость составит #8 рублей. Скажите, вас устраивают расписание и стоимость?`; const FIELD_DEFS = [ { key: "1", label: "Имя отчество родителя" }, { key: "2", label: "ФИО педагога" }, { key: "3", label: "Название программы" }, { key: "4", label: "Фамилия Имя ученика" }, { key: "5", label: "Расписание занятий" }, { key: "6", label: "Стоимость 1 академ. часа" }, { key: "7", label: "Часов всего/в месяц" }, { key: "8", label: "Суммарная стоимость" }, ]; // ЗАГЛУШКА: при встраивании передавайте реальный объект из БД как проп `data` const STUB_DATA = { 1: "Анна Ивановна", 2: "Петров Пётр Петрович", 3: "Робототехника для начинающих", 4: "Сидоров Иван", 5: "вторник, четверг 15:00–16:00", 6: "1500", 7: "32 / 8", 8: "48000", }; function renderTemplate(template, data) { return template.replace(/#(\d+)/g, (match, num) => { const value = data[num]; return value !== undefined && value !== "" ? String(value) : match; }); } export default function ApplicationScriptCard({ data = STUB_DATA }) { const [template, setTemplate] = useState(DEFAULT_TEMPLATE); const [isEditing, setIsEditing] = useState(false); const [draft, setDraft] = useState(DEFAULT_TEMPLATE); const [toast, setToast] = useState(false); const [suggest, setSuggest] = useState(null); // { query, start, activeIndex } const textareaRef = useRef(null); const toastTimerRef = useRef(null); useEffect(() => { try { const saved = window.localStorage.getItem(STORAGE_KEY); if (saved) { setTemplate(saved); setDraft(saved); } } catch (e) { // localStorage недоступен — работаем с дефолтом } }, []); useEffect(() => () => clearTimeout(toastTimerRef.current), []); const filledText = useMemo(() => renderTemplate(template, data), [template, data]); const isCustom = template !== DEFAULT_TEMPLATE; const draftIsDefault = draft === DEFAULT_TEMPLATE; const suggestions = useMemo(() => { if (!suggest) return []; const q = suggest.query.toLowerCase(); return FIELD_DEFS.filter( (f) => f.key.startsWith(q) || f.label.toLowerCase().includes(q) ); }, [suggest]); const handleStartEdit = () => { setDraft(template); setIsEditing(true); }; const handleCancel = () => { setDraft(template); setSuggest(null); setIsEditing(false); }; const handleSave = () => { setTemplate(draft); try { if (draft === DEFAULT_TEMPLATE) { window.localStorage.removeItem(STORAGE_KEY); } else { window.localStorage.setItem(STORAGE_KEY, draft); } } catch (e) { // localStorage недоступен } setSuggest(null); setIsEditing(false); }; const handleResetDefault = () => { setDraft(DEFAULT_TEMPLATE); }; const insertAt = (text, start, end, block) => { const next = text.slice(0, start) + block + text.slice(end); return { next, caret: start + block.length }; }; // Вставляет тег, сохраняя текущую прокрутку textarea (иначе браузер скроллит к каретке) const handleInsertBlock = (key) => { const el = textareaRef.current; const block = `#${key}`; const start = el?.selectionStart ?? draft.length; const end = el?.selectionEnd ?? draft.length; const scrollTop = el?.scrollTop ?? 0; const { next, caret } = insertAt(draft, start, end, block); setDraft(next); setSuggest(null); requestAnimationFrame(() => { if (!el) return; el.focus(); el.setSelectionRange(caret, caret); el.scrollTop = scrollTop; }); }; // Открывает автодополнение, когда педагог печатает "#..." прямо в тексте const handleChange = (e) => { const value = e.target.value; const pos = e.target.selectionStart; setDraft(value); const before = value.slice(0, pos); const match = before.match(/#([a-zA-Zа-яА-Я0-9]*)$/); if (match) { setSuggest({ query: match[1], start: pos - match[0].length, activeIndex: 0 }); } else { setSuggest(null); } }; const applySuggestion = (field) => { if (!suggest) return; const el = textareaRef.current; const cursor = el?.selectionStart ?? draft.length; const scrollTop = el?.scrollTop ?? 0; const { next, caret } = insertAt(draft, suggest.start, cursor, `#${field.key}`); setDraft(next); setSuggest(null); requestAnimationFrame(() => { if (!el) return; el.focus(); el.setSelectionRange(caret, caret); el.scrollTop = scrollTop; }); }; const handleKeyDown = (e) => { if (!suggest || suggestions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setSuggest((s) => ({ ...s, activeIndex: (s.activeIndex + 1) % suggestions.length })); } else if (e.key === "ArrowUp") { e.preventDefault(); setSuggest((s) => ({ ...s, activeIndex: (s.activeIndex - 1 + suggestions.length) % suggestions.length, })); } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); applySuggestion(suggestions[suggest.activeIndex]); } else if (e.key === "Escape") { setSuggest(null); } }; const showToast = () => { setToast(true); clearTimeout(toastTimerRef.current); toastTimerRef.current = setTimeout(() => setToast(false), 2000); }; // Резервный способ копирования: в песочнице (iframe с артефактом) navigator.clipboard // часто заблокирован политикой безопасности, поэтому подстраховываемся textarea+execCommand const legacyCopy = (text) => { const el = document.createElement("textarea"); el.value = text; el.style.position = "fixed"; el.style.left = "-9999px"; document.body.appendChild(el); el.focus(); el.select(); let ok = false; try { ok = document.execCommand("copy"); } catch (e) { ok = false; } document.body.removeChild(el); return ok; }; const handleCopy = async () => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(filledText); showToast(); return; } throw new Error("clipboard API недоступен"); } catch (e) { const ok = legacyCopy(filledText); if (ok) { showToast(); } else { console.error("Не удалось скопировать текст автоматически:", e); } } }; return ( <div className="relative w-full max-w-2xl mx-auto rounded-xl border border-slate-200 bg-white shadow-sm"> {/* Уведомление о копировании */} <div className={`pointer-events-none absolute -top-3 right-4 z-20 transition-all duration-300 ${ toast ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1" }`} > <div className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-medium text-white shadow-lg"> <Check size={13} className="text-emerald-400" /> Скрипт скопирован </div> </div> {/* Header */} <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-slate-100"> <div> <h3 className="text-sm font-semibold text-slate-900">Скрипт обращения к родителю</h3> {!isEditing && ( <p className="text-xs text-slate-400 mt-0.5"> {isCustom ? "Изменённый шаблон · " : ""}нажмите на текст, чтобы скопировать </p> )} </div> {!isEditing ? ( <button onClick={handleStartEdit} className="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700 transition-colors shrink-0" > <Pencil size={14} /> Редактировать </button> ) : ( <div className="flex items-center gap-2 shrink-0"> <button onClick={handleCancel} className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-50 transition-colors" > <X size={14} /> Отмена </button> <button onClick={handleSave} className="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700 transition-colors" > <Save size={14} /> Сохранить </button> </div> )} </div> {/* Body */} <div className="px-4 py-3"> {!isEditing ? ( <button onClick={handleCopy} className="group relative w-full text-left rounded-lg -mx-1 px-1 py-1 hover:bg-slate-50 transition-colors" > <p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-700 pr-7"> {filledText} </p> <span className="absolute top-1 right-1 text-slate-300 group-hover:text-slate-500 transition-colors"> <Copy size={15} /> </span> </button> ) : ( <div className="flex flex-col sm:flex-row gap-3"> {/* Textarea + автодополнение по вводу "#" */} <div className="relative flex-1 min-w-0"> <textarea ref={textareaRef} value={draft} onChange={handleChange} onKeyDown={handleKeyDown} onBlur={() => setTimeout(() => setSuggest(null), 120)} rows={9} className="w-full h-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 text-[13px] leading-relaxed text-slate-800 font-mono focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-slate-300" spellCheck={false} /> {suggest && suggestions.length > 0 && ( <div className="absolute left-2 right-2 z-10 mt-1 rounded-lg border border-slate-200 bg-white shadow-lg overflow-hidden"> {suggestions.map((f, i) => ( <button key={f.key} onMouseDown={(e) => { e.preventDefault(); applySuggestion(f); }} className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors ${ i === suggest.activeIndex ? "bg-slate-900 text-white" : "text-slate-700 hover:bg-slate-50" }`} > <span className={ i === suggest.activeIndex ? "text-slate-300" : "text-slate-400" } > #{f.key} </span> {f.label} </button> ))} </div> )} <p className="mt-1 text-[11px] text-slate-400"> Наберите «#», чтобы выбрать блок из списка </p> </div> {/* Список блоков с пояснениями — справа */} <div className="sm:w-48 shrink-0"> <p className="text-[11px] font-medium text-slate-400 mb-1.5"> Доступные блоки </p> <div className="flex flex-col gap-1 max-h-64 overflow-y-auto pr-0.5"> {FIELD_DEFS.map((f) => ( <button key={f.key} onClick={() => handleInsertBlock(f.key)} className="flex items-start gap-1.5 rounded-md border border-slate-200 bg-slate-50 px-2 py-1.5 text-left hover:border-slate-400 hover:bg-white transition-colors" > <span className="text-[11px] font-semibold text-slate-500 shrink-0"> #{f.key} </span> <span className="text-[11px] leading-snug text-slate-600"> {f.label} </span> </button> ))} </div> {!draftIsDefault && ( <button onClick={handleResetDefault} className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-slate-400 hover:text-slate-700 transition-colors" > <RotateCcw size={11} /> Сбросить к дефолту </button> )} </div> </div> )} </div> </div> ); }