Добавлена авторизация (локальная и через Moodle), локальная регистрация

This commit is contained in:
belred 2026-05-07 22:01:03 +03:00
parent bdecc30299
commit eeb4a39506
23 changed files with 2778 additions and 245 deletions

BIN
local.db Normal file

Binary file not shown.

1335
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
"@sveltejs/adapter-auto": "^6.1.1", "@sveltejs/adapter-auto": "^6.1.1",
"@sveltejs/kit": "^2.43.2", "@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/eslint__js": "^8.42.3", "@types/eslint__js": "^8.42.3",
"@types/prettier": "^2.7.3", "@types/prettier": "^2.7.3",
"eslint": "^9.38.0", "eslint": "^9.38.0",
@ -21,5 +22,10 @@
"svelte": "^5.39.5", "svelte": "^5.39.5",
"typescript-eslint": "^8.46.1", "typescript-eslint": "^8.46.1",
"vite": "^7.1.7" "vite": "^7.1.7"
},
"dependencies": {
"@auth/sveltekit": "^1.11.2",
"better-sqlite3": "^12.9.0",
"drizzle-orm": "^0.45.2"
} }
} }

87
src/auth.js Normal file
View File

@ -0,0 +1,87 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import Credentials from '@auth/sveltekit/providers/credentials';
import { db, users } from '$lib/server/db.js';
import { eq } from 'drizzle-orm';
import { verifyPassword, createMoodleUser } from '$lib/server/auth.js';
import { moodleLogin } from '$lib/server/moodle.js';
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [
//локальный аккаунт (email + пароль)
Credentials({
id: 'local',
name: 'Локальный аккаунт',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Пароль', type: 'password' },
},
async authorize({ email, password }) {
if (!email || !password) return null;
const user = db.select().from(users).where(eq(users.email, email)).get();
if (!user || user.authType !== 'local' || !user.passwordHash) return null;
if (!verifyPassword(password, user.passwordHash)) return null;
return {
id: user.id,
name: user.username,
email: user.email,
authType: 'local',
};
},
}),
//вход через Moodle
Credentials({
id: 'moodle',
name: 'Moodle',
credentials: {
username: { label: 'Логин Moodle', type: 'text' },
password: { label: 'Пароль', type: 'password' },
},
async authorize({ username, password }) {
if (!username || !password) return null;
try {
//проверяем логин/пароль через Moodle API (moodle.js)
const mUser = await moodleLogin(username, password);
//создаём или находим пользователя в нашей БД
const userId = createMoodleUser(mUser.moodleId, mUser.username, mUser.email);
const user = db.select().from(users).where(eq(users.id, userId)).get();
return {
id: user.id,
name: user.username,
email: user.email,
authType: 'moodle',
moodleId: user.moodleId,
};
} catch {
return null; //неверный логин/пароль
}
},
}),
],
callbacks: {
//добавляем в JWT токен
jwt({ token, user }) {
if (user) {
token.id = user.id;
token.authType = user.authType;
token.moodleId = user.moodleId ?? null;
}
return token;
},
//передаём в объект сессии
session({ session, token }) {
session.user.id = token.id;
session.user.authType = token.authType;
session.user.moodleId = token.moodleId ?? null;
return session;
},
},
pages: {
signIn: '/login',
},
trustHost: true,
});

11
src/hooks.server.js Normal file
View File

@ -0,0 +1,11 @@
import { handle as authHandle } from './auth.js';
import { sequence } from '@sveltejs/kit/hooks';
export const handle = sequence(
authHandle,
async ({ event, resolve }) => {
const session = await event.locals.auth();
event.locals.user = session?.user ?? null;
return resolve(event);
}
);

View File

