Add HF model auto-download with configurable AI env, and share link visibility toggles
This commit is contained in:
+10
-1
@@ -1,3 +1,12 @@
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=сложный-пароль-админки
|
ADMIN_PASSWORD=сложный-пароль-админки
|
||||||
DB_PASSWORD=случайная-длинная-строка
|
DB_PASSWORD=случайная-длинная-строка
|
||||||
|
|
||||||
|
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
|
||||||
|
HUGGINGFACE_TOKEN=
|
||||||
|
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
||||||
|
AI_MODEL=qwen2.5-1.5b-instruct-q4_k_m.gguf
|
||||||
|
# Репозиторий Hugging Face, откуда скачивается модель
|
||||||
|
AI_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF
|
||||||
|
# Промпт по умолчанию для автоисправления текстов (переопределяет встроенный, если в настройках не сохранён свой)
|
||||||
|
AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.
|
||||||
+13
-2
@@ -54,10 +54,18 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.')
|
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -67,11 +75,14 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
student_name VARCHAR(150),
|
student_name VARCHAR(150),
|
||||||
date_from DATE,
|
date_from DATE,
|
||||||
date_to DATE,
|
date_to DATE,
|
||||||
anonymize_names BOOLEAN DEFAULT false,
|
show_student_names BOOLEAN,
|
||||||
expires_at TIMESTAMPTZ,
|
expires_at TIMESTAMPTZ,
|
||||||
access_password_hash VARCHAR(255),
|
access_password_hash VARCHAR(255),
|
||||||
message TEXT,
|
message TEXT,
|
||||||
link_url VARCHAR(500),
|
link_url VARCHAR(500),
|
||||||
|
show_student_message BOOLEAN,
|
||||||
|
show_entry_date BOOLEAN,
|
||||||
|
show_group_photos BOOLEAN,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -13,8 +13,14 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.')
|
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
UPDATE settings
|
||||||
|
SET value = 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'
|
||||||
|
WHERE key = 'ai_prompt' AND value IN (
|
||||||
|
'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.',
|
||||||
|
'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.'
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -29,6 +35,18 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
|
|
||||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
|
||||||
|
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
|
||||||
|
ALTER TABLE share_links DROP COLUMN anonymize_names;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -76,6 +94,16 @@ CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status
|
|||||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
DELETE FROM settings WHERE key = 'share_anonymize_names';
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||||
|
|||||||
@@ -18,4 +18,19 @@ CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at)
|
|||||||
|
|
||||||
-- 6. Add public message and external link shown on the share page
|
-- 6. Add public message and external link shown on the share page
|
||||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||||
|
|
||||||
|
-- 7. Per-link visibility overrides (NULL = fall back to global settings)
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
|
||||||
|
|
||||||
|
-- 8. Replace anonymize_names with positive show_student_names (NULL = inherit global setting)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
|
||||||
|
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
|
||||||
|
ALTER TABLE share_links DROP COLUMN anonymize_names;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+10
-16
@@ -29,6 +29,8 @@ services:
|
|||||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
|
AI_PROMPT: ${AI_PROMPT:-}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -65,28 +67,20 @@ services:
|
|||||||
container_name: text-corrector
|
container_name: text-corrector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
entrypoint: ["/bin/sh", "/start-text-corrector.sh"]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
|
AI_MODEL_REPO: ${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}
|
||||||
|
HUGGINGFACE_TOKEN: ${HUGGINGFACE_TOKEN:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./models:/models
|
- ./models:/models
|
||||||
|
- ./start-text-corrector.sh:/start-text-corrector.sh:ro
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
command:
|
|
||||||
- -m
|
|
||||||
- /models/qwen2.5-1.5b-instruct-q4_k_m.gguf
|
|
||||||
- --host
|
|
||||||
- 0.0.0.0
|
|
||||||
- --port
|
|
||||||
- "8080"
|
|
||||||
- -c
|
|
||||||
- "4096"
|
|
||||||
- -n
|
|
||||||
- "256"
|
|
||||||
- -t
|
|
||||||
- "8"
|
|
||||||
- --temp
|
|
||||||
- "0.1"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -287,7 +287,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.bar:hover{opacity:1}
|
.bar:hover{opacity:1}
|
||||||
.bar.today{opacity:1}
|
.bar.today{opacity:1}
|
||||||
.bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap}
|
.bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap}
|
||||||
.recent{display:flex;flex-direction:column;gap:2px}
|
.recent{display:grid;grid-template-columns:repeat(2,1fr);gap:2px 16px}
|
||||||
.recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s}
|
.recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s}
|
||||||
.recent-item:hover{background:var(--bg)}
|
.recent-item:hover{background:var(--bg)}
|
||||||
.recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
|
.recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
|
||||||
@@ -321,11 +321,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
|
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
|
||||||
.stat .label a:hover{text-decoration:underline}
|
.stat .label a:hover{text-decoration:underline}
|
||||||
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
||||||
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}.recent{grid-template-columns:1fr}}
|
||||||
/* --- System Info Dashboard --- */
|
/* --- System Info Dashboard --- */
|
||||||
.sys-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
.sys-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
||||||
@media(max-width:480px){.sys-grid{grid-template-columns:1fr;gap:10px}}
|
@media(max-width:480px){.sys-grid{grid-template-columns:1fr;gap:10px}}
|
||||||
.sys-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0}
|
.sys-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0}
|
||||||
.sys-card.loading{color:var(--muted);justify-content:center;align-items:center;min-height:80px}
|
.sys-card.loading{color:var(--muted);justify-content:center;align-items:center;min-height:80px}
|
||||||
.sys-card.err{color:#ef4444}
|
.sys-card.err{color:#ef4444}
|
||||||
.sys-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
.sys-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
|||||||
+24
-6
@@ -129,12 +129,30 @@
|
|||||||
<label>Пароль для доступа (необязательно)</label>
|
<label>Пароль для доступа (необязательно)</label>
|
||||||
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field full">
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
<input type="checkbox" id="linkAnonymizeNames">
|
<input type="checkbox" id="linkShowNames">
|
||||||
Показывать только инициалы учеников (анонимизировать)
|
Показывать фамилии и имена учеников
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowMessage">
|
||||||
|
Показать сообщение ученика
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowDate">
|
||||||
|
Показать дату в карточке записи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowPhotos">
|
||||||
|
Показывать фото группы
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||||
<button type="button" class="btn-primary ghost" id="linkCreateCloseBtn">Отмена</button>
|
<button type="button" class="btn-primary ghost" id="linkCreateCloseBtn">Отмена</button>
|
||||||
<button type="button" class="btn-primary" id="linkCreateSaveBtn">Создать</button>
|
<button type="button" class="btn-primary" id="linkCreateSaveBtn">Создать</button>
|
||||||
|
|||||||
+17
-3
@@ -10,6 +10,15 @@ let searchTimer = null;
|
|||||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||||
let aiCorrecting = false;
|
let aiCorrecting = false;
|
||||||
let editDeletePhoto = false;
|
let editDeletePhoto = false;
|
||||||
|
let linkSettings = null;
|
||||||
|
|
||||||
|
async function loadLinkSettings() {
|
||||||
|
if (!linkSettings) {
|
||||||
|
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||||||
|
linkSettings = await res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
return linkSettings;
|
||||||
|
}
|
||||||
|
|
||||||
function isImageFile(name) {
|
function isImageFile(name) {
|
||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
@@ -531,10 +540,15 @@ async function openLinkCreate() {
|
|||||||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
||||||
document.getElementById('linkStudent').value = currentStudent;
|
document.getElementById('linkStudent').value = currentStudent;
|
||||||
document.getElementById('linkExpiresDays').value = '7';
|
document.getElementById('linkExpiresDays').value = '7';
|
||||||
document.getElementById('linkAnonymizeNames').checked = false;
|
document.getElementById('linkShowNames').checked = false;
|
||||||
document.getElementById('linkPassword').value = '';
|
document.getElementById('linkPassword').value = '';
|
||||||
document.getElementById('linkMessage').value = '';
|
document.getElementById('linkMessage').value = '';
|
||||||
document.getElementById('linkUrl').value = '';
|
document.getElementById('linkUrl').value = '';
|
||||||
|
const s = await loadLinkSettings();
|
||||||
|
document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||||
document.getElementById('linkCreateView').style.display = '';
|
document.getElementById('linkCreateView').style.display = '';
|
||||||
document.getElementById('linkResultView').style.display = 'none';
|
document.getElementById('linkResultView').style.display = 'none';
|
||||||
document.getElementById('linkModal').classList.add('open');
|
document.getElementById('linkModal').classList.add('open');
|
||||||
@@ -551,7 +565,7 @@ async function createLink() {
|
|||||||
const date_from = document.getElementById('dateFrom').value;
|
const date_from = document.getElementById('dateFrom').value;
|
||||||
const date_to = document.getElementById('dateTo').value;
|
const date_to = document.getElementById('dateTo').value;
|
||||||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||||
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
|
const show_student_names = document.getElementById('linkShowNames').checked;
|
||||||
const access_password = document.getElementById('linkPassword').value;
|
const access_password = document.getElementById('linkPassword').value;
|
||||||
const message = document.getElementById('linkMessage').value.trim();
|
const message = document.getElementById('linkMessage').value.trim();
|
||||||
const link_url = document.getElementById('linkUrl').value.trim();
|
const link_url = document.getElementById('linkUrl').value.trim();
|
||||||
@@ -561,7 +575,7 @@ async function createLink() {
|
|||||||
const res = await fetch(`${API}/api/links`, {
|
const res = await fetch(`${API}/api/links`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null })
|
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked })
|
||||||
});
|
});
|
||||||
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
||||||
const link = await res.json();
|
const link = await res.json();
|
||||||
|
|||||||
+29
-7
@@ -2,15 +2,27 @@ let links = [];
|
|||||||
let groups = [];
|
let groups = [];
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
let studentsLoaded = false;
|
let studentsLoaded = false;
|
||||||
|
let linkSettings = null;
|
||||||
|
|
||||||
|
async function loadLinkSettings() {
|
||||||
|
if (!linkSettings) {
|
||||||
|
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||||||
|
linkSettings = await res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
return linkSettings;
|
||||||
|
}
|
||||||
|
|
||||||
function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
||||||
|
|
||||||
async function loadLinks() {
|
async function loadLinks() {
|
||||||
|
await loadLinkSettings();
|
||||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
||||||
links = await res.json();
|
links = await res.json();
|
||||||
const el = document.getElementById('linksList');
|
const el = document.getElementById('linksList');
|
||||||
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
||||||
el.innerHTML = links.map(l => `
|
el.innerHTML = links.map(l => {
|
||||||
|
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
|
||||||
|
return `
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div style="min-width:0">
|
<div style="min-width:0">
|
||||||
<div class="name">${esc(l.name)}</div>
|
<div class="name">${esc(l.name)}</div>
|
||||||
@@ -19,7 +31,7 @@ async function loadLinks() {
|
|||||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||||
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||||
${l.anonymize_names ? ` · <span style="color:#f59e0b">анонимизировано</span>` : ''}
|
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +43,7 @@ async function loadLinks() {
|
|||||||
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">×</button>
|
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||||
@@ -43,10 +55,15 @@ async function openLinkModal() {
|
|||||||
document.getElementById('linkDateFrom').value = '';
|
document.getElementById('linkDateFrom').value = '';
|
||||||
document.getElementById('linkDateTo').value = '';
|
document.getElementById('linkDateTo').value = '';
|
||||||
document.getElementById('linkExpiresDays').value = '7';
|
document.getElementById('linkExpiresDays').value = '7';
|
||||||
document.getElementById('linkAnonymizeNames').checked = false;
|
document.getElementById('linkShowNames').checked = false;
|
||||||
document.getElementById('linkPassword').value = '';
|
document.getElementById('linkPassword').value = '';
|
||||||
document.getElementById('linkMessage').value = '';
|
document.getElementById('linkMessage').value = '';
|
||||||
document.getElementById('linkUrl').value = '';
|
document.getElementById('linkUrl').value = '';
|
||||||
|
const s = await loadLinkSettings();
|
||||||
|
document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
groups = await res.json();
|
groups = await res.json();
|
||||||
@@ -72,10 +89,15 @@ async function openLinkEdit(id) {
|
|||||||
document.getElementById('linkDateFrom').value = l.date_from || '';
|
document.getElementById('linkDateFrom').value = l.date_from || '';
|
||||||
document.getElementById('linkDateTo').value = l.date_to || '';
|
document.getElementById('linkDateTo').value = l.date_to || '';
|
||||||
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
|
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
|
||||||
document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false;
|
document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : true;
|
||||||
document.getElementById('linkPassword').value = '';
|
document.getElementById('linkPassword').value = '';
|
||||||
document.getElementById('linkMessage').value = l.message || '';
|
document.getElementById('linkMessage').value = l.message || '';
|
||||||
document.getElementById('linkUrl').value = l.link_url || '';
|
document.getElementById('linkUrl').value = l.link_url || '';
|
||||||
|
const s = await loadLinkSettings();
|
||||||
|
document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('linkShowMessage').checked = l.show_student_message != null ? !!l.show_student_message : s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('linkShowDate').checked = l.show_entry_date != null ? !!l.show_entry_date : s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('linkShowPhotos').checked = l.show_group_photos != null ? !!l.show_group_photos : s.share_show_group_photos !== 'false';
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
groups = await res.json();
|
groups = await res.json();
|
||||||
@@ -106,14 +128,14 @@ async function saveLink() {
|
|||||||
const date_from = document.getElementById('linkDateFrom').value;
|
const date_from = document.getElementById('linkDateFrom').value;
|
||||||
const date_to = document.getElementById('linkDateTo').value;
|
const date_to = document.getElementById('linkDateTo').value;
|
||||||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||||
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
|
const show_student_names = document.getElementById('linkShowNames').checked;
|
||||||
const access_password = document.getElementById('linkPassword').value;
|
const access_password = document.getElementById('linkPassword').value;
|
||||||
const message = document.getElementById('linkMessage').value.trim();
|
const message = document.getElementById('linkMessage').value.trim();
|
||||||
const link_url = document.getElementById('linkUrl').value.trim();
|
const link_url = document.getElementById('linkUrl').value.trim();
|
||||||
if (!name) { alert('Введите название'); return; }
|
if (!name) { alert('Введите название'); return; }
|
||||||
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||||||
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null });
|
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked });
|
||||||
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
||||||
method: editingId ? 'PUT' : 'POST',
|
method: editingId ? 'PUT' : 'POST',
|
||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck'];
|
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos'];
|
||||||
|
|
||||||
function fieldValue(el) {
|
function fieldValue(el) {
|
||||||
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
||||||
@@ -47,6 +47,10 @@ async function loadSettings() {
|
|||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
document.getElementById('footerRight').value = s.footer_right || '';
|
||||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||||
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
|
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
|
||||||
|
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
loadAiQueue();
|
loadAiQueue();
|
||||||
if (stRes.ok) {
|
if (stRes.ok) {
|
||||||
@@ -158,6 +162,10 @@ async function saveSettings() {
|
|||||||
footer_right: document.getElementById('footerRight').value.trim(),
|
footer_right: document.getElementById('footerRight').value.trim(),
|
||||||
ai_prompt: document.getElementById('aiPrompt').value.trim(),
|
ai_prompt: document.getElementById('aiPrompt').value.trim(),
|
||||||
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
|
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
|
||||||
|
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
|
||||||
|
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
|
||||||
|
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
|
||||||
|
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
|
||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
+87
-5
@@ -2,6 +2,81 @@ function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').
|
|||||||
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
||||||
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
||||||
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
||||||
|
|
||||||
|
let countdownInterval = null;
|
||||||
|
let countdownTarget = null;
|
||||||
|
let countdownScrollHandler = null;
|
||||||
|
|
||||||
|
function formatRemaining(ms) {
|
||||||
|
if (ms <= 0) return null;
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const days = Math.floor(totalSec / 86400);
|
||||||
|
const hours = Math.floor((totalSec % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const seconds = totalSec % 60;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (days > 0) parts.push(`${days} ${declension(days, 'день', 'дня', 'дней')}`);
|
||||||
|
if (hours > 0) parts.push(`${hours} ${declension(hours, 'час', 'часа', 'часов')}`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes} ${declension(minutes, 'минута', 'минуты', 'минут')}`);
|
||||||
|
if (days === 0) parts.push(`${seconds} ${declension(seconds, 'секунда', 'секунды', 'секунд')}`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function declension(n, one, few, many) {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 19) return many;
|
||||||
|
if (mod10 === 1) return one;
|
||||||
|
if (mod10 >= 2 && mod10 <= 4) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowCountdown() {
|
||||||
|
const hero = document.querySelector('.hero');
|
||||||
|
if (!hero) return true;
|
||||||
|
return window.scrollY > hero.offsetHeight - 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCountdownVisibility() {
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!countdownTarget) { el.classList.remove('visible'); return; }
|
||||||
|
if (countdownTarget - Date.now() <= 0) { el.classList.remove('visible'); return; }
|
||||||
|
if (shouldShowCountdown()) el.classList.add('visible');
|
||||||
|
else el.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!countdownTarget) { el.classList.remove('visible'); return; }
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = countdownTarget - now;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
el.classList.remove('visible');
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = formatRemaining(remaining);
|
||||||
|
if (!text) { el.classList.remove('visible'); return; }
|
||||||
|
el.classList.remove('warn', 'urgent');
|
||||||
|
if (remaining < 3600000) el.classList.add('urgent');
|
||||||
|
else if (remaining < 86400000) el.classList.add('warn');
|
||||||
|
el.querySelector('.cd-text').innerHTML = `Ссылка доступна ещё <strong>${text}</strong>`;
|
||||||
|
syncCountdownVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(expiresAt) {
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; }
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!expiresAt) { el.classList.remove('visible'); return; }
|
||||||
|
countdownTarget = new Date(expiresAt).getTime();
|
||||||
|
countdownScrollHandler = syncCountdownVisibility;
|
||||||
|
window.addEventListener('scroll', countdownScrollHandler, { passive: true });
|
||||||
|
updateCountdown();
|
||||||
|
countdownInterval = setInterval(updateCountdown, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function fileType(name){
|
function fileType(name){
|
||||||
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
||||||
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
||||||
@@ -98,6 +173,9 @@ function showError(msg) {
|
|||||||
document.getElementById('photosBlock').style.display = 'none';
|
document.getElementById('photosBlock').style.display = 'none';
|
||||||
document.getElementById('shareMessage').style.display = 'none';
|
document.getElementById('shareMessage').style.display = 'none';
|
||||||
document.getElementById('shareLinkBlock').style.display = 'none';
|
document.getElementById('shareLinkBlock').style.display = 'none';
|
||||||
|
document.getElementById('countdown').classList.remove('visible');
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderShare(data, password) {
|
function renderShare(data, password) {
|
||||||
@@ -109,6 +187,8 @@ function renderShare(data, password) {
|
|||||||
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
||||||
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
||||||
|
|
||||||
|
startCountdown(data.expires_at);
|
||||||
|
|
||||||
const msgEl = document.getElementById('shareMessage');
|
const msgEl = document.getElementById('shareMessage');
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
msgEl.textContent = data.message;
|
msgEl.textContent = data.message;
|
||||||
@@ -133,7 +213,7 @@ function renderShare(data, password) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pb = document.getElementById('photosBlock');
|
const pb = document.getElementById('photosBlock');
|
||||||
if (data.photos && data.photos.length) {
|
if (data.show_group_photos && data.photos && data.photos.length) {
|
||||||
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
||||||
<a class="gphoto" href="#" data-img="${esc(p.photo_path)}">
|
<a class="gphoto" href="#" data-img="${esc(p.photo_path)}">
|
||||||
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
|
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
|
||||||
@@ -150,7 +230,9 @@ function renderShare(data, password) {
|
|||||||
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isAnonymized = data.anonymize_names;
|
const isAnonymized = !data.show_student_names;
|
||||||
|
const showStudentMessage = !!data.show_student_message;
|
||||||
|
const showEntryDate = !!data.show_entry_date;
|
||||||
grid.innerHTML = data.entries.map(e => `
|
grid.innerHTML = data.entries.map(e => `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="photo">
|
<div class="photo">
|
||||||
@@ -161,10 +243,10 @@ function renderShare(data, password) {
|
|||||||
<div class="body">
|
<div class="body">
|
||||||
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
||||||
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
||||||
<div class="desc">${esc(e.description)}</div>
|
${showStudentMessage && e.description ? `<div class="desc">${esc(e.description)}</div>` : ''}
|
||||||
${filesHTML(e.files, password, currentToken)}
|
${filesHTML(e.files, password, currentToken)}
|
||||||
</div>
|
</div>
|
||||||
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
|
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -179,4 +261,4 @@ document.addEventListener('click', e => {
|
|||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
await loadShare();
|
await loadShare();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+20
-2
@@ -58,8 +58,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field full">
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
<input type="checkbox" id="linkAnonymizeNames">
|
<input type="checkbox" id="linkShowNames">
|
||||||
Показывать только инициалы учеников (анонимизировать)
|
Показывать фамилии и имена учеников
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowMessage">
|
||||||
|
Показать сообщение ученика
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowDate">
|
||||||
|
Показать дату в карточке записи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowPhotos">
|
||||||
|
Показывать фото группы
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||||
|
|||||||
@@ -43,6 +43,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic">🔗</div>
|
||||||
|
<h3>Страница публичной ссылки /s/<hash></h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="shareShowMessage">
|
||||||
|
Показать сообщение ученика
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="shareShowDate">
|
||||||
|
Показать дату в карточке записи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="shareShowNames">
|
||||||
|
Показывать фамилии и имена учеников
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="shareShowPhotos">
|
||||||
|
Показывать фото группы
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic">📄</div>
|
<div class="ic">📄</div>
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
|||||||
.share-link-block{margin-bottom:32px}
|
.share-link-block{margin-bottom:32px}
|
||||||
.share-link-btn{display:inline-flex;align-items:center;gap:8px;background:var(--dark);color:#fff;text-decoration:none;border-radius:999px;padding:13px 24px;font-size:.92rem;font-weight:700;transition:opacity .15s,transform .15s}
|
.share-link-btn{display:inline-flex;align-items:center;gap:8px;background:var(--dark);color:#fff;text-decoration:none;border-radius:999px;padding:13px 24px;font-size:.92rem;font-weight:700;transition:opacity .15s,transform .15s}
|
||||||
.share-link-btn:hover{opacity:.9;transform:translateY(-2px)}
|
.share-link-btn:hover{opacity:.9;transform:translateY(-2px)}
|
||||||
|
.countdown{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(150%);z-index:50;display:flex;align-items:center;gap:10px;padding:14px 24px;font-size:.92rem;color:var(--dark);background:#fff;border-radius:16px;box-shadow:0 8px 30px rgba(20,30,40,.16);max-width:calc(100vw - 32px);opacity:0;transition:transform .3s ease,opacity .3s ease;pointer-events:none}
|
||||||
|
.countdown.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto}
|
||||||
|
.countdown .cd-icon{font-size:1.2rem}
|
||||||
|
.countdown .cd-text{color:#4a4f5a;line-height:1.4;text-align:center}
|
||||||
|
.countdown .cd-text strong{color:var(--dark);font-weight:700}
|
||||||
|
.countdown.warn{background:#FFF3E0}
|
||||||
|
.countdown.warn .cd-text strong{color:#E65100}
|
||||||
|
.countdown.urgent{background:#FFEBEE}
|
||||||
|
.countdown.urgent .cd-text strong{color:#C62828}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -83,6 +92,7 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
|||||||
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
||||||
</main>
|
</main>
|
||||||
<div class="lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
|
<div class="lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
|
||||||
|
<div id="countdown" class="countdown"><span class="cd-icon">⏳</span><span class="cd-text"></span></div>
|
||||||
|
|
||||||
<div class="password-modal" id="passwordModal">
|
<div class="password-modal" id="passwordModal">
|
||||||
<div class="password-box">
|
<div class="password-box">
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||||
const keys = ['footer_left', 'footer_right'];
|
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos'];
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of keys) out[k] = await getSetting(k, '');
|
for (const k of keys) out[k] = await getSetting(k, '');
|
||||||
res.json(out);
|
res.json(out);
|
||||||
@@ -715,7 +715,8 @@ const zlib = require('zlib');
|
|||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
||||||
const AI_DEFAULT_PROMPT = 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.';
|
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||||
|
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
let entryAutoChecker = null;
|
let entryAutoChecker = null;
|
||||||
|
|
||||||
async function getAiPrompt() {
|
async function getAiPrompt() {
|
||||||
@@ -730,7 +731,7 @@ async function aiCorrectText(text) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'qwen2.5-1.5b-instruct',
|
model: AI_MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: text }
|
{ role: 'user', content: text }
|
||||||
@@ -1148,7 +1149,7 @@ app.get('/api/links', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/links', requireAuth, async (req, res) => {
|
app.post('/api/links', requireAuth, async (req, res) => {
|
||||||
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
|
const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||||
let message, linkUrl;
|
let message, linkUrl;
|
||||||
try {
|
try {
|
||||||
@@ -1178,16 +1179,16 @@ app.post('/api/links', requireAuth, async (req, res) => {
|
|||||||
expiresAt = defaultExp.toISOString();
|
expiresAt = defaultExp.toISOString();
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash, message, link_url)
|
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, show_student_names, expires_at, access_password_hash, message, link_url, show_student_message, show_entry_date, show_group_photos)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`,
|
||||||
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash, message, linkUrl]
|
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, expiresAt, passwordHash, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos]
|
||||||
);
|
);
|
||||||
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
|
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
|
||||||
res.status(201).json(normDates(rows[0]));
|
res.status(201).json(normDates(rows[0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/links/:id', requireAuth, async (req, res) => {
|
app.put('/api/links/:id', requireAuth, async (req, res) => {
|
||||||
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
|
const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||||
let message, linkUrl;
|
let message, linkUrl;
|
||||||
try {
|
try {
|
||||||
@@ -1227,9 +1228,9 @@ app.put('/api/links/:id', requireAuth, async (req, res) => {
|
|||||||
expiresAt = null; // Clear expiry if null sent
|
expiresAt = null; // Clear expiry if null sent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6', 'message = $7', 'link_url = $8'];
|
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'show_student_names = $6', 'message = $7', 'link_url = $8', 'show_student_message = $9', 'show_entry_date = $10', 'show_group_photos = $11'];
|
||||||
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, message, linkUrl];
|
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos];
|
||||||
let paramIdx = 9;
|
let paramIdx = 12;
|
||||||
if (passwordHash !== undefined) {
|
if (passwordHash !== undefined) {
|
||||||
fields.push(`access_password_hash = $${paramIdx++}`);
|
fields.push(`access_password_hash = $${paramIdx++}`);
|
||||||
values.push(passwordHash);
|
values.push(passwordHash);
|
||||||
@@ -1311,8 +1312,10 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
entries.forEach(r => { r.files = files[r.id] || []; });
|
entries.forEach(r => { r.files = files[r.id] || []; });
|
||||||
|
|
||||||
// Anonymize names if enabled
|
const showStudentNames = l.show_student_names != null ? l.show_student_names : (await getSetting('share_show_student_names', 'true')) !== 'false';
|
||||||
if (l.anonymize_names) {
|
|
||||||
|
// Anonymize names if disabled
|
||||||
|
if (!showStudentNames) {
|
||||||
const nameMap = new Map();
|
const nameMap = new Map();
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
entries.forEach(e => {
|
entries.forEach(e => {
|
||||||
@@ -1342,9 +1345,13 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|||||||
message: l.message,
|
message: l.message,
|
||||||
link_url: l.link_url,
|
link_url: l.link_url,
|
||||||
created_at: l.created_at,
|
created_at: l.created_at,
|
||||||
anonymize_names: l.anonymize_names,
|
expires_at: l.expires_at,
|
||||||
|
show_student_names: showStudentNames,
|
||||||
|
show_student_message: l.show_student_message != null ? l.show_student_message : (await getSetting('share_show_student_message', 'false')) === 'true',
|
||||||
|
show_entry_date: l.show_entry_date != null ? l.show_entry_date : (await getSetting('share_show_entry_date', 'false')) === 'true',
|
||||||
|
show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false',
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2589,7 +2596,7 @@ app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
|||||||
enabled,
|
enabled,
|
||||||
counts,
|
counts,
|
||||||
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
||||||
model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct',
|
model: AI_MODEL,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2642,7 +2649,7 @@ app.get('/api/ai/status', requireAdmin, async (_, res) => {
|
|||||||
const service = await aiHealthCheck();
|
const service = await aiHealthCheck();
|
||||||
res.json({
|
res.json({
|
||||||
enabled,
|
enabled,
|
||||||
model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct',
|
model: AI_MODEL,
|
||||||
ai_url: AI_URL,
|
ai_url: AI_URL,
|
||||||
service,
|
service,
|
||||||
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MODEL_NAME="${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}"
|
||||||
|
MODEL_REPO="${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}"
|
||||||
|
MODEL_PATH="/models/${MODEL_NAME}"
|
||||||
|
MODEL_URL="https://huggingface.co/${MODEL_REPO}/resolve/main/${MODEL_NAME}"
|
||||||
|
|
||||||
|
if [ ! -s "${MODEL_PATH}" ]; then
|
||||||
|
echo "[text-corrector] Model ${MODEL_PATH} not found, downloading from ${MODEL_URL}"
|
||||||
|
set -- -L --fail --retry 3 --retry-delay 5 --progress-bar -o "${MODEL_PATH}.part"
|
||||||
|
if [ -n "${HUGGINGFACE_TOKEN}" ]; then
|
||||||
|
echo "[text-corrector] Using Hugging Face token"
|
||||||
|
set -- "$@" -H "Authorization: Bearer ${HUGGINGFACE_TOKEN}"
|
||||||
|
fi
|
||||||
|
curl "$@" "${MODEL_URL}"
|
||||||
|
mv "${MODEL_PATH}.part" "${MODEL_PATH}"
|
||||||
|
echo "[text-corrector] Download complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[text-corrector] Starting llama-server with ${MODEL_PATH}"
|
||||||
|
exec /app/llama-server -m "${MODEL_PATH}" --host 0.0.0.0 --port 8080 -c 4096 -n 256 -t 8 --temp 0.1
|
||||||
@@ -6,8 +6,8 @@ const MIN_TEXT_CHARS = 4;
|
|||||||
|
|
||||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
||||||
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||||
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct';
|
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||||
const DEFAULT_PROMPT = defaultPrompt || 'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.';
|
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user