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:
Z User 2026-03-24 03:03:21 +00:00
parent 061e230b18
commit c32a95fc91
13 changed files with 1896 additions and 1561 deletions

View File

@ -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"
}

View File

@ -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');
}
if (response.status === 204) {
return null;
}
return response.json();
}
return response.json();
}
// Auth endpoints
async login(email, password) {
const formData = new FormData();
formData.append('username', email);
formData.append('password', password);
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 response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST',
body: formData
});
async register(email, username, password) {
return this.request('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, username, password }),
});
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Login failed' }));
throw new Error(error.detail || 'Login failed');
}
async getMe() {
return this.request('/auth/me');
}
const data = await response.json();
this.setToken(data.access_token);
return data;
}
async updateMe(data) {
return this.request('/auth/me', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async register(email, username, password) {
return this.request('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, username, password })
});
}
async getEndpoints() {
return this.request('/chat/endpoints');
}
async getMe() {
return this.request('/auth/me');
}
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,
}),
});
}
// Endpoints
async getEndpoints() {
return this.request('/endpoints');
}
async getChatHistory(limit = 50) {
return this.request(`/chat/history?limit=${limit}`);
}
async createEndpoint(data) {
return this.request('/endpoints', {
method: 'POST',
body: JSON.stringify(data)
});
}
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
async updateEndpoint(id, data) {
return this.request(`/endpoints/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
const headers = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
async deleteEndpoint(id) {
return this.request(`/endpoints/${id}`, {
method: 'DELETE'
});
}
const response = await fetch(`${API_BASE}/chat/upload`, {
method: 'POST',
headers,
body: formData,
});
// Chat
async sendMessage(endpointId, message, conversationId = null) {
return this.request('/chat/message', {
method: 'POST',
body: JSON.stringify({
endpoint_id: endpointId,
message,
conversation_id: conversationId
})
});
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
throw new Error(error.detail || 'Upload failed');
}
async getConversations() {
return this.request('/chat/conversations');
}
return response.json();
}
async getConversation(id) {
return this.request(`/chat/conversations/${id}`);
}
async getFiles() {
return this.request('/chat/files');
}
async deleteConversation(id) {
return this.request(`/chat/conversations/${id}`, {
method: 'DELETE'
});
}
async deleteFile(fileId) {
return this.request(`/chat/files/${fileId}`, { method: 'DELETE' });
}
// File upload
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
async getAdminStats() {
return this.request('/admin/stats');
}
const response = await fetch(`${this.baseUrl}/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.getToken()}`
},
body: formData
});
async getUsers() {
return this.request('/admin/users');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
throw new Error(error.detail || 'Upload failed');
}
async updateUser(userId, data) {
return this.request(`/admin/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
return response.json();
}
async deleteUser(userId) {
return this.request(`/admin/users/${userId}`, { method: 'DELETE' });
}
// Admin endpoints
async getAdminStats() {
return this.request('/admin/stats');
}
async getAdminEndpoints() {
return this.request('/admin/endpoints');
}
async getUsers() {
return this.request('/admin/users');
}
async createEndpoint(data) {
return this.request('/admin/endpoints', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateUser(id, data) {
return this.request(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async updateEndpoint(endpointId, data) {
return this.request(`/admin/endpoints/${endpointId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteUser(id) {
return this.request(`/admin/users/${id}`, {
method: 'DELETE'
});
}
logout() {
this.clearToken();
}
async deleteEndpoint(endpointId) {
return this.request(`/admin/endpoints/${endpointId}`, { method: 'DELETE' });
}
}
export const api = new ApiClient();

View File

@ -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);
}

View 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';
}
}

View File

@ -1 +1,2 @@
export const ssr = false;
export const prerender = false;

View File

@ -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>

View File

@ -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

View File

@ -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)}>&times;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -2,5 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
plugins: [sveltekit()]
});