Добавлена авторизация (локальная и через Moodle), локальная регистрация
This commit is contained in:
parent
bdecc30299
commit
eeb4a39506
1335
package-lock.json
generated
1335
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
||||
"@sveltejs/adapter-auto": "^6.1.1",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/prettier": "^2.7.3",
|
||||
"eslint": "^9.38.0",
|
||||
@ -21,5 +22,10 @@
|
||||
"svelte": "^5.39.5",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"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
87
src/auth.js
Normal 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
11
src/hooks.server.js
Normal 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);
|
||||
}
|
||||
);
|
||||
@ -10,6 +10,36 @@
|
||||
|
||||
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) {
|
||||
for (let i = 0; i < tool.bytes; i++) {
|
||||
const byteIndex = startIndex + i;
|
||||
@ -51,147 +81,181 @@
|
||||
$: selectedToolData = availableTools.find(t => t.id === selectedTool);
|
||||
</script>
|
||||
|
||||
<svelte:window on:mousemove={onMove} on:mouseup={stopDrag} />
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
class="tool-selector-overlay"
|
||||
style="position: fixed; left: 0; top: 0; right: 0; bottom: 0; z-index: 1000;"
|
||||
<div
|
||||
bind:this={popupEl}
|
||||
class="popup"
|
||||
class:dragging
|
||||
style={popupStyle}
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="tool-selector-popup"
|
||||
style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
"
|
||||
>
|
||||
<div class="tool-header">
|
||||
<h4>Редактирование байта {startIndex}</h4>
|
||||
</div>
|
||||
<!--заголовок (тянем за него)-->
|
||||
<div class="popup-header" on:mousedown={startDrag} role="presentation" tabindex="-1">
|
||||
<span class="drag-icon">⠿</span>
|
||||
<span class="title">Редактирование байта {startIndex}</span>
|
||||
<button class="close-x" on:click={() => show = false}>✕</button>
|
||||
</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="tools-row">
|
||||
{#each availableTools as tool}
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:active={selectedTool === tool.id}
|
||||
on:click={() => selectedTool = tool.id}
|
||||
>{tool.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="tool-content">
|
||||
{#if selectedTool === 'bit'}
|
||||
<BitEditor
|
||||
buffer={new Uint8Array([buffer[startIndex]])}
|
||||
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="content">
|
||||
{#if selectedTool === 'bit'}
|
||||
<BitEditor
|
||||
buffer={new Uint8Array([buffer[startIndex]])}
|
||||
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="tool-footer">
|
||||
<button on:click={() => show = false} class="close-button">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button class="close-btn" on:click={() => show = false}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tool-selector-overlay {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-selector-popup {
|
||||
.popup {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: 2px solid #2196F3;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
||||
overflow-y: auto;
|
||||
min-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
min-width: 380px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 12px;
|
||||
text-align: center;
|
||||
|
||||
.popup.dragging { cursor: grabbing; box-shadow: 0 12px 40px rgba(0,0,0,0.3); }
|
||||
|
||||
/*заголовок*/
|
||||
.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;
|
||||
}
|
||||
|
||||
.tool-header h4 {
|
||||
margin: 0 0 4px 0;
|
||||
color: #2196F3;
|
||||
|
||||
.popup-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tool-selection {
|
||||
|
||||
.drag-icon {
|
||||
color: #bbb;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: #1565c0;
|
||||
font-size: 0.95em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.close-x {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
font-size: 1em;
|
||||
padding: 3px 7px;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-x:hover {
|
||||
color: #f44336;
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
/*инструменты*/
|
||||
.tools-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
|
||||
.tool-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.82em;
|
||||
transition: all 0.15s;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tool-button:hover {
|
||||
background: #f0f7ff;
|
||||
border-color: #90caf9;
|
||||
|
||||
.tool-btn:hover {
|
||||
border-color: #90caf9;
|
||||
color: #1565c0;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.tool-button.selected {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border-color: #1976D2;
|
||||
|
||||
.tool-btn.active {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border-color: #1976D2;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
max-height: 60vh;
|
||||
|
||||
.content {
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-footer {
|
||||
|
||||
.footer {
|
||||
padding: 10px 14px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 8px 24px;
|
||||
|
||||
.close-btn {
|
||||
padding: 7px 22px;
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #ddd;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #e0e0e0;
|
||||
|
||||
.close-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
@ -139,7 +139,7 @@ export const lessons = [
|
||||
{
|
||||
id: 5,
|
||||
slug: 'ipv4-ttl',
|
||||
title: 'TTL — время жизни пакета',
|
||||
title: 'TTL - время жизни пакета',
|
||||
category: 'IPv4',
|
||||
difficulty: 'Средний',
|
||||
component: 'HexEditor',
|
||||
|
||||
@ -103,12 +103,22 @@ export const theory = {
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура кадра Ethernet II</h3>
|
||||
<div style="${DARK}">
|
||||
<div>+----------+----------+----------+------------------+------+</div>
|
||||
<div>| Dst MAC | Src MAC |EtherType | Data | FCS |</div>
|
||||
<div>| 6 байт | 6 байт | 2 байта | 46-1500 байт | 4 б |</div>
|
||||
<div>+----------+----------+----------+------------------+------+</div>
|
||||
</div>
|
||||
<table style="border-collapse:collapse;width:100%;margin:10px 0;font-size:0.9em;">
|
||||
<tr>
|
||||
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Dst MAC</th>
|
||||
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Src MAC</th>
|
||||
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">EtherType</th>
|
||||
<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;">46–1500 байт</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>EtherType</strong> — тип протокола: <code>0x0800</code> — IPv4, <code>0x0806</code> — ARP, <code>0x86DD</code> — IPv6.</p>
|
||||
<p><strong>FCS</strong> (Frame Check Sequence) — контрольная сумма, рассчитывается автоматически.</p>
|
||||
@ -160,11 +170,11 @@ export const theory = {
|
||||
|
||||
'ipv4-ttl': `
|
||||
<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}">
|
||||
<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 style="${B}">
|
||||
@ -180,7 +190,7 @@ export const theory = {
|
||||
|
||||
<div style="${B}">
|
||||
<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>
|
||||
`,
|
||||
|
||||
51
src/lib/server/auth.js
Normal file
51
src/lib/server/auth.js
Normal 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
50
src/lib/server/db.js
Normal 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
124
src/lib/server/moodle.js
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -1,3 +1,8 @@
|
||||
//прогресс хранится в БД если пользователь авторизован,
|
||||
//иначе в localStorage (для гостей)
|
||||
|
||||
const TASKS_REQUIRED = 3;
|
||||
|
||||
export const progressStorage = {
|
||||
_getAll() {
|
||||
if (typeof window === 'undefined') return {};
|
||||
@ -17,7 +22,7 @@ export const progressStorage = {
|
||||
const all = this._getAll();
|
||||
const raw = all[String(lessonId)];
|
||||
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 };
|
||||
},
|
||||
|
||||
@ -30,11 +35,12 @@ export const progressStorage = {
|
||||
},
|
||||
|
||||
//сохраняет новое значение счётчика; автоматически выставляет completed
|
||||
saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = 3) {
|
||||
saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = TASKS_REQUIRED) {
|
||||
const all = this._getAll();
|
||||
const completed = tasksCompleted >= tasksRequired;
|
||||
all[String(lessonId)] = { completed, tasksCompleted };
|
||||
this._save(all);
|
||||
this._syncToDB(lessonId, tasksCompleted, completed);
|
||||
return completed;
|
||||
},
|
||||
|
||||
@ -48,4 +54,62 @@ export const progressStorage = {
|
||||
}
|
||||
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');
|
||||
}
|
||||
},
|
||||
};
|
||||
3
src/routes/+page.server.js
Normal file
3
src/routes/+page.server.js
Normal file
@ -0,0 +1,3 @@
|
||||
export async function load({ locals }) {
|
||||
return { user: locals.user };
|
||||
}
|
||||
@ -3,15 +3,50 @@
|
||||
import { progressStorage } from '$lib/utils/storage';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data; //получаем user
|
||||
$: user = data?.user ?? null;
|
||||
|
||||
let progress = {};
|
||||
let progressData = {}; //детальный прогресс для плашек { tasksCompleted, completed }
|
||||
let showProfileMenu = false;
|
||||
|
||||
let searchQuery = '';
|
||||
let activeCategory = 'Все';
|
||||
let activeDifficulty = 'Все';
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
if (user) {
|
||||
await progressStorage.transferToDB(); //localStorage -> БД при первом входе
|
||||
await progressStorage.loadFromDB(); //БД -> localStorage
|
||||
}
|
||||
progressData = progressStorage._getAll();
|
||||
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))];
|
||||
$: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))];
|
||||
@ -28,14 +63,29 @@
|
||||
return matchSearch && matchCat && matchDiff;
|
||||
});
|
||||
|
||||
//счётчик завершённых уроков
|
||||
$: completedCount = lessons.filter(l => progress[l.id]).length;
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
activeCategory = 'Все';
|
||||
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) {
|
||||
@ -45,6 +95,39 @@
|
||||
|
||||
<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">
|
||||
<h1>Обучение сетевым протоколам</h1>
|
||||
@ -122,11 +205,16 @@
|
||||
<!--список уроков-->
|
||||
<div class="lessons-list">
|
||||
{#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">
|
||||
<h3>{lesson.title}</h3>
|
||||
{#if progress[lesson.id]}
|
||||
<span class="completed-badge">✅ Пройден</span>
|
||||
{#if status === 'completed'}
|
||||
<span class="status-badge done">✅ Пройден</span>
|
||||
{:else if status === 'in-progress'}
|
||||
<span class="status-badge progress">⏳ В процессе</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="lesson-footer">
|
||||
@ -264,7 +352,9 @@
|
||||
color: #555;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tag:hover { border-color: #2196F3; color: #2196F3; }
|
||||
|
||||
.tag.active {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
@ -282,6 +372,7 @@
|
||||
font-size: 0.85em;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.reset-btn:hover { background: #f44336; color: white; }
|
||||
|
||||
/* счётчик*/
|
||||
@ -309,15 +400,21 @@
|
||||
background: white;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
|
||||
.lesson-card:hover {
|
||||
border-color: #2196F3;
|
||||
box-shadow: 0 3px 10px rgba(33,150,243,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.lesson-card.done {
|
||||
border-left: 4px solid #4caf50;
|
||||
}
|
||||
|
||||
.lesson-card.in-progress {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.lesson-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -333,9 +430,7 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.completed-badge {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
.status-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.78em;
|
||||
@ -343,6 +438,17 @@
|
||||
white-space: nowrap;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.status-badge.done {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.progress {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
border: 1px solid #ffe082;
|
||||
}
|
||||
|
||||
.lesson-footer {
|
||||
display: flex;
|
||||
@ -385,4 +491,119 @@
|
||||
.filter-group { flex-direction: column; align-items: flex-start; }
|
||||
.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>
|
||||
13
src/routes/api/auth/delete/+server.js
Normal file
13
src/routes/api/auth/delete/+server.js
Normal 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 });
|
||||
}
|
||||
28
src/routes/api/auth/register/+server.js
Normal file
28
src/routes/api/auth/register/+server.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { OPENROUTER_API_KEY } from '$env/static/private';
|
||||
|
||||
const MODEL = 'openrouter/free'; // авто-роутер: всегда выбирает доступную бесплатную модель
|
||||
import { AI_API_KEY, AI_API_URL, AI_MODEL } from '$env/static/private';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const body = await request.json();
|
||||
@ -14,24 +12,24 @@ export async function POST({ request }) {
|
||||
const prompt = buildPrompt(lessonType, taskDescription, currentState);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
const response = await fetch(AI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Authorization': `Bearer ${AI_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://localhost',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
model: AI_MODEL,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 300,
|
||||
temperature: 0.4,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error('OpenRouter error:', err);
|
||||
console.error('AI API error:', err);
|
||||
return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 });
|
||||
}
|
||||
|
||||
@ -56,14 +54,15 @@ export async function POST({ request }) {
|
||||
}
|
||||
|
||||
function buildPrompt(lessonType, taskDescription, currentState) {
|
||||
return `Ты — помощник в образовательном веб-приложении для изучения сетевых протоколов.
|
||||
return `Ты помощник в образовательном веб-приложении для изучения сетевых протоколов.
|
||||
Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким — максимум 2-3 предложения.
|
||||
Не используй markdown, не пиши заголовков. Пиши просто и понятно для начинающего.
|
||||
Не используй markdown, не пиши заголовков. Никаких рассуждений, размышлений, "подумаем", "итак", "значит".
|
||||
Сразу готовая подсказка без вступлений. Пиши просто и понятно для начинающего.
|
||||
|
||||
Задание: "${taskDescription}"
|
||||
Тип задания: ${lessonType}
|
||||
Состояние пользователя: ${JSON.stringify(currentState)}
|
||||
|
||||
Дай подсказку — как двигаться к решению, НЕ называя конечный ответ прямо.
|
||||
Дай короткую подсказу - как двигаться к решению, НЕ называя конечный ответ прямо.
|
||||
Можно намекнуть на нужный байт, бит или концепцию.`;
|
||||
}
|
||||
130
src/routes/api/progress/+server.js
Normal file
130
src/routes/api/progress/+server.js
Normal 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 });
|
||||
}
|
||||
@ -53,7 +53,7 @@
|
||||
readOnlyRanges = lesson.readOnlyRanges ?? [];
|
||||
|
||||
tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString());
|
||||
currentTaskNum = Math.min(tasksCompleted + 1, TASKS_REQUIRED);
|
||||
currentTaskNum = tasksCompleted + 1;
|
||||
taskJustSolved = false;
|
||||
notification = { show: false, message: '', type: 'success' };
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
}
|
||||
|
||||
function loadNextTask(isInitial = false) {
|
||||
if (!isInitial) currentTaskNum = Math.min(currentTaskNum + 1, TASKS_REQUIRED + 1);
|
||||
if (!isInitial) currentTaskNum = tasksCompleted + 1;
|
||||
taskJustSolved = false;
|
||||
aiMessage = null;
|
||||
aiError = null;
|
||||
@ -99,7 +99,7 @@
|
||||
? processBuffer(newBuffer, lesson.id)
|
||||
: newBuffer;
|
||||
|
||||
if (aiMessage) { aiMessage = null; aiError = null; }
|
||||
//if (aiMessage) { aiMessage = null; aiError = null; }
|
||||
}
|
||||
|
||||
function checkSolution() {
|
||||
|
||||
7
src/routes/login/+page.server.js
Normal file
7
src/routes/login/+page.server.js
Normal file
@ -0,0 +1,7 @@
|
||||
export async function load({ url }) {
|
||||
const errorCode = url.searchParams.get('error');
|
||||
const error = errorCode === 'CredentialsSignin'
|
||||
? 'Неверный логин или пароль'
|
||||
: null;
|
||||
return { error };
|
||||
}
|
||||
292
src/routes/login/+page.svelte
Normal file
292
src/routes/login/+page.svelte
Normal 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>
|
||||
26
src/routes/register/+page.server.js
Normal file
26
src/routes/register/+page.server.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
212
src/routes/register/+page.svelte
Normal file
212
src/routes/register/+page.svelte
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user