@ -10,6 +10,36 @@
let selectedTool = 'bit'; // 'bit', 'byte', '2bytes', '4bytes', '6bytes', '8bytes' let selectedTool = 'bit'; // 'bit', 'byte', '2bytes', '4bytes', '6bytes', '8bytes'
let dragging = false;
let posX = null, posY = null;
let dragOffsetX = 0, dragOffsetY = 0;
let popupEl;
$: if (show) { posX = null; posY = null; }
function startDrag(e) {
if (e.target.closest('button, input, select, label')) return;
dragging = true;
const rect = popupEl.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
posX = e.clientX - dragOffsetX;
posY = e.clientY - dragOffsetY;
}
function stopDrag() {
dragging = false;
}
$: popupStyle = posX !== null
? `left:${posX}px; top:${posY}px; transform:none;`
: `left:50%; top:50%; transform:translate(-50%,-50%);`;
function isToolValid(tool, startIndex) { function isToolValid(tool, startIndex) {
for (let i = 0; i < tool.bytes; i++) { for (let i = 0; i < tool.bytes; i++) {
const byteIndex = startIndex + i; const byteIndex = startIndex + i;
@ -51,147 +81,181 @@
$: selectedToolData = availableTools.find(t => t.id === selectedTool); $: selectedToolData = availableTools.find(t => t.id === selectedTool);
</script> </script>
<svelte:window on:mousemove={onMove} on:mouseup={stopDrag} />
{#if show} {#if show}
<div <div
class="tool-selector-overlay" bind:this={popupEl}
style="position: fixed; left: 0; top: 0; right: 0; bottom: 0; z-index: 1000;" class="popup"
class:dragging
style={popupStyle}
role="dialog"
> >
<div <!--заголовок (тянем за него)-->
class="tool-selector-popup" <div class="popup-header" on:mousedown={startDrag} role="presentation" tabindex="-1">
style=" <span class="drag-icon"></span>
position: absolute; <span class="title">Редактирование байта {startIndex}</span>
left: 50%; <button class="close-x" on:click={() => show = false}>✕</button>
top: 50%; </div>
transform: translate(-50%, -50%);
max-width: 90vw;
max-height: 90vh;
"
>
<div class="tool-header">
<h4>Редактирование байта {startIndex}</h4>
</div>
<div class="tool-selection">
{#each availableTools as tool}
<button
class:selected={selectedTool === tool.id}
class="tool-button"
on:click={() => selectTool(tool.id)}
>
{tool.name}
</button>
{/each}
</div>
<div class="tool-content"> <!--выбор инструмента-->
{#if selectedTool === 'bit'} <div class="tools-row">
<BitEditor {#each availableTools as tool}
buffer={new Uint8Array([buffer[startIndex]])} <button
onBufferChange={(newByte) => { class="tool-btn"
const newBuffer = new Uint8Array(buffer); class:active={selectedTool === tool.id}
newBuffer[startIndex] = newByte[0]; on:click={() => selectedTool = tool.id}
onBufferChange(newBuffer); >{tool.name}</button>
}} {/each}
/> </div>
{:else if selectedToolData}
<ByteEditor
buffer={buffer}
onBufferChange={onBufferChange}
startIndex={startIndex}
byteLength={selectedToolData.bytes}
/>
{/if}
</div>
<div class="tool-footer"> <div class="content">
<button on:click={() => show = false} class="close-button"> {#if selectedTool === 'bit'}
Закрыть <BitEditor
</button> buffer={new Uint8Array([buffer[startIndex]])}
</div> onBufferChange={(newByte) => {
const newBuffer = new Uint8Array(buffer);
newBuffer[startIndex] = newByte[0];
onBufferChange(newBuffer);
}}
/>
{:else if selectedToolData}
<ByteEditor
buffer={buffer}
onBufferChange={onBufferChange}
startIndex={startIndex}
byteLength={selectedToolData.bytes}
/>
{/if}
</div>
<div class="footer">
<button class="close-btn" on:click={() => show = false}>Закрыть</button>
</div> </div>
</div> </div>
{/if} {/if}
<style> <style>
.tool-selector-overlay { .popup {
background: rgba(0, 0, 0, 0.3); position: fixed;
display: flex; z-index: 1000;
justify-content: center;
align-items: center;
}
.tool-selector-popup {
background: white; background: white;
border: 2px solid #2196F3; border: 2px solid #2196F3;
border-radius: 12px; border-radius: 12px;
padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); min-width: 380px;
overflow-y: auto; max-width: 90vw;
min-width: 400px; max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.tool-header { .popup.dragging { cursor: grabbing; box-shadow: 0 12px 40px rgba(0,0,0,0.3); }
margin-bottom: 16px;
border-bottom: 1px solid #eee; /*заголовок*/
padding-bottom: 12px; .popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid #e8e8e8;
cursor: grab;
background: #f8f9ff;
border-radius: 10px 10px 0 0;
user-select: none;
}
.popup-header:active {
cursor: grabbing;
}
.drag-icon {
color: #bbb;
font-size: 1.2em;
}
.title {
flex: 1;
font-weight: 600;
color: #1565c0;
font-size: 0.95em;
text-align: center; text-align: center;
} }
.tool-header h4 { .close-x {
margin: 0 0 4px 0; background: none;
color: #2196F3; border: none;
cursor: pointer;
color: #aaa;
font-size: 1em;
padding: 3px 7px;
border-radius: 50%;
line-height: 1;
transition: all 0.15s;
} }
.tool-selection { .close-x:hover {
color: #f44336;
background: #ffebee;
}
/*инструменты*/
.tools-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 6px;
margin-bottom: 20px; padding: 12px 14px;
justify-content: center; border-bottom: 1px solid #f0f0f0;
} }
.tool-button { .tool-btn {
padding: 8px 16px; padding: 5px 12px;
border: 2px solid #e0e0e0; border: 1px solid #ddd;
background: white; background: white;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.9em; font-size: 0.82em;
transition: all 0.2s; transition: all 0.15s;
color: #555;
} }
.tool-button:hover { .tool-btn:hover {
background: #f0f7ff;
border-color: #90caf9; border-color: #90caf9;
color: #1565c0;
background: #f0f7ff;
} }
.tool-button.selected { .tool-btn.active {
background: #2196F3; background: #2196F3;
color: white; color: white;
border-color: #1976D2; border-color: #1976D2;
} }
.tool-content { .content {
max-height: 60vh;
overflow-y: auto; overflow-y: auto;
margin-bottom: 16px; padding: 12px 14px;
flex: 1;
} }
.tool-footer { .footer {
padding: 10px 14px;
text-align: center; text-align: center;
border-top: 1px solid #f0f0f0;
} }
.close-button { .close-btn {
padding: 8px 24px; padding: 7px 22px;
background: #f5f5f5; background: #f5f5f5;
border: 2px solid #ddd; border: 1px solid #ddd;
border-radius: 6px; border-radius: 6px;
color: #333;
cursor: pointer; cursor: pointer;
font-weight: bold; font-size: 0.9em;
transition: background 0.15s;
} }
.close-button:hover { .close-btn:hover {
background: #e0e0e0; background: #e0e0e0;
} }
</style> </style>

View File

@ -139,7 +139,7 @@ export const lessons = [
{ {
id: 5, id: 5,
slug: 'ipv4-ttl', slug: 'ipv4-ttl',
title: 'TTL время жизни пакета', title: 'TTL - время жизни пакета',
category: 'IPv4', category: 'IPv4',
difficulty: 'Средний', difficulty: 'Средний',
component: 'HexEditor', component: 'HexEditor',

View File

@ -103,12 +103,22 @@ export const theory = {
<div style="${B}"> <div style="${B}">
<h3 style="${H3}">Структура кадра Ethernet II</h3> <h3 style="${H3}">Структура кадра Ethernet II</h3>
<div style="${DARK}"> <table style="border-collapse:collapse;width:100%;margin:10px 0;font-size:0.9em;">
<div>+----------+----------+----------+------------------+------+</div> <tr>
<div>| Dst MAC | Src MAC |EtherType | Data | FCS |</div> <th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Dst MAC</th>
<div>| 6 байт | 6 байт | 2 байта | 46-1500 байт | 4 б |</div> <th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Src MAC</th>
<div>+----------+----------+----------+------------------+------+</div> <th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">EtherType</th>
</div> <th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Data</th>
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">FCS</th>
</tr>
<tr>
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">6 байт</td>
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;background:#f5f9ff;">6 байт</td>
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">2 байта</td>
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;background:#f5f9ff;">461500 байт</td>
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">4 байта</td>
</tr>
</table>
<p><strong>Dst MAC</strong> получатель. <strong>Src MAC</strong> отправитель.</p> <p><strong>Dst MAC</strong> получатель. <strong>Src MAC</strong> отправитель.</p>
<p><strong>EtherType</strong> тип протокола: <code>0x0800</code> IPv4, <code>0x0806</code> ARP, <code>0x86DD</code> IPv6.</p> <p><strong>EtherType</strong> тип протокола: <code>0x0800</code> IPv4, <code>0x0806</code> ARP, <code>0x86DD</code> IPv6.</p>
<p><strong>FCS</strong> (Frame Check Sequence) контрольная сумма, рассчитывается автоматически.</p> <p><strong>FCS</strong> (Frame Check Sequence) контрольная сумма, рассчитывается автоматически.</p>
@ -160,11 +170,11 @@ export const theory = {
'ipv4-ttl': ` 'ipv4-ttl': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;"> <div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">TTL время жизни пакета</h2> <h2 style="${H2}">TTL - время жизни пакета</h2>
<div style="${B}"> <div style="${B}">
<h3 style="${H3}">Зачем нужен TTL?</h3> <h3 style="${H3}">Зачем нужен TTL?</h3>
<p><strong>TTL (Time To Live)</strong> счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.</p> <p><strong>TTL (Time To Live)</strong> - счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.</p>
</div> </div>
<div style="${B}"> <div style="${B}">
@ -180,7 +190,7 @@ export const theory = {
<div style="${B}"> <div style="${B}">
<h3 style="${H3}">traceroute и TTL</h3> <h3 style="${H3}">traceroute и TTL</h3>
<p><code>traceroute</code> отправляет пакеты с TTL=1, 2, 3 Каждый маршрутизатор отвечает ICMP «Time Exceeded» так строится полная цепочка узлов до цели.</p> <p><code>traceroute</code> отправляет пакеты с TTL=1, 2, 3 Каждый маршрутизатор отвечает ICMP «Time Exceeded» - так строится полная цепочка узлов до цели.</p>
</div> </div>
</div> </div>
`, `,

51
src/lib/server/auth.js Normal file
View File

@ -0,0 +1,51 @@
import { db, users } from './db';
import { eq } from 'drizzle-orm';
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
//пароли
export function hashPassword(password) {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
const [salt, hash] = stored.split(':');
const hashBuffer = Buffer.from(hash, 'hex');
const inputHash = scryptSync(password, salt, 64);
return timingSafeEqual(hashBuffer, inputHash);
}
//пользователи
export function createLocalUser(username, email, password) {
const existing = db.select().from(users).where(eq(users.email, email)).get();
if (existing) throw new Error('Пользователь с таким email уже существует');
const id = randomBytes(16).toString('hex');
db.insert(users).values({
id, username, email,
passwordHash: hashPassword(password),
moodleId: null,
authType: 'local',
createdAt: Date.now(),
}).run();
return id;
}
export function createMoodleUser(moodleId, username, email) {
const existing = db.select().from(users)
.where(eq(users.moodleId, moodleId)).get();
if (existing) return existing.id;
const id = randomBytes(16).toString('hex');
db.insert(users).values({
id, username, email,
passwordHash: null,
moodleId,
authType: 'moodle',
createdAt: Date.now(),
}).run();
return id;
}

50
src/lib/server/db.js Normal file
View File

@ -0,0 +1,50 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
//схема таблиц
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash'), //null, если вход через Moodle
moodleId: text('moodle_id'), //null, если своя регистрация
authType: text('auth_type').notNull(), //'local' или 'moodle'
createdAt: integer('created_at').notNull(),
});
export const progress = sqliteTable('progress', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull(),
lessonId: text('lesson_id').notNull(),
tasksCompleted: integer('tasks_completed').notNull().default(0),
completed: integer('completed').notNull().default(0), // 0 или 1
updatedAt: integer('updated_at').notNull(),
});
//подключение
const sqlite = new Database('local.db');
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT,
moodle_id TEXT,
auth_type TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
lesson_id TEXT NOT NULL,
tasks_completed INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
`);
export const db = drizzle(sqlite);

124
src/lib/server/moodle.js Normal file
View File

@ -0,0 +1,124 @@
import { MOODLE_URL, MOODLE_TOKEN, MOODLE_ASSIGNMENT_ID } from '$env/static/private';
async function moodleRequest(wsfunction, params = {}) {
const url = new URL(`${MOODLE_URL}/webservice/rest/server.php`);
url.searchParams.set('wstoken', MOODLE_TOKEN);
url.searchParams.set('wsfunction', wsfunction);
url.searchParams.set('moodlewsrestformat', 'json');
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url.toString());
const data = await res.json();
if (data?.exception) throw new Error(data.message ?? 'Moodle API error');
return data;
}
//cобирает все Set-Cookie заголовки в одну строку
function extractCookies(response) {
const raw = response.headers.getSetCookie?.()
?? [response.headers.get('set-cookie') ?? ''];
return raw.map(c => c.split(';')[0]).filter(Boolean).join('; ');
}
//объединяет старые и новые куки (новые перезаписывают старые)
function mergeCookies(existing, incoming) {
const map = {};
[...existing.split('; '), ...incoming.split('; ')]
.filter(Boolean)
.forEach(pair => {
const [k, v] = pair.split('=');
if (k) map[k.trim()] = v ?? '';
});
return Object.entries(map).map(([k, v]) => `${k}=${v}`).join('; ');
}
export async function moodleLogin(username, password) {
//получаем logintoken и начальные cookies
const loginPageRes = await fetch(`${MOODLE_URL}/login/index.php`);
const loginPageHtml = await loginPageRes.text();
let cookies = extractCookies(loginPageRes);
const tokenMatch = loginPageHtml.match(/name="logintoken"\s+value="([^"]+)"/);
const logintoken = tokenMatch ? tokenMatch[1] : '';
//POST логин/пароль
const formRes = await fetch(`${MOODLE_URL}/login/index.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': cookies },
body: new URLSearchParams({ username, password, logintoken }),
redirect: 'manual',
});
cookies = mergeCookies(cookies, extractCookies(formRes));
let location = formRes.headers.get('location') ?? '';
//если testsession, то следуем редиректу с куками
if (location.includes('testsession')) {
const testRes = await fetch(location, {
headers: { 'Cookie': cookies },
redirect: 'manual',
});
cookies = mergeCookies(cookies, extractCookies(testRes));
location = testRes.headers.get('location') ?? '';
}
//проверяем финальный редирект
if (!location || (!location.includes('/my') && !location.includes('dashboard'))) {
throw new Error('Неверный логин или пароль');
}
//получаем данные пользователя через админский токен
const users = await moodleRequest('core_user_get_users_by_field', {
field: 'username', 'values[0]': username,
});
if (!users || users.length === 0) throw new Error('Пользователь не найден в Moodle');
const user = users[0];
return {
moodleId: String(user.id),
username: user.username,
email: user.email,
fullname: user.fullname,
};
}
async function moodlePost(wsfunction, params = {}) {
const allParams = {
wstoken: MOODLE_TOKEN,
wsfunction,
moodlewsrestformat: 'json',
...params,
};
const body = Object.entries(allParams)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
const res = await fetch(`${MOODLE_URL}/webservice/rest/server.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const data = await res.json();
if (data?.exception) throw new Error(data.message ?? 'Moodle API error');
return data;
}
//синхронизация с Moodle Gradebook
//синхронизирует завершение урока с Moodle Gradebook
//оценка 100 - урок завершён
export async function syncMoodleGrade(moodleUserId, completedCount, totalCount) {
const grade = Math.round((completedCount / totalCount) * 100);
await moodlePost('mod_assign_save_grade', {
'assignmentid': Number(MOODLE_ASSIGNMENT_ID),
'userid': Number(moodleUserId),
'grade': grade,
'attemptnumber': -1,
'addattempt': 0,
'workflowstate': '',
'applytoall': 1,
'plugindata[assignfeedbackcomments_editor][text]': `Пройдено: ${completedCount} из ${totalCount}`,
'plugindata[assignfeedbackcomments_editor][format]': 1,
});
}

View File

@ -1,3 +1,8 @@
//прогресс хранится в БД если пользователь авторизован,
//иначе в localStorage (для гостей)
const TASKS_REQUIRED = 3;
export const progressStorage = { export const progressStorage = {
_getAll() { _getAll() {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
@ -17,7 +22,7 @@ export const progressStorage = {
const all = this._getAll(); const all = this._getAll();
const raw = all[String(lessonId)]; const raw = all[String(lessonId)];
if (raw === undefined || raw === null) return { completed: false, tasksCompleted: 0 }; if (raw === undefined || raw === null) return { completed: false, tasksCompleted: 0 };
if (typeof raw === 'boolean') return { completed: raw, tasksCompleted: raw ? 3 : 0 }; if (typeof raw === 'boolean') return { completed: raw, tasksCompleted: raw ? TASKS_REQUIRED : 0 };
return { completed: !!raw.completed, tasksCompleted: raw.tasksCompleted ?? 0 }; return { completed: !!raw.completed, tasksCompleted: raw.tasksCompleted ?? 0 };
}, },
@ -30,11 +35,12 @@ export const progressStorage = {
}, },
//сохраняет новое значение счётчика; автоматически выставляет completed //сохраняет новое значение счётчика; автоматически выставляет completed
saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = 3) { saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = TASKS_REQUIRED) {
const all = this._getAll(); const all = this._getAll();
const completed = tasksCompleted >= tasksRequired; const completed = tasksCompleted >= tasksRequired;
all[String(lessonId)] = { completed, tasksCompleted }; all[String(lessonId)] = { completed, tasksCompleted };
this._save(all); this._save(all);
this._syncToDB(lessonId, tasksCompleted, completed);
return completed; return completed;
}, },
@ -48,4 +54,62 @@ export const progressStorage = {
} }
return result; return result;
}, },
//вызывается при каждом сохранении - если пользователь авторизован, дублирует прогресс в БД
_syncToDB(lessonId, tasksCompleted, completed) {
if (typeof window === 'undefined') return;
fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lessonId, tasksCompleted, completed }),
}).catch(() => {});
},
//загружает прогресс из БД и синхронизирует с localStorage
async loadFromDB() {
if (typeof window === 'undefined') return;
try {
const res = await fetch('/api/progress');
if (!res.ok) return;
const data = await res.json();
//объединяем: берём максимум из localStorage и БД
const local = this._getAll();
for (const [lessonId, dbVal] of Object.entries(data)) {
const localVal = local[String(lessonId)];
const localTC = typeof localVal === 'boolean'
? (localVal ? TASKS_REQUIRED : 0)
: (localVal?.tasksCompleted ?? 0);
const dbTC = dbVal.tasksCompleted ?? 0;
const bestTC = Math.max(localTC, dbTC);
local[String(lessonId)] = {
completed: bestTC >= TASKS_REQUIRED,
tasksCompleted: bestTC,
};
}
this._save(local);
} catch {}
},
//переносит прогресс из localStorage в БД при входе/регистрации
async transferToDB() {
if (typeof window === 'undefined') return;
const local = this._getAll();
if (Object.keys(local).length === 0) return;
try {
await fetch('/api/progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(local),
});
} catch {}
},
//очищает localStorage (вызывается при выходе)
clear() {
if (typeof window !== 'undefined') {
localStorage.removeItem('networking-progress');
}
},
}; };

View File

@ -0,0 +1,3 @@
export async function load({ locals }) {
return { user: locals.user };
}

View File

@ -3,15 +3,50 @@
import { progressStorage } from '$lib/utils/storage'; import { progressStorage } from '$lib/utils/storage';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export let data; //получаем user
$: user = data?.user ?? null;
let progress = {}; let progress = {};
let progressData = {}; //детальный прогресс для плашек { tasksCompleted, completed }
let showProfileMenu = false;
let searchQuery = ''; let searchQuery = '';
let activeCategory = 'Все'; let activeCategory = 'Все';
let activeDifficulty = 'Все'; let activeDifficulty = 'Все';
onMount(() => { onMount(async () => {
if (user) {
await progressStorage.transferToDB(); //localStorage -> БД при первом входе
await progressStorage.loadFromDB(); //БД -> localStorage
}
progressData = progressStorage._getAll();
progress = progressStorage.getProgress(); progress = progressStorage.getProgress();
}); });
//профиль
async function logout() {
progressStorage.clear();
await fetch('/auth/signout', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
window.location.href = '/';
}
async function deleteAccount() {
if (!confirm('Удалить аккаунт? Это действие необратимо.')) return;
const res = await fetch('/api/auth/delete', { method: 'POST' });
if (res.ok) {
progressStorage.clear();
//выходим из Auth.js сессии
await fetch('/auth/signout', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
window.location.href = '/';
}
}
//уникальные категории и сложности из уроков //уникальные категории и сложности из уроков
$: categories = ['Все', ...new Set(lessons.map(l => l.category))]; $: categories = ['Все', ...new Set(lessons.map(l => l.category))];
$: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))]; $: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))];
@ -28,15 +63,30 @@
return matchSearch && matchCat && matchDiff; return matchSearch && matchCat && matchDiff;
}); });
//счётчик завершённых уроков
$: completedCount = lessons.filter(l => progress[l.id]).length;
function clearFilters() { function clearFilters() {
searchQuery = ''; searchQuery = '';
activeCategory = 'Все'; activeCategory = 'Все';
activeDifficulty = 'Все'; activeDifficulty = 'Все';
} }
$: lessonStatuses = Object.fromEntries(
lessons.map(l => {
const p = progressData[String(l.id)];
if (!p) return [l.id, 'none'];
if (typeof p === 'boolean') return [l.id, p ? 'completed' : 'none'];
if (p.completed) return [l.id, 'completed'];
if ((p.tasksCompleted ?? 0) > 0) return [l.id, 'in-progress'];
return [l.id, 'none'];
})
);
//счётчик завершённых уроков
$: completedCount = Object.values(lessonStatuses).filter(s => s === 'completed').length;
function getLessonStatus(lessonId) {
return lessonStatuses[lessonId] ?? 'none';
}
//цвет тэга сложности //цвет тэга сложности
function difficultyColor(d) { function difficultyColor(d) {
return { 'Начинающий': '#4caf50', 'Средний': '#ff9800', 'Продвинутый': '#f44336' }[d] ?? '#2196f3'; return { 'Начинающий': '#4caf50', 'Средний': '#ff9800', 'Продвинутый': '#f44336' }[d] ?? '#2196f3';
@ -45,6 +95,39 @@
<div class="page"> <div class="page">
<div class="user-bar">
{#if user}
<div class="profile-wrap">
<button class="profile-btn"
on:click={() => showProfileMenu = !showProfileMenu}>
👤 {user.name}
<span class="auth-badge" class:moodle={user.authType === 'moodle'}>
{user.authType === 'moodle' ? 'Moodle' : 'Локальный'}
</span>
<span class="arrow">{showProfileMenu ? '▲' : '▼'}</span>
</button>
{#if showProfileMenu}
<div class="profile-menu">
<button class="menu-item" on:click={logout}>
Выйти
</button>
{#if user.authType !== 'moodle'}
<button class="menu-item danger" on:click={deleteAccount}>
Удалить аккаунт
</button>
{/if}
</div>
{/if}
</div>
{:else}
<div class="auth-links">
<a href="/login" class="auth-link">Войти</a>
<a href="/register" class="auth-link primary">Регистрация</a>
</div>
{/if}
</div>
<!--шапка--> <!--шапка-->
<header class="page-header"> <header class="page-header">
<h1>Обучение сетевым протоколам</h1> <h1>Обучение сетевым протоколам</h1>
@ -122,11 +205,16 @@
<!--список уроков--> <!--список уроков-->
<div class="lessons-list"> <div class="lessons-list">
{#each filtered as lesson (lesson.id)} {#each filtered as lesson (lesson.id)}
<a href="/lessons/{lesson.slug}" class="lesson-card" class:done={progress[lesson.id]}> {@const status = lessonStatuses[lesson.id] ?? 'none'}
<a href="/lessons/{lesson.slug}" class="lesson-card"
class:done={status === 'completed'}
class:in-progress={status === 'in-progress'}>
<div class="lesson-header"> <div class="lesson-header">
<h3>{lesson.title}</h3> <h3>{lesson.title}</h3>
{#if progress[lesson.id]} {#if status === 'completed'}
<span class="completed-badge">✅ Пройден</span> <span class="status-badge done">✅ Пройден</span>
{:else if status === 'in-progress'}
<span class="status-badge progress">В процессе</span>
{/if} {/if}
</div> </div>
<div class="lesson-footer"> <div class="lesson-footer">
@ -264,7 +352,9 @@
color: #555; color: #555;
transition: all 0.15s; transition: all 0.15s;
} }
.tag:hover { border-color: #2196F3; color: #2196F3; } .tag:hover { border-color: #2196F3; color: #2196F3; }
.tag.active { .tag.active {
background: #2196F3; background: #2196F3;
color: white; color: white;
@ -282,6 +372,7 @@
font-size: 0.85em; font-size: 0.85em;
transition: all 0.15s; transition: all 0.15s;
} }
.reset-btn:hover { background: #f44336; color: white; } .reset-btn:hover { background: #f44336; color: white; }
/* счётчик*/ /* счётчик*/
@ -309,15 +400,21 @@
background: white; background: white;
transition: all 0.18s; transition: all 0.18s;
} }
.lesson-card:hover { .lesson-card:hover {
border-color: #2196F3; border-color: #2196F3;
box-shadow: 0 3px 10px rgba(33,150,243,0.15); box-shadow: 0 3px 10px rgba(33,150,243,0.15);
transform: translateY(-2px); transform: translateY(-2px);
} }
.lesson-card.done { .lesson-card.done {
border-left: 4px solid #4caf50; border-left: 4px solid #4caf50;
} }
.lesson-card.in-progress {
border-left: 4px solid #ffc107;
}
.lesson-header { .lesson-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -333,9 +430,7 @@
line-height: 1.4; line-height: 1.4;
} }
.completed-badge { .status-badge {
background: #e8f5e9;
color: #2e7d32;
padding: 3px 10px; padding: 3px 10px;
border-radius: 12px; border-radius: 12px;
font-size: 0.78em; font-size: 0.78em;
@ -344,6 +439,17 @@
margin-left: 12px; margin-left: 12px;
} }
.status-badge.done {
background: #e8f5e9;
color: #2e7d32;
}
.status-badge.progress {
background: #fff8e1;
color: #f57f17;
border: 1px solid #ffe082;
}
.lesson-footer { .lesson-footer {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -385,4 +491,119 @@
.filter-group { flex-direction: column; align-items: flex-start; } .filter-group { flex-direction: column; align-items: flex-start; }
.filter-label { min-width: unset; } .filter-label { min-width: unset; }
} }
.user-bar {
display: flex;
justify-content: flex-end;
padding: 10px 20px;
background: white;
border-bottom: 1px solid #e0e0e0;
position: relative;
}
.profile-wrap {
position: relative;
}
.profile-btn {
display: flex;
align-items: center;
gap: 8px;
background: #f5f5f5;
border: 1px solid #ddd;
padding: 7px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 0.9em;
color: #333;
}
.profile-btn:hover {
background: #eeeeee;
}
.auth-badge {
font-size: 0.7em;
padding: 2px 6px;
border-radius: 10px;
background: #e3f2fd;
color: #1565c0;
}
.auth-badge.moodle {
background: #fff3e0;
color: #e65100;
}
.arrow {
font-size: 0.7em;
color: #999;
}
.profile-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
min-width: 180px;
z-index: 100;
overflow: hidden;
}
.menu-item {
display: block;
width: 100%;
padding: 11px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 0.9em;
color: #333;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.danger {
color: #f44336;
}
.menu-item.danger:hover {
background: #ffebee;
}
.auth-links {
display: flex;
gap: 10px;
align-items: center;
}
.auth-link {
padding: 7px 16px;
border-radius: 8px;
text-decoration: none;
font-size: 0.9em;
color: #555;
border: 1px solid #ddd;
}
.auth-link:hover {
border-color: #2196F3;
color: #2196F3;
}
.auth-link.primary {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.auth-link.primary:hover {
background: #1565c0;
}
</style> </style>

View File

@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { db, users, progress } from '$lib/server/db.js';
import { eq } from 'drizzle-orm';
export async function POST({ locals }) {
const user = locals.user;
if (!user) return json({ error: 'Не авторизован' }, { status: 401 });
db.delete(progress).where(eq(progress.userId, user.id)).run();
db.delete(users).where(eq(users.id, user.id)).run();
return json({ success: true });
}

View File

@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import { createLocalUser, createSession } from '$lib/server/auth';
export async function POST({ request, cookies }) {
const { username, email, password } = await request.json();
if (!username || !email || !password) {
return json({ error: 'Заполните все поля' }, { status: 400 });
}
if (password.length < 8) {
return json({ error: 'Пароль должен быть не менее 8 символов' }, { status: 400 });
}
try {
const userId = createLocalUser(username, email, password);
const session = createSession(userId);
cookies.set('session', session.id, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
return json({ success: true, transferProgress: true });
} catch (e) {
return json({ error: e.message }, { status: 400 });
}
}

View File

@ -1,7 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { OPENROUTER_API_KEY } from '$env/static/private'; import { AI_API_KEY, AI_API_URL, AI_MODEL } from '$env/static/private';
const MODEL = 'openrouter/free'; // авто-роутер: всегда выбирает доступную бесплатную модель
export async function POST({ request }) { export async function POST({ request }) {
const body = await request.json(); const body = await request.json();
@ -14,24 +12,24 @@ export async function POST({ request }) {
const prompt = buildPrompt(lessonType, taskDescription, currentState); const prompt = buildPrompt(lessonType, taskDescription, currentState);
try { try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { const response = await fetch(AI_API_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`, 'Authorization': `Bearer ${AI_API_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'HTTP-Referer': 'https://localhost', 'HTTP-Referer': 'https://localhost',
}, },
body: JSON.stringify({ body: JSON.stringify({
model: MODEL, model: AI_MODEL,
messages: [{ role: 'user', content: prompt }], messages: [{ role: 'user', content: prompt }],
max_tokens: 300, max_tokens: 300,
temperature: 0.4, temperature: 0.3,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const err = await response.text(); const err = await response.text();
console.error('OpenRouter error:', err); console.error('AI API error:', err);
return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 }); return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 });
} }
@ -56,14 +54,15 @@ export async function POST({ request }) {
} }
function buildPrompt(lessonType, taskDescription, currentState) { function buildPrompt(lessonType, taskDescription, currentState) {
return `Ты помощник в образовательном веб-приложении для изучения сетевых протоколов. return `Ты помощник в образовательном веб-приложении для изучения сетевых протоколов.
Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким максимум 2-3 предложения. Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким максимум 2-3 предложения.
Не используй markdown, не пиши заголовков. Пиши просто и понятно для начинающего. Не используй markdown, не пиши заголовков. Никаких рассуждений, размышлений, "подумаем", "итак", "значит".
Сразу готовая подсказка без вступлений. Пиши просто и понятно для начинающего.
Задание: "${taskDescription}" Задание: "${taskDescription}"
Тип задания: ${lessonType} Тип задания: ${lessonType}
Состояние пользователя: ${JSON.stringify(currentState)} Состояние пользователя: ${JSON.stringify(currentState)}
Дай подсказку как двигаться к решению, НЕ называя конечный ответ прямо. Дай короткую подсказу - как двигаться к решению, НЕ называя конечный ответ прямо.
Можно намекнуть на нужный байт, бит или концепцию.`; Можно намекнуть на нужный байт, бит или концепцию.`;
} }

View File

@ -0,0 +1,130 @@
import { json } from '@sveltejs/kit';
import { db, progress as progressTable } from '$lib/server/db.js';
import { eq, and } from 'drizzle-orm';
import { syncMoodleGrade } from '$lib/server/moodle.js';
import { lessons } from '$lib/data/lessons.js';
//GET - загрузить прогресс пользователя
export async function GET({ locals }) {
if (!locals.user) return json({});
const rows = db.select().from(progressTable)
.where(eq(progressTable.userId, locals.user.id))
.all();
const result = {};
for (const row of rows) {
result[row.lessonId] = {
completed: row.completed === 1,
tasksCompleted: row.tasksCompleted,
};
}
return json(result);
}
//POST - сохранить прогресс урока
export async function POST({ request, locals }) {
if (!locals.user) return json({ error: 'Не авторизован' }, { status: 401 });
const { lessonId, tasksCompleted, completed } = await request.json();
const existing = db.select().from(progressTable)
.where(and(
eq(progressTable.userId, locals.user.id),
eq(progressTable.lessonId, String(lessonId)),
)).get();
if (existing) {
db.update(progressTable)
.set({
tasksCompleted,
completed: completed ? 1 : 0,
updatedAt: Date.now(),
})
.where(and(
eq(progressTable.userId, locals.user.id),
eq(progressTable.lessonId, String(lessonId)),
)).run();
} else {
db.insert(progressTable).values({
userId: locals.user.id,
lessonId: String(lessonId),
tasksCompleted,
completed: completed ? 1 : 0,
updatedAt: Date.now(),
}).run();
}
//если Moodle-пользователь и урок завершён, то синхронизируем с Gradebook
if (completed && locals.user.authType === 'moodle' && locals.user.moodleId) {
const completedCount = db.select().from(progressTable)
.where(and(
eq(progressTable.userId, locals.user.id),
eq(progressTable.completed, 1),
))
.all().length;
syncMoodleGrade(locals.user.moodleId, completedCount, lessons.length)
.catch(e => console.error('Moodle grade sync error:', e.message));
}
return json({ success: true });
}
//PUT - перенести прогресс из localStorage в БД при входе
export async function PUT({ request, locals }) {
if (!locals.user) return json({ error: 'Не авторизован' }, { status: 401 });
const localProgress = await request.json(); //{ lessonId: { completed, tasksCompleted } }
for (const [lessonId, data] of Object.entries(localProgress)) {
if (!data || typeof data.tasksCompleted !== 'number') continue;
const existing = db.select().from(progressTable)
.where(and(
eq(progressTable.userId, locals.user.id),
eq(progressTable.lessonId, String(lessonId)),
)).get();
//берём максимум из localStorage и БД
const bestCompleted = existing
? Math.max(existing.tasksCompleted, data.tasksCompleted)
: data.tasksCompleted;
if (existing) {
db.update(progressTable)
.set({
tasksCompleted: bestCompleted,
completed: bestCompleted >= 3 ? 1 : 0,
updatedAt: Date.now(),
})
.where(and(
eq(progressTable.userId, locals.user.id),
eq(progressTable.lessonId, String(lessonId)),
)).run();
} else {
db.insert(progressTable).values({
userId: locals.user.id,
lessonId: String(lessonId),
tasksCompleted: bestCompleted,
completed: bestCompleted >= 3 ? 1 : 0,
updatedAt: Date.now(),
}).run();
}
//cинхронизируем завершённые уроки в Moodle Gradebook
if (locals.user.authType === 'moodle' && locals.user.moodleId) {
const completedCount = db.select().from(progressTable)
.where(and(
eq(progressTable.userId, locals.user.id),
eq(progressTable.completed, 1),
))
.all().length;
syncMoodleGrade(locals.user.moodleId, completedCount, lessons.length)
.catch(() => {});
}
}
return json({ success: true });
}

View File

@ -53,7 +53,7 @@
readOnlyRanges = lesson.readOnlyRanges ?? []; readOnlyRanges = lesson.readOnlyRanges ?? [];
tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString()); tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString());
currentTaskNum = Math.min(tasksCompleted + 1, TASKS_REQUIRED); currentTaskNum = tasksCompleted + 1;
taskJustSolved = false; taskJustSolved = false;
notification = { show: false, message: '', type: 'success' }; notification = { show: false, message: '', type: 'success' };
@ -61,7 +61,7 @@
} }
function loadNextTask(isInitial = false) { function loadNextTask(isInitial = false) {
if (!isInitial) currentTaskNum = Math.min(currentTaskNum + 1, TASKS_REQUIRED + 1); if (!isInitial) currentTaskNum = tasksCompleted + 1;
taskJustSolved = false; taskJustSolved = false;
aiMessage = null; aiMessage = null;
aiError = null; aiError = null;
@ -99,7 +99,7 @@
? processBuffer(newBuffer, lesson.id) ? processBuffer(newBuffer, lesson.id)
: newBuffer; : newBuffer;
if (aiMessage) { aiMessage = null; aiError = null; } //if (aiMessage) { aiMessage = null; aiError = null; }
} }
function checkSolution() { function checkSolution() {

View File

@ -0,0 +1,7 @@
export async function load({ url }) {
const errorCode = url.searchParams.get('error');
const error = errorCode === 'CredentialsSignin'
? 'Неверный логин или пароль'
: null;
return { error };
}

View File

@ -0,0 +1,292 @@
<script>
import { progressStorage } from '$lib/utils/storage';
export let data; // { error } из load()
let activeTab = 'local';
let email = '';
let password = '';
let moodleUsername = '';
let moodlePassword = '';
let error = data?.error ?? '';
let loading = false;
let showPass = false;
let showMPass = false;
async function loginLocal() {
loading = true;
error = '';
try {
const res = await fetch('/auth/callback/local', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ email, password }),
});
if (res.url.includes('/login')) {
error = 'Неверный логин или пароль';
} else {
await progressStorage.transferToDB();
window.location.href = '/';
}
} catch {
error = 'Ошибка сети';
}
loading = false;
}
async function loginMoodle() {
loading = true;
error = '';
try {
const res = await fetch('/auth/callback/moodle', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: moodleUsername, password: moodlePassword }),
});
if (res.url.includes('/login')) {
error = 'Неверный логин или пароль Moodle';
} else {
await progressStorage.transferToDB();
window.location.href = '/';
}
} catch {
error = 'Ошибка сети';
}
loading = false;
}
</script>
<div class="page">
<div class="card">
<a href="/" class="close-btn" title="На главную"></a>
<h1>Вход</h1>
<div class="tabs">
<button class="tab" class:active={activeTab === 'local'}
on:click={() => { activeTab = 'local'; error = ''; }}>
Свой аккаунт
</button>
<button class="tab" class:active={activeTab === 'moodle'}
on:click={() => { activeTab = 'moodle'; error = ''; }}>
Войти через Moodle
</button>
</div>
{#if activeTab === 'local'}
<div class="form">
<input type="email" bind:value={email} placeholder="Email" />
<div class="pass-wrap">
<input type={showPass ? 'text' : 'password'}
bind:value={password} placeholder="Пароль"
autocomplete="current-password" />
<button type="button" class="eye" on:click={() => showPass = !showPass}>
{showPass ? '🙈' : '👁️'}
</button>
</div>
<button class="btn-primary" on:click={loginLocal} disabled={loading}>
{loading ? 'Вход...' : 'Войти'}
</button>
</div>
<p class="link">Нет аккаунта? <a href="/register">Зарегистрироваться</a></p>
{:else}
<div class="form">
<p class="hint">Введите логин и пароль от аккаунта Moodle</p>
<input type="text" bind:value={moodleUsername} placeholder="Логин Moodle" />
<div class="pass-wrap">
<input type={showMPass ? 'text' : 'password'}
bind:value={moodlePassword} placeholder="Пароль Moodle"
autocomplete="current-password" />
<button type="button" class="eye" on:click={() => showMPass = !showMPass}>
{showMPass ? '🙈' : '👁️'}
</button>
</div>
<button class="btn-moodle" on:click={loginMoodle} disabled={loading}>
{loading ? 'Проверка...' : '🎓 Войти через Moodle'}
</button>
</div>
<p class="link">
Нет аккаунта Moodle?
<a href="https://moodle-production-c39f.up.railway.app/login/signup.php"
target="_blank">Зарегистрироваться в Moodle</a>
</p>
{/if}
{#if error}
<div class="error">{error}</div>
{/if}
</div>
</div>
<style>
.page {
min-height:100vh;
display:flex;
align-items:center;
justify-content:center;
background:#f5f7fa;
}
.card {
position:relative;
background:white;
border-radius:12px;
padding:40px;
width:100%;
max-width:420px;
box-shadow:0 4px 20px rgba(0,0,0,.1);
}
.close-btn {
position:absolute;
top:14px;
right:14px;
font-size:1.1em;
color:#aaa;
text-decoration:none;
padding:4px 8px;
border-radius:50%;
transition:all .2s;
}
.close-btn:hover {
color:#555;
background:#f0f0f0;
}
h1 {
margin:0 0 24px;
color:#1565c0;
text-align:center;
}
.tabs {
display:flex;
border-bottom:2px solid #e0e0e0;
margin-bottom:24px;
}
.tab {
flex:1;
padding:10px;
border:none;
background:none;
cursor:pointer;
color:#888;
font-size:.95em;
border-bottom:2px solid transparent;
margin-bottom:-2px;
transition:all .2s;
}
.tab.active {
color:#1565c0;
border-bottom-color:#1565c0;
font-weight:bold;
}
.form {
display:flex;
flex-direction:column;
gap:12px;
}
input {
width:100%;
padding:11px 14px;
border:1px solid #ddd;
border-radius:8px;
font-size:1em;
outline:none;
box-sizing:border-box;
}
input:focus {
border-color:#2196F3;
}
.pass-wrap {
position:relative;
display:flex;
align-items:center;
}
.pass-wrap input {
padding-right:44px;
}
.eye {
position:absolute;
right:10px;
background:none;
border:none;
cursor:pointer;
font-size:1.1em;
padding:4px;
}
.btn-primary {
background:#2196F3;
color:white;
border:none;
padding:12px;
border-radius:8px;
cursor:pointer;
font-size:1em;
font-weight:500;
}
.btn-primary:hover:not(:disabled) {
background:#1565c0;
}
.btn-moodle {
background:#f98012;
color:white;
border:none;
padding:12px;
border-radius:8px;
cursor:pointer;
font-size:1em;
font-weight:500;
}
.btn-moodle:hover:not(:disabled) {
background:#e06b00;
}
button:disabled {
opacity:.6;
cursor:default;
}
.hint {
font-size:.85em;
color:#888;
margin:0;
text-align:center;
}
.link {
text-align:center;
margin-top:16px;
font-size:.9em;
color:#666;
}
.link a {
color:#2196F3;
text-decoration:none;
}
.error {
background:#ffebee;
color:#c62828;
padding:10px 14px;
border-radius:8px;
margin-top:16px;
font-size:.9em;
text-align:center;
}
</style>

View File

@ -0,0 +1,26 @@
import { createLocalUser } from '$lib/server/auth.js';
export const actions = {
default: async (event) => {
const data = await event.request.formData();
const username = data.get('username');
const email = String(data.get('email') ?? '');
const password = String(data.get('password') ?? '');
const confirm = String(data.get('confirm') ?? '');
if (!username || !email || !password)
return { error: 'Заполните все поля' };
if (password.length < 8)
return { error: 'Пароль должен быть не менее 8 символов' };
if (password !== confirm)
return { error: 'Пароли не совпадают' };
try {
createLocalUser(username, email, password);
} catch (e) {
return { error: e.message };
}
return { success: true, email, password };
},
};

View File

@ -0,0 +1,212 @@
<script>
import { enhance } from '$app/forms';
import { progressStorage } from '$lib/utils/storage';
export let form; //ошибки из page.server.js
let loading = false;
let showPass = false;
let showConfirm = false;
let email = '';
let password = '';
let confirm = '';
function handleEnhance() {
loading = true;
return async ({ result, update }) => {
if (result.type === 'error' || (result.type === 'failure' && result.data?.error)) {
await update({ reset: false });
password = '';
confirm = '';
loading = false;
return;
}
if (result.type === 'success' && result.data?.success) {
//аккаунт создан, входим через Auth.js fetch
try {
const res = await fetch('/auth/callback/local', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ email: result.data.email, password: result.data.password }),
});
await progressStorage.transferToDB();
window.location.href = res.url.includes('/login') ? '/login' : '/';
} catch {
window.location.href = '/login?registered=1';
}
} else {
await update({ reset: false });
password = '';
confirm = '';
loading = false;
}
};
}
</script>
<div class="page">
<div class="card">
<a href="/" class="close-btn" title="На главную"></a>
<h1>Регистрация</h1>
<form method="POST" use:enhance={handleEnhance}>
<div class="form">
<input name="username" type="text" placeholder="Имя пользователя" required />
<input name="email" type="email" placeholder="Email"
bind:value={email} required />
<div class="pass-wrap">
<input name="password" type={showPass ? 'text' : 'password'}
placeholder="Пароль (мин. 8 символов)"
bind:value={password}
autocomplete="new-password" required />
<button type="button" class="eye" on:click={() => showPass = !showPass}>
{showPass ? '🙈' : '👁️'}
</button>
</div>
<div class="pass-wrap">
<input name="confirm" type={showConfirm ? 'text' : 'password'}
placeholder="Повторите пароль"
bind:value={confirm}
autocomplete="new-password" required />
<button type="button" class="eye" on:click={() => showConfirm = !showConfirm}>
{showConfirm ? '🙈' : '👁️'}
</button>
</div>
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
</button>
</div>
</form>
{#if form?.error}
<div class="error">{form.error}</div>
{/if}
<p class="link">Уже есть аккаунт? <a href="/login">Войти</a></p>
</div>
</div>
<style>
.page {
min-height:100vh;
display:flex;
align-items:center;
justify-content:center;
background:#f5f7fa;
}
.card {
position:relative;
background:white;
border-radius:12px;
padding:40px;
width:100%;
max-width:420px;
box-shadow:0 4px 20px rgba(0,0,0,.1);
}
.close-btn {
position:absolute;
top:14px;
right:14px;
font-size:1.1em;
color:#aaa;
text-decoration:none;
padding:4px 8px;
border-radius:50%;
transition:all .2s;
}
.close-btn:hover {
color:#555;
background:#f0f0f0;
}
h1 {
margin:0 0 24px;
color:#1565c0;
text-align:center;
}
.form {
display:flex;
flex-direction:column;
gap:12px;
}
input {
width:100%;
padding:11px 14px;
border:1px solid #ddd;
border-radius:8px;
font-size:1em;
outline:none;
box-sizing:border-box;
}
input:focus {
border-color:#2196F3;
}
.pass-wrap {
position:relative;
display:flex;
align-items:center;
}
.pass-wrap input {
padding-right:44px;
}
.eye {
position:absolute;
right:10px;
background:none;
border:none;
cursor:pointer;
font-size:1.1em;
padding:4px;
}
.btn-primary {
background:#2196F3;
color:white;
border:none;
padding:12px;
border-radius:8px;
cursor:pointer;
font-size:1em;
font-weight:500;
}
.btn-primary:hover:not(:disabled) {
background:#1565c0;
}
button:disabled {
opacity:.6;
cursor:default;
}
.link {
text-align:center;
margin-top:16px;
font-size:.9em;
color:#666;
}
.link a {
color:#2196F3;
text-decoration:none;
}
.error {
background:#ffebee;
color:#c62828;
padding:10px 14px;
border-radius:8px;
margin-top:16px;
font-size:.9em;
text-align:center;
}
</style>