fix: Update frontend to Svelte 5 syntax
- Use $state() runes for reactive state - Use $derived() for computed values - Update event handlers (onclick instead of on:click) - Update package.json for Svelte 5 dependencies - Rename stores.js to stores.svelte.js for runes support
This commit is contained in:
parent
061e230b18
commit
c32a95fc91
@ -8,14 +8,15 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"svelte": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^12.0.0",
|
||||
"highlight.js": "^11.9.0"
|
||||
"marked": "^15.0.0",
|
||||
"highlight.js": "^11.11.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@ -1,189 +1,177 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = API_BASE;
|
||||
}
|
||||
constructor() {
|
||||
this.token = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
this.token = localStorage.getItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (typeof window !== 'undefined') {
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
}
|
||||
async request(endpoint, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
clearToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async login(email, password) {
|
||||
const formData = new FormData();
|
||||
formData.append('username', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Login failed' }));
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
async login(email, password) {
|
||||
const data = await this.request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
async register(email, username, password) {
|
||||
return this.request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
async register(email, username, password) {
|
||||
return this.request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, password })
|
||||
});
|
||||
}
|
||||
async getMe() {
|
||||
return this.request('/auth/me');
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request('/auth/me');
|
||||
}
|
||||
async updateMe(data) {
|
||||
return this.request('/auth/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Endpoints
|
||||
async getEndpoints() {
|
||||
return this.request('/endpoints');
|
||||
}
|
||||
async getEndpoints() {
|
||||
return this.request('/chat/endpoints');
|
||||
}
|
||||
|
||||
async createEndpoint(data) {
|
||||
return this.request('/endpoints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
async sendMessage(message, endpointId = null, fileIds = [], conversationHistory = []) {
|
||||
return this.request('/chat/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
endpoint_id: endpointId,
|
||||
file_ids: fileIds,
|
||||
conversation_history: conversationHistory,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async updateEndpoint(id, data) {
|
||||
return this.request(`/endpoints/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
async getChatHistory(limit = 50) {
|
||||
return this.request(`/chat/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
async deleteEndpoint(id) {
|
||||
return this.request(`/endpoints/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// Chat
|
||||
async sendMessage(endpointId, message, conversationId = null) {
|
||||
return this.request('/chat/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
endpoint_id: endpointId,
|
||||
message,
|
||||
conversation_id: conversationId
|
||||
})
|
||||
});
|
||||
}
|
||||
const headers = {};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
async getConversations() {
|
||||
return this.request('/chat/conversations');
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/chat/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
async getConversation(id) {
|
||||
return this.request(`/chat/conversations/${id}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
async deleteConversation(id) {
|
||||
return this.request(`/chat/conversations/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// File upload
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
async getFiles() {
|
||||
return this.request('/chat/files');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.getToken()}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
async deleteFile(fileId) {
|
||||
return this.request(`/chat/files/${fileId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
async getAdminStats() {
|
||||
return this.request('/admin/stats');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
async getUsers() {
|
||||
return this.request('/admin/users');
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async getAdminStats() {
|
||||
return this.request('/admin/stats');
|
||||
}
|
||||
async updateUser(userId, data) {
|
||||
return this.request(`/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.request('/admin/users');
|
||||
}
|
||||
async deleteUser(userId) {
|
||||
return this.request(`/admin/users/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async updateUser(id, data) {
|
||||
return this.request(`/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
async getAdminEndpoints() {
|
||||
return this.request('/admin/endpoints');
|
||||
}
|
||||
|
||||
async deleteUser(id) {
|
||||
return this.request(`/admin/users/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
async createEndpoint(data) {
|
||||
return this.request('/admin/endpoints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.clearToken();
|
||||
}
|
||||
async updateEndpoint(endpointId, data) {
|
||||
return this.request(`/admin/endpoints/${endpointId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEndpoint(endpointId) {
|
||||
return this.request(`/admin/endpoints/${endpointId}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
|
||||
export const user = writable(null);
|
||||
export const isLoading = writable(true);
|
||||
|
||||
export async function initializeAuth() {
|
||||
isLoading.set(true);
|
||||
const token = api.getToken();
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const userData = await api.getMe();
|
||||
user.set(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error);
|
||||
api.clearToken();
|
||||
user.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.set(false);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
api.logout();
|
||||
user.set(null);
|
||||
}
|
||||
39
frontend/src/lib/stores.svelte.js
Normal file
39
frontend/src/lib/stores.svelte.js
Normal file
@ -0,0 +1,39 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
// Svelte 5 runes-based stores
|
||||
let user = $state(null);
|
||||
let isLoading = $state(true);
|
||||
|
||||
export function getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
export function getIsLoading() {
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
export async function initializeAuth() {
|
||||
try {
|
||||
if (api.token) {
|
||||
const userData = await api.getMe();
|
||||
user = userData;
|
||||
}
|
||||
} catch (e) {
|
||||
api.setToken(null);
|
||||
user = null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUser(userData) {
|
||||
user = userData;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
api.setToken(null);
|
||||
user = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
@ -1,233 +1,251 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, isLoading, initializeAuth, logout } from '$lib/stores';
|
||||
import '../app.css';
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getUser, getIsLoading, initializeAuth, logout, setUser } from '$lib/stores.svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
onMount(() => {
|
||||
initializeAuth();
|
||||
});
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
$: currentPath = $page.url.pathname;
|
||||
$: isAuthPage = currentPath === '/login' || currentPath === '/register';
|
||||
let user = $derived(getUser());
|
||||
let isLoading = $derived(getIsLoading());
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/login');
|
||||
}
|
||||
onMount(() => {
|
||||
initializeAuth();
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLoading}
|
||||
<div class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if !$user && !isAuthPage}
|
||||
{#if typeof window !== 'undefined'}
|
||||
<script>
|
||||
window.location.href = '/login';
|
||||
</script>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<div class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if user}
|
||||
<div class="app-layout">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="menu-toggle" onclick={() => sidebarOpen = !sidebarOpen}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="logo">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo-img" />
|
||||
<span class="logo-text">Moxiegen</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span class="user-name">{user.username}</span>
|
||||
<button class="btn btn-secondary btn-sm" onclick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" class:open={sidebarOpen}>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/chat" class="nav-link" class:active={$page.url.pathname === '/chat'}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
Chat
|
||||
</a>
|
||||
{#if user.role === 'admin'}
|
||||
<a href="/admin" class="nav-link" class:active={$page.url.pathname.startsWith('/admin')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
Admin Panel
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Overlay for mobile -->
|
||||
{#if sidebarOpen}
|
||||
<div class="sidebar-overlay" onclick={() => sidebarOpen = false}></div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="app-layout">
|
||||
{#if $user}
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<span class="logo-text">Moxiegen</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/chat" class="nav-item" class:active={currentPath === '/chat'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
Chat
|
||||
</a>
|
||||
|
||||
{#if $user.is_admin}
|
||||
<a href="/admin" class="nav-item" class:active={currentPath === '/admin'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">{$user.username?.[0]?.toUpperCase() || 'U'}</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name">{$user.username}</span>
|
||||
<span class="user-email">{$user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" on:click={handleLogout}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background-color: var(--background);
|
||||
}
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"sidebar main";
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
}
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.5rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.logo-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 0.5rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
.user-name {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1.5rem 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.nav-link.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--error);
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: var(--header-height);
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: var(--header-height);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,43 +1,48 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getUser, getIsLoading, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
onMount(() => {
|
||||
if ($user) {
|
||||
goto('/chat');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
let user = $derived(getUser());
|
||||
let isLoading = $derived(getIsLoading());
|
||||
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (user) {
|
||||
goto('/chat');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="redirect-page">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting...</p>
|
||||
<div class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading Moxiegen...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.redirect-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
background: var(--background);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,420 +1,593 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, getIsLoading, initializeAuth } from '$lib/stores.svelte';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
let endpoints = [];
|
||||
let selectedEndpoint = null;
|
||||
let conversations = [];
|
||||
let currentConversation = null;
|
||||
let messages = [];
|
||||
let newMessage = '';
|
||||
let loading = false;
|
||||
let sending = false;
|
||||
let error = '';
|
||||
let fileInput;
|
||||
let uploadedFile = null;
|
||||
let messages = $state([]);
|
||||
let inputMessage = $state('');
|
||||
let endpoints = $state([]);
|
||||
let selectedEndpoint = $state(null);
|
||||
let loading = $state(true);
|
||||
let sending = $state(false);
|
||||
let uploadedFiles = $state([]);
|
||||
let selectedFiles = $state([]);
|
||||
let fileInput;
|
||||
|
||||
// Configure marked with highlight.js
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
let user = $derived(getUser());
|
||||
let isLoading = $derived(getIsLoading());
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
endpoints = await api.getEndpoints();
|
||||
if (endpoints.length > 0) {
|
||||
selectedEndpoint = endpoints[0];
|
||||
}
|
||||
conversations = await api.getConversations();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (!user) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function selectConversation(conv) {
|
||||
currentConversation = conv;
|
||||
messages = conv.messages || [];
|
||||
}
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [endpointsData, historyData, filesData] = await Promise.all([
|
||||
api.getEndpoints(),
|
||||
api.getChatHistory(50),
|
||||
api.getFiles()
|
||||
]);
|
||||
endpoints = endpointsData;
|
||||
uploadedFiles = filesData;
|
||||
|
||||
function newChat() {
|
||||
currentConversation = null;
|
||||
messages = [];
|
||||
}
|
||||
const defaultEp = endpoints.find(e => e.is_default);
|
||||
if (defaultEp) {
|
||||
selectedEndpoint = defaultEp.id;
|
||||
} else if (endpoints.length > 0) {
|
||||
selectedEndpoint = endpoints[0].id;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!newMessage.trim() || !selectedEndpoint) return;
|
||||
messages = historyData.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: new Date(m.created_at)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
sending = true;
|
||||
const userMessage = newMessage;
|
||||
newMessage = '';
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.trim() || sending) return;
|
||||
|
||||
// Add user message to UI immediately
|
||||
messages = [...messages, { role: 'user', content: userMessage }];
|
||||
const userMessage = inputMessage;
|
||||
inputMessage = '';
|
||||
|
||||
try {
|
||||
const response = await api.sendMessage(
|
||||
selectedEndpoint.id,
|
||||
userMessage,
|
||||
currentConversation?.id
|
||||
);
|
||||
messages = [...messages, {
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
|
||||
// Add assistant response
|
||||
messages = [...messages, { role: 'assistant', content: response.response }];
|
||||
const conversationHistory = messages.slice(-10).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
// Update conversation if new
|
||||
if (!currentConversation) {
|
||||
currentConversation = { id: response.conversation_id };
|
||||
conversations = await api.getConversations();
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
// Remove the user message on error
|
||||
messages = messages.slice(0, -1);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
sending = true;
|
||||
|
||||
async function handleFileUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const response = await api.sendMessage(
|
||||
userMessage,
|
||||
selectedEndpoint,
|
||||
selectedFiles.map(f => f.id),
|
||||
conversationHistory
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await api.uploadFile(file);
|
||||
uploadedFile = result;
|
||||
newMessage += `\n[File: ${file.name}]`;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
messages = [...messages, {
|
||||
role: 'assistant',
|
||||
content: response.response,
|
||||
model: response.model_used,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
|
||||
function renderMarkdown(content) {
|
||||
return marked(content);
|
||||
}
|
||||
selectedFiles = [];
|
||||
} catch (e) {
|
||||
messages = [...messages, {
|
||||
role: 'assistant',
|
||||
content: `Error: ${e.message}`,
|
||||
isError: true,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
} finally {
|
||||
sending = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const uploaded = await api.uploadFile(file);
|
||||
uploadedFiles = [...uploadedFiles, uploaded];
|
||||
selectedFiles = [...selectedFiles, uploaded];
|
||||
} catch (e) {
|
||||
alert('Failed to upload file: ' + e.message);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
function removeSelectedFile(file) {
|
||||
selectedFiles = selectedFiles.filter(f => f.id !== file.id);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
const container = document.querySelector('.messages-container');
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function renderMarkdown(content) {
|
||||
return marked.parse(content);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-page">
|
||||
<!-- Conversations Sidebar -->
|
||||
<aside class="conversations-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button class="btn btn-primary" on:click={newChat}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
<!-- Sidebar with endpoints -->
|
||||
<aside class="chat-sidebar">
|
||||
<h3>AI Endpoints</h3>
|
||||
{#if endpoints.length === 0}
|
||||
<p class="no-endpoints">No endpoints configured. Ask an admin to add one.</p>
|
||||
{:else}
|
||||
<div class="endpoint-list">
|
||||
{#each endpoints as endpoint}
|
||||
<label class="endpoint-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="endpoint"
|
||||
value={endpoint.id}
|
||||
bind:group={selectedEndpoint}
|
||||
/>
|
||||
<span class="endpoint-info">
|
||||
<span class="endpoint-name">{endpoint.name}</span>
|
||||
<span class="endpoint-model">{endpoint.model}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="conversations-list">
|
||||
{#each conversations as conv (conv.id)}
|
||||
<button
|
||||
class="conversation-item"
|
||||
class:active={currentConversation?.id === conv.id}
|
||||
on:click={() => selectConversation(conv)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span>{conv.title || 'Untitled conversation'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="files-section">
|
||||
<h4>Uploaded Files</h4>
|
||||
{#if uploadedFiles.length === 0}
|
||||
<p class="no-files">No files uploaded yet</p>
|
||||
{:else}
|
||||
<ul class="files-list">
|
||||
{#each uploadedFiles as file}
|
||||
<li>
|
||||
<span class="file-name">{file.original_filename}</span>
|
||||
<span class="file-size">{formatFileSize(file.file_size)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-main">
|
||||
<!-- Endpoint Selector -->
|
||||
<div class="chat-header">
|
||||
<select class="input endpoint-select" bind:value={selectedEndpoint}>
|
||||
<option value={null} disabled selected>Select an endpoint</option>
|
||||
{#each endpoints as endpoint (endpoint.id)}
|
||||
<option value={endpoint}>{endpoint.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Main chat area -->
|
||||
<div class="chat-main">
|
||||
<!-- Messages -->
|
||||
<div class="messages-container">
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading chat...</p>
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="empty-logo" />
|
||||
<h2>Start a Conversation</h2>
|
||||
<p>Send a message to begin chatting with AI</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message}
|
||||
<div class="message {message.role} {message.isError ? 'error' : ''}">
|
||||
<div class="message-header">
|
||||
<span class="message-role">
|
||||
{message.role === 'user' ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
{#if message.model}
|
||||
<span class="message-model">{message.model}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="message-content">
|
||||
{#if message.role === 'user'}
|
||||
{message.content}
|
||||
{:else}
|
||||
<div class="markdown-content">
|
||||
{@html renderMarkdown(message.content)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Messages Area -->
|
||||
<div class="messages-area">
|
||||
{#if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<h2>Start a conversation</h2>
|
||||
<p>Select an endpoint and type a message to begin</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message (message.id || Math.random())}
|
||||
<div class="message {message.role}">
|
||||
<div class="message-content">
|
||||
{#if message.role === 'user'}
|
||||
<p>{message.content}</p>
|
||||
{:else}
|
||||
<div class="markdown-content">
|
||||
{@html renderMarkdown(message.content)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- Input area -->
|
||||
<div class="input-area">
|
||||
{#if selectedFiles.length > 0}
|
||||
<div class="selected-files">
|
||||
{#each selectedFiles as file}
|
||||
<div class="selected-file-chip">
|
||||
<span>{file.original_filename}</span>
|
||||
<button onclick={() => removeSelectedFile(file)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form onsubmit={(e) => { e.preventDefault(); sendMessage(); }} class="input-form">
|
||||
<div class="input-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-icon"
|
||||
onclick={() => fileInput.click()}
|
||||
title="Upload file"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileUpload}
|
||||
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx,.csv,.json,.md"
|
||||
style="display: none"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
<textarea
|
||||
class="input message-input"
|
||||
placeholder="Type your message..."
|
||||
bind:value={inputMessage}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<input type="file" bind:this={fileInput} on:change={handleFileUpload} accept="*/*" hidden />
|
||||
<button
|
||||
class="btn btn-secondary attachment-btn"
|
||||
on:click={() => fileInput.click()}
|
||||
title="Upload file"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="input message-input"
|
||||
bind:value={newMessage}
|
||||
placeholder="Type a message..."
|
||||
on:keydown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||
disabled={!selectedEndpoint || sending}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-primary send-btn"
|
||||
on:click={sendMessage}
|
||||
disabled={!newMessage.trim() || !selectedEndpoint || sending}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-icon"
|
||||
disabled={!inputMessage.trim() || sending || endpoints.length === 0}
|
||||
>
|
||||
{#if sending}
|
||||
<div class="spinner-small"></div>
|
||||
{:else}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
@import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
.conversations-sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - 4rem);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chat-sidebar {
|
||||
width: 240px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.chat-sidebar h3 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.chat-sidebar h4 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
.no-endpoints, .no-files {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.endpoint-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--surface);
|
||||
}
|
||||
.endpoint-option:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.endpoint-select {
|
||||
max-width: 300px;
|
||||
}
|
||||
.endpoint-option input {
|
||||
margin-top: 0.25rem;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.messages-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.endpoint-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.endpoint-name {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.endpoint-model {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.files-list {
|
||||
list-style: none;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.files-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.file-size {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background-color: var(--surface);
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(pre) {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background) !important;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markdown-content :global(code) {
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.empty-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.markdown-content :global(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.empty-state h2 {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ol) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid white;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
.message {
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
.message.user {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 1.5rem;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--error);
|
||||
}
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background-color: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.message-role {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attachment-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.message-model {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--background);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
}
|
||||
.message-content {
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.message.user .message-content {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.error .message-content {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.input-area {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.selected-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.selected-file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.selected-file-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.selected-file-chip button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.625rem;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-page {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: calc(100vh - var(--header-height) - 2rem);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
order: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,158 +1,169 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if ($user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
let user = $derived(getUser());
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await api.login(email, password);
|
||||
const userData = await api.getMe();
|
||||
user.set(userData);
|
||||
goto('/chat');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await api.login(email, password);
|
||||
goto('/chat');
|
||||
} catch (e) {
|
||||
error = e.message || 'Login failed. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card card">
|
||||
<div class="login-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to your account</p>
|
||||
</div>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="auth-logo" />
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to your account to continue</p>
|
||||
</div>
|
||||
|
||||
<form on:submit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
bind:value={email}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onsubmit={handleLogin} class="auth-form">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
bind:value={password}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
placeholder="Enter your email"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
<div class="form-group">
|
||||
<label class="label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary full-width" disabled={loading}>
|
||||
{#if loading}
|
||||
Signing in...
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="demo-credentials">
|
||||
<p>Demo credentials:</p>
|
||||
<code>demo@moxiegen.com / demo123</code>
|
||||
</div>
|
||||
<div class="auth-footer">
|
||||
<p>Don't have an account? <a href="/register">Create one</a></p>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>Don't have an account? <a href="/register">Sign up</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-credentials">
|
||||
<p><strong>Demo Admin:</strong></p>
|
||||
<p>Email: admin@moxiegen.local</p>
|
||||
<p>Password: admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.auth-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.auth-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-credentials {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--background);
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.demo-credentials p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.demo-credentials code {
|
||||
font-size: 0.875rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.demo-credentials {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--background);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.demo-credentials p {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,173 +1,188 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
let email = '';
|
||||
let username = '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
let email = $state('');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if ($user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
let user = $derived(getUser());
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
async function handleRegister(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
|
||||
if (password.length < 6) {
|
||||
error = 'Password must be at least 6 characters';
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
if (password.length < 6) {
|
||||
error = 'Password must be at least 6 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.register(email, username, password);
|
||||
// Auto login after registration
|
||||
await api.login(email, password);
|
||||
const userData = await api.getMe();
|
||||
user.set(userData);
|
||||
goto('/chat');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await api.register(email, username, password);
|
||||
await api.login(email, password);
|
||||
goto('/chat');
|
||||
} catch (e) {
|
||||
error = e.message || 'Registration failed. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
<div class="register-card card">
|
||||
<div class="register-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<h1>Create Account</h1>
|
||||
<p>Get started with Moxiegen</p>
|
||||
</div>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="auth-logo" />
|
||||
<h1>Create Account</h1>
|
||||
<p>Sign up to start using Moxiegen</p>
|
||||
</div>
|
||||
|
||||
<form on:submit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
bind:value={email}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onsubmit={handleRegister} class="auth-form">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="input"
|
||||
bind:value={username}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
placeholder="Enter your email"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
bind:value={password}
|
||||
placeholder="Create a password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="input"
|
||||
placeholder="Choose a username"
|
||||
bind:value={username}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="input"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
placeholder="Create a password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
<div class="form-group">
|
||||
<label class="label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="input"
|
||||
placeholder="Confirm your password"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary full-width" disabled={loading}>
|
||||
{#if loading}
|
||||
Creating account...
|
||||
{:else}
|
||||
Create Account
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="register-footer">
|
||||
<p>Already have an account? <a href="/login">Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-footer">
|
||||
<p>Already have an account? <a href="/login">Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.register-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.register-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.register-header p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.auth-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.auth-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,9 +2,9 @@ import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@ -2,5 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user