feat: Initial Moxiegen webapp implementation

- AI chat interface with file uploads
- Admin panel for managing OpenAI/Ollama endpoints
- User authentication with JWT
- SQLite database backend
- SvelteKit frontend with dark theme
This commit is contained in:
Z User 2026-03-24 00:08:54 +00:00
parent 0c5e10b714
commit 061e230b18
31 changed files with 3185 additions and 0 deletions

106
README.md Normal file
View File

@ -0,0 +1,106 @@
# Moxiegen
AI Chat Interface with OpenAI and Ollama support.
## Features
- **AI Chat Interface**: Chat with AI models from OpenAI or Ollama
- **File Uploads**: Attach files to your chat messages
- **Admin Panel**: Manage AI endpoints and users
- **Multi-Endpoint Support**: Configure multiple AI backends
- **User Authentication**: Secure login system with SQLite database
## Tech Stack
- **Backend**: Python (FastAPI)
- **Frontend**: SvelteKit
- **Database**: SQLite
- **Auth**: JWT tokens
## Quick Start
### Backend Setup
```bash
cd backend
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Run the server
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### Frontend Setup
```bash
cd frontend
# Install dependencies
npm install
# Run development server
npm run dev
```
### Access the Application
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API Docs: http://localhost:8000/docs
### Default Admin Login
- Email: `admin@moxiegen.local`
- Password: `admin123`
## Configuration
### Adding AI Endpoints
1. Login as admin
2. Go to Admin Panel → AI Endpoints
3. Click "Add Endpoint"
4. Configure:
**For OpenAI:**
- Type: OpenAI Compatible
- Base URL: `https://api.openai.com/v1`
- API Key: Your OpenAI API key
- Model: `gpt-3.5-turbo` or `gpt-4`
**For Ollama:**
- Type: Ollama
- Base URL: `http://localhost:11434`
- API Key: Leave empty
- Model: Your Ollama model name (e.g., `llama2`)
## Project Structure
```
moxiegen/
├── backend/
│ ├── app/
│ │ ├── api/ # API routes
│ │ ├── models/ # Database models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── core/ # Config, auth, database
│ │ └── main.py # FastAPI app
│ ├── uploads/ # Uploaded files
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── lib/ # Utilities, API client
│ │ ├── routes/ # SvelteKit routes
│ │ └── app.css # Global styles
│ └── package.json
└── README.md
```
## License
MIT

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# Moxiegen Backend Application

View File

@ -0,0 +1 @@
# API module

206
backend/app/api/admin.py Normal file
View File

@ -0,0 +1,206 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.auth import get_current_admin_user, get_password_hash
from app.models.models import User, AIEndpoint, ChatMessage, UploadedFile
from app.schemas.schemas import (
UserResponse,
UserUpdate,
AIEndpointCreate,
AIEndpointUpdate,
AIEndpointResponse,
AdminStats
)
router = APIRouter(prefix="/admin", tags=["Admin"])
# User Management
@router.get("/users", response_model=List[UserResponse])
def list_users(
skip: int = 0,
limit: int = 100,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/users/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.put("/users/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_data: UserUpdate,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_data.email:
existing_user = db.query(User).filter(
User.email == user_data.email,
User.id != user_id
).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
user.email = user_data.email
if user_data.username:
existing_user = db.query(User).filter(
User.username == user_data.username,
User.id != user_id
).first()
if existing_user:
raise HTTPException(status_code=400, detail="Username already taken")
user.username = user_data.username
if user_data.password:
user.hashed_password = get_password_hash(user_data.password)
if user_data.role:
user.role = user_data.role
if user_data.is_active is not None:
user.is_active = user_data.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}
# AI Endpoint Management
@router.get("/endpoints", response_model=List[AIEndpointResponse])
def list_endpoints(
skip: int = 0,
limit: int = 100,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
endpoints = db.query(AIEndpoint).offset(skip).limit(limit).all()
return endpoints
@router.post("/endpoints", response_model=AIEndpointResponse)
def create_endpoint(
endpoint_data: AIEndpointCreate,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
# If this is set as default, unset other defaults
if endpoint_data.is_default:
db.query(AIEndpoint).filter(AIEndpoint.is_default == True).update({"is_default": False})
new_endpoint = AIEndpoint(**endpoint_data.model_dump())
db.add(new_endpoint)
db.commit()
db.refresh(new_endpoint)
return new_endpoint
@router.get("/endpoints/{endpoint_id}", response_model=AIEndpointResponse)
def get_endpoint(
endpoint_id: int,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
endpoint = db.query(AIEndpoint).filter(AIEndpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(status_code=404, detail="Endpoint not found")
return endpoint
@router.put("/endpoints/{endpoint_id}", response_model=AIEndpointResponse)
def update_endpoint(
endpoint_id: int,
endpoint_data: AIEndpointUpdate,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
endpoint = db.query(AIEndpoint).filter(AIEndpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(status_code=404, detail="Endpoint not found")
update_data = endpoint_data.model_dump(exclude_unset=True)
# If setting as default, unset other defaults
if update_data.get("is_default"):
db.query(AIEndpoint).filter(
AIEndpoint.is_default == True,
AIEndpoint.id != endpoint_id
).update({"is_default": False})
for key, value in update_data.items():
setattr(endpoint, key, value)
db.commit()
db.refresh(endpoint)
return endpoint
@router.delete("/endpoints/{endpoint_id}")
def delete_endpoint(
endpoint_id: int,
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
endpoint = db.query(AIEndpoint).filter(AIEndpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(status_code=404, detail="Endpoint not found")
db.delete(endpoint)
db.commit()
return {"message": "Endpoint deleted successfully"}
# Admin Statistics
@router.get("/stats", response_model=AdminStats)
def get_stats(
current_user = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
total_users = db.query(User).count()
total_endpoints = db.query(AIEndpoint).count()
total_messages = db.query(ChatMessage).count()
total_files = db.query(UploadedFile).count()
active_users = db.query(User).filter(User.is_active == True).count()
return AdminStats(
total_users=total_users,
total_endpoints=total_endpoints,
total_messages=total_messages,
total_files=total_files,
active_users=active_users
)

128
backend/app/api/auth.py Normal file
View File

@ -0,0 +1,128 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.auth import (
verify_password,
get_password_hash,
create_access_token,
get_current_user
)
from app.core.config import settings
from app.models.models import User
from app.schemas.schemas import (
UserCreate,
UserResponse,
UserUpdate,
Token
)
router = APIRouter(prefix="/auth", tags=["Authentication"])
@router.post("/register", response_model=UserResponse)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
# Check if email already exists
db_user = db.query(User).filter(User.email == user_data.email).first()
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Check if username already exists
db_user = db.query(User).filter(User.username == user_data.username).first()
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
new_user = User(
email=user_data.email,
username=user_data.username,
hashed_password=hashed_password,
role="user",
is_active=True
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/login", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
# Find user by username or email
user = db.query(User).filter(
(User.username == form_data.username) | (User.email == form_data.username)
).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.id},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=UserResponse)
def update_me(
user_data: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if user_data.email:
existing_user = db.query(User).filter(
User.email == user_data.email,
User.id != current_user.id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
current_user.email = user_data.email
if user_data.username:
existing_user = db.query(User).filter(
User.username == user_data.username,
User.id != current_user.id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
current_user.username = user_data.username
if user_data.password:
current_user.hashed_password = get_password_hash(user_data.password)
db.commit()
db.refresh(current_user)
return current_user

231
backend/app/api/chat.py Normal file
View File

@ -0,0 +1,231 @@
import os
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
import httpx
from openai import AsyncOpenAI
from app.core.database import get_db
from app.core.auth import get_current_user
from app.core.config import settings
from app.models.models import User, AIEndpoint, ChatMessage, UploadedFile
from app.schemas.schemas import (
ChatRequest,
ChatResponse,
ChatMessageResponse,
UploadedFileResponse
)
router = APIRouter(prefix="/chat", tags=["Chat"])
# Ensure upload directory exists
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
@router.post("/upload", response_model=UploadedFileResponse)
async def upload_file(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Check file size
contents = await file.read()
if len(contents) > settings.MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"File size exceeds maximum allowed size of {settings.MAX_FILE_SIZE} bytes"
)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(settings.UPLOAD_DIR, unique_filename)
# Save file
with open(file_path, "wb") as f:
f.write(contents)
# Create database record
uploaded_file = UploadedFile(
user_id=current_user.id,
filename=unique_filename,
original_filename=file.filename,
file_path=file_path,
file_size=len(contents),
file_type=file.content_type
)
db.add(uploaded_file)
db.commit()
db.refresh(uploaded_file)
return uploaded_file
@router.get("/files", response_model=List[UploadedFileResponse])
def list_files(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
files = db.query(UploadedFile).filter(UploadedFile.user_id == current_user.id).all()
return files
@router.delete("/files/{file_id}")
def delete_file(
file_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
uploaded_file = db.query(UploadedFile).filter(
UploadedFile.id == file_id,
UploadedFile.user_id == current_user.id
).first()
if not uploaded_file:
raise HTTPException(status_code=404, detail="File not found")
# Delete file from filesystem
if os.path.exists(uploaded_file.file_path):
os.remove(uploaded_file.file_path)
db.delete(uploaded_file)
db.commit()
return {"message": "File deleted successfully"}
@router.post("/message", response_model=ChatResponse)
async def send_message(
request: ChatRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Get the AI endpoint
endpoint = None
if request.endpoint_id:
endpoint = db.query(AIEndpoint).filter(
AIEndpoint.id == request.endpoint_id,
AIEndpoint.is_active == True
).first()
else:
# Get default endpoint
endpoint = db.query(AIEndpoint).filter(
AIEndpoint.is_default == True,
AIEndpoint.is_active == True
).first()
if not endpoint:
raise HTTPException(
status_code=400,
detail="No active AI endpoint available"
)
# Save user message
user_message = ChatMessage(
user_id=current_user.id,
role="user",
content=request.message,
endpoint_id=endpoint.id
)
db.add(user_message)
db.commit()
# Build messages for API call
messages = []
if request.conversation_history:
for msg in request.conversation_history:
messages.append({"role": msg.role, "content": msg.content})
messages.append({"role": "user", "content": request.message})
# Call AI endpoint
try:
response_content = await call_ai_endpoint(endpoint, messages)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error calling AI endpoint: {str(e)}"
)
# Save assistant message
assistant_message = ChatMessage(
user_id=current_user.id,
role="assistant",
content=response_content,
endpoint_id=endpoint.id
)
db.add(assistant_message)
db.commit()
return ChatResponse(
response=response_content,
endpoint_id=endpoint.id,
model=endpoint.model_name
)
async def call_ai_endpoint(endpoint: AIEndpoint, messages: List[dict]) -> str:
if endpoint.endpoint_type == "openai":
return await call_openai_compatible(endpoint, messages)
elif endpoint.endpoint_type == "ollama":
return await call_ollama(endpoint, messages)
else:
raise ValueError(f"Unknown endpoint type: {endpoint.endpoint_type}")
async def call_openai_compatible(endpoint: AIEndpoint, messages: List[dict]) -> str:
"""Call OpenAI-compatible API (works with OpenAI, local AI servers, etc.)"""
client = AsyncOpenAI(
api_key=endpoint.api_key or "not-needed",
base_url=endpoint.base_url
)
response = await client.chat.completions.create(
model=endpoint.model_name,
messages=messages
)
return response.choices[0].message.content
async def call_ollama(endpoint: AIEndpoint, messages: List[dict]) -> str:
"""Call Ollama API"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{endpoint.base_url.rstrip('/')}/api/chat",
json={
"model": endpoint.model_name,
"messages": messages,
"stream": False
},
timeout=60.0
)
response.raise_for_status()
data = response.json()
return data["message"]["content"]
@router.get("/history", response_model=List[ChatMessageResponse])
def get_history(
limit: int = 50,
endpoint_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(ChatMessage).filter(ChatMessage.user_id == current_user.id)
if endpoint_id:
query = query.filter(ChatMessage.endpoint_id == endpoint_id)
messages = query.order_by(ChatMessage.created_at.desc()).limit(limit).all()
return list(reversed(messages))
@router.delete("/history")
def clear_history(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
db.query(ChatMessage).filter(ChatMessage.user_id == current_user.id).delete()
db.commit()
return {"message": "Chat history cleared successfully"}

View File

@ -0,0 +1 @@
# Core module

79
backend/app/core/auth.py Normal file
View File

@ -0,0 +1,79 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
):
from app.models.models import User
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if payload is None:
raise credentials_exception
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
async def get_current_admin_user(
current_user = Depends(get_current_user)
):
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

View File

@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
SECRET_KEY: str = "your-secret-key-change-in-production"
DATABASE_URL: str = "sqlite:///./moxiegen.db"
UPLOAD_DIR: str = "./uploads"
MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 10MB
DEFAULT_ADMIN_EMAIL: str = "admin@moxiegen.com"
DEFAULT_ADMIN_PASSWORD: str = "admin123"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
settings = Settings()

View File

@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

98
backend/app/main.py Normal file
View File

@ -0,0 +1,98 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import engine, Base, SessionLocal
from app.core.auth import get_password_hash
from app.models.models import User, AIEndpoint
from app.api import auth, admin, chat
# Create database tables
def init_db():
Base.metadata.create_all(bind=engine)
# Create default admin user and default AI endpoint
def create_defaults():
db = SessionLocal()
try:
# Check if admin exists
admin = db.query(User).filter(User.email == settings.DEFAULT_ADMIN_EMAIL).first()
if not admin:
admin = User(
email=settings.DEFAULT_ADMIN_EMAIL,
username="admin",
hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
role="admin",
is_active=True
)
db.add(admin)
print(f"Created default admin user: {settings.DEFAULT_ADMIN_EMAIL}")
# Check if default endpoint exists
default_endpoint = db.query(AIEndpoint).filter(AIEndpoint.is_default == True).first()
if not default_endpoint:
# Create a default Ollama endpoint
default_endpoint = AIEndpoint(
name="Default Ollama",
endpoint_type="ollama",
base_url="http://localhost:11434",
model_name="llama2",
is_active=True,
is_default=True
)
db.add(default_endpoint)
print("Created default Ollama endpoint")
db.commit()
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
init_db()
create_defaults()
# Ensure upload directory exists
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
yield
# Shutdown (if needed)
pass
app = FastAPI(
title="Moxiegen API",
description="Backend API for Moxiegen application",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure this properly in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(chat.router, prefix="/api")
@app.get("/")
def root():
return {"message": "Welcome to Moxiegen API", "version": "1.0.0"}
@app.get("/health")
def health_check():
return {"status": "healthy"}

View File

@ -0,0 +1 @@
# Models module

View File

@ -0,0 +1,64 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, default="user") # "user" or "admin"
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
chat_messages = relationship("ChatMessage", back_populates="user")
uploaded_files = relationship("UploadedFile", back_populates="user")
class AIEndpoint(Base):
__tablename__ = "ai_endpoints"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
endpoint_type = Column(String, nullable=False) # "openai" or "ollama"
base_url = Column(String, nullable=False)
api_key = Column(String, nullable=True) # Optional for Ollama
model_name = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(String, nullable=False) # "user" or "assistant"
content = Column(Text, nullable=False)
endpoint_id = Column(Integer, ForeignKey("ai_endpoints.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="chat_messages")
endpoint = relationship("AIEndpoint")
class UploadedFile(Base):
__tablename__ = "uploaded_files"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
filename = Column(String, nullable=False)
original_filename = Column(String, nullable=False)
file_path = Column(String, nullable=False)
file_size = Column(Integer, nullable=False)
file_type = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="uploaded_files")

View File

@ -0,0 +1 @@
# Schemas module

View File

@ -0,0 +1,134 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, EmailStr
# User schemas
class UserBase(BaseModel):
email: EmailStr
username: str
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
password: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
class UserResponse(UserBase):
id: int
role: str
is_active: bool
created_at: datetime
class Config:
from_attributes = True
# Token schemas
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[int] = None
# AIEndpoint schemas
class AIEndpointBase(BaseModel):
name: str
endpoint_type: str # "openai" or "ollama"
base_url: str
api_key: Optional[str] = None
model_name: str
is_active: bool = True
is_default: bool = False
class AIEndpointCreate(AIEndpointBase):
pass
class AIEndpointUpdate(BaseModel):
name: Optional[str] = None
endpoint_type: Optional[str] = None
base_url: Optional[str] = None
api_key: Optional[str] = None
model_name: Optional[str] = None
is_active: Optional[bool] = None
is_default: Optional[bool] = None
class AIEndpointResponse(AIEndpointBase):
id: int
created_at: datetime
class Config:
from_attributes = True
# ChatMessage schemas
class ChatMessageBase(BaseModel):
role: str
content: str
class ChatMessageCreate(ChatMessageBase):
endpoint_id: Optional[int] = None
class ChatMessageResponse(ChatMessageBase):
id: int
user_id: int
endpoint_id: Optional[int]
created_at: datetime
class Config:
from_attributes = True
# UploadedFile schemas
class UploadedFileBase(BaseModel):
original_filename: str
file_type: Optional[str] = None
class UploadedFileResponse(UploadedFileBase):
id: int
user_id: int
filename: str
file_path: str
file_size: int
created_at: datetime
class Config:
from_attributes = True
# Chat schemas
class ChatRequest(BaseModel):
message: str
endpoint_id: Optional[int] = None
conversation_history: Optional[List[ChatMessageBase]] = None
class ChatResponse(BaseModel):
response: str
endpoint_id: Optional[int] = None
model: Optional[str] = None
# AdminStats schema
class AdminStats(BaseModel):
total_users: int
total_endpoints: int
total_messages: int
total_files: int
active_users: int

10
backend/requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi
uvicorn
sqlalchemy
pydantic
python-jose
passlib
python-multipart
aiofiles
httpx
openai

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "moxiegen-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"svelte": "^4.2.0",
"vite": "^5.0.0"
},
"dependencies": {
"marked": "^12.0.0",
"highlight.js": "^11.9.0"
},
"type": "module"
}

233
frontend/src/app.css Normal file
View File

@ -0,0 +1,233 @@
/* CSS Variables - Dark Theme */
:root {
--primary: #6366f1;
--primary-hover: #818cf8;
--background: #0f172a;
--surface: #1e293b;
--surface-hover: #334155;
--text: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--error: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
}
/* Reset & Base */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background);
color: var(--text);
line-height: 1.5;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
/* Utility Classes */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background-color: var(--surface-hover);
}
.btn-danger {
background-color: var(--error);
color: white;
}
.btn-danger:hover {
background-color: #dc2626;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input {
width: 100%;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text);
transition: border-color 0.15s ease;
}
.input:focus {
outline: none;
border-color: var(--primary);
}
.input::placeholder {
color: var(--text-muted);
}
.card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
}
.error-message {
color: var(--error);
font-size: 0.875rem;
margin-top: 0.5rem;
}
.success-message {
color: var(--success);
font-size: 0.875rem;
margin-top: 0.5rem;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface-hover);
}
/* Code blocks */
pre {
background-color: var(--background);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
font-size: 0.875rem;
}
code {
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.15s ease;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
font-weight: 600;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table tbody tr:hover {
background-color: var(--background);
}

12
frontend/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

189
frontend/src/lib/api.js Normal file
View File

@ -0,0 +1,189 @@
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
class ApiClient {
constructor() {
this.baseUrl = API_BASE;
}
getToken() {
if (typeof window !== 'undefined') {
return localStorage.getItem('token');
}
return null;
}
setToken(token) {
if (typeof window !== 'undefined') {
localStorage.setItem('token', token);
}
}
clearToken() {
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
}
}
getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
const token = this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
...options,
headers: {
...this.getHeaders(),
...options.headers
}
};
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');
}
return response.json();
}
// 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
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Login failed' }));
throw new Error(error.detail || 'Login failed');
}
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 getMe() {
return this.request('/auth/me');
}
// Endpoints
async getEndpoints() {
return this.request('/endpoints');
}
async createEndpoint(data) {
return this.request('/endpoints', {
method: 'POST',
body: JSON.stringify(data)
});
}
async updateEndpoint(id, data) {
return this.request(`/endpoints/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async deleteEndpoint(id) {
return this.request(`/endpoints/${id}`, {
method: 'DELETE'
});
}
// Chat
async sendMessage(endpointId, message, conversationId = null) {
return this.request('/chat/message', {
method: 'POST',
body: JSON.stringify({
endpoint_id: endpointId,
message,
conversation_id: conversationId
})
});
}
async getConversations() {
return this.request('/chat/conversations');
}
async getConversation(id) {
return this.request(`/chat/conversations/${id}`);
}
async deleteConversation(id) {
return this.request(`/chat/conversations/${id}`, {
method: 'DELETE'
});
}
// File upload
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${this.baseUrl}/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.getToken()}`
},
body: formData
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
throw new Error(error.detail || 'Upload failed');
}
return response.json();
}
// Admin endpoints
async getAdminStats() {
return this.request('/admin/stats');
}
async getUsers() {
return this.request('/admin/users');
}
async updateUser(id, data) {
return this.request(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async deleteUser(id) {
return this.request(`/admin/users/${id}`, {
method: 'DELETE'
});
}
logout() {
this.clearToken();
}
}
export const api = new ApiClient();

View File

@ -0,0 +1,28 @@
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 @@
export const ssr = false;

View File

@ -0,0 +1,233 @@
<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';
onMount(() => {
initializeAuth();
});
$: currentPath = $page.url.pathname;
$: isAuthPage = currentPath === '/login' || currentPath === '/register';
function handleLogout() {
logout();
goto('/login');
}
</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}
{: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>
{/if}
<style>
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background-color: 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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.app-layout {
display: flex;
min-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;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.logo {
width: 36px;
height: 36px;
border-radius: 8px;
object-fit: cover;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
}
.sidebar-nav {
flex: 1;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.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;
}
.nav-item:hover {
background-color: var(--background);
color: var(--text);
}
.nav-item.active {
background-color: var(--primary);
color: white;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.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;
}
.user-details {
display: flex;
flex-direction: column;
}
.user-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.user-email {
font-size: 0.75rem;
color: var(--text-muted);
}
.logout-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.15s ease;
}
.logout-btn:hover {
background-color: var(--background);
color: var(--error);
}
.main-content {
flex: 1;
margin-left: 260px;
min-height: 100vh;
}
</style>

View File

@ -0,0 +1,43 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
onMount(() => {
if ($user) {
goto('/chat');
} else {
goto('/login');
}
});
</script>
<div class="redirect-page">
<div class="spinner"></div>
<p>Redirecting...</p>
</div>
<style>
.redirect-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,557 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { user } from '$lib/stores';
import { goto } from '$app/navigation';
let activeTab = 'dashboard';
let stats = null;
let users = [];
let endpoints = [];
let loading = true;
let error = '';
// User editing
let editingUser = null;
let editUserData = {};
// Endpoint editing
let editingEndpoint = null;
let endpointData = {};
let showEndpointModal = false;
onMount(async () => {
if (!$user?.is_admin) {
goto('/chat');
return;
}
await loadData();
});
async function loadData() {
loading = true;
try {
[stats, users, endpoints] = await Promise.all([
api.getAdminStats(),
api.getUsers(),
api.getEndpoints()
]);
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
function startEditUser(user) {
editingUser = user.id;
editUserData = { ...user };
}
function cancelEditUser() {
editingUser = null;
editUserData = {};
}
async function saveUser() {
try {
await api.updateUser(editingUser, editUserData);
users = users.map(u => u.id === editingUser ? { ...editUserData } : u);
editingUser = null;
editUserData = {};
} catch (err) {
error = err.message;
}
}
async function deleteUser(user) {
if (!confirm(`Delete user "${user.username}"?`)) return;
try {
await api.deleteUser(user.id);
users = users.filter(u => u.id !== user.id);
} catch (err) {
error = err.message;
}
}
function openEndpointModal(endpoint = null) {
editingEndpoint = endpoint?.id || null;
endpointData = endpoint ? { ...endpoint } : { name: '', url: '', api_key: '' };
showEndpointModal = true;
}
function closeEndpointModal() {
showEndpointModal = false;
editingEndpoint = null;
endpointData = {};
}
async function saveEndpoint() {
try {
if (editingEndpoint) {
await api.updateEndpoint(editingEndpoint, endpointData);
endpoints = endpoints.map(e => e.id === editingEndpoint ? { ...endpointData } : e);
} else {
const newEndpoint = await api.createEndpoint(endpointData);
endpoints = [...endpoints, newEndpoint];
}
closeEndpointModal();
} catch (err) {
error = err.message;
}
}
async function deleteEndpoint(endpoint) {
if (!confirm(`Delete endpoint "${endpoint.name}"?`)) return;
try {
await api.deleteEndpoint(endpoint.id);
endpoints = endpoints.filter(e => e.id !== endpoint.id);
} catch (err) {
error = err.message;
}
}
</script>
<div class="admin-page">
<div class="admin-header">
<h1>Admin Panel</h1>
</div>
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'dashboard'}
on:click={() => activeTab = 'dashboard'}
>
Dashboard
</button>
<button
class="tab"
class:active={activeTab === 'users'}
on:click={() => activeTab = 'users'}
>
Users
</button>
<button
class="tab"
class:active={activeTab === 'endpoints'}
on:click={() => activeTab = 'endpoints'}
>
Endpoints
</button>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else}
<!-- Dashboard Tab -->
{#if activeTab === 'dashboard'}
<div class="dashboard">
<div class="stats-grid">
<div class="stat-card card">
<div class="stat-icon users">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{stats?.total_users || 0}</span>
<span class="stat-label">Total Users</span>
</div>
</div>
<div class="stat-card card">
<div class="stat-icon endpoints">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{stats?.total_endpoints || 0}</span>
<span class="stat-label">Endpoints</span>
</div>
</div>
<div class="stat-card card">
<div class="stat-icon messages">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>
</div>
<div class="stat-info">
<span class="stat-value">{stats?.total_messages || 0}</span>
<span class="stat-label">Messages</span>
</div>
</div>
<div class="stat-card card">
<div class="stat-icon active">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{stats?.active_users || 0}</span>
<span class="stat-label">Active Users</span>
</div>
</div>
</div>
</div>
{/if}
<!-- Users Tab -->
{#if activeTab === 'users'}
<div class="users-tab">
<div class="card">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr>
<td>{user.id}</td>
<td>
{#if editingUser === user.id}
<input type="text" class="input" bind:value={editUserData.username} />
{:else}
{user.username}
{/if}
</td>
<td>
{#if editingUser === user.id}
<input type="email" class="input" bind:value={editUserData.email} />
{:else}
{user.email}
{/if}
</td>
<td>
{#if editingUser === user.id}
<input type="checkbox" bind:checked={editUserData.is_admin} />
{:else}
{#if user.is_admin}
<span class="badge">Admin</span>
{/if}
{/if}
</td>
<td>{new Date(user.created_at).toLocaleDateString()}</td>
<td>
<div class="action-buttons">
{#if editingUser === user.id}
<button class="btn btn-primary btn-sm" on:click={saveUser}>Save</button>
<button class="btn btn-secondary btn-sm" on:click={cancelEditUser}>Cancel</button>
{:else}
<button class="btn btn-secondary btn-sm" on:click={() => startEditUser(user)}>Edit</button>
<button class="btn btn-danger btn-sm" on:click={() => deleteUser(user)}>Delete</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Endpoints Tab -->
{#if activeTab === 'endpoints'}
<div class="endpoints-tab">
<div class="tab-header">
<button class="btn btn-primary" on:click={() => openEndpointModal()}>
<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>
Add Endpoint
</button>
</div>
<div class="endpoints-grid">
{#each endpoints as endpoint (endpoint.id)}
<div class="endpoint-card card">
<div class="endpoint-header">
<h3>{endpoint.name}</h3>
<span class="endpoint-status {endpoint.is_active ? 'active' : 'inactive'}">
{endpoint.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div class="endpoint-details">
<p><strong>URL:</strong> {endpoint.url}</p>
<p><strong>Model:</strong> {endpoint.model || 'Default'}</p>
</div>
<div class="endpoint-actions">
<button class="btn btn-secondary btn-sm" on:click={() => openEndpointModal(endpoint)}>Edit</button>
<button class="btn btn-danger btn-sm" on:click={() => deleteEndpoint(endpoint)}>Delete</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
</div>
{#if showEndpointModal}
<div class="modal-overlay" on:click={closeEndpointModal}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<h2>{editingEndpoint ? 'Edit Endpoint' : 'Add Endpoint'}</h2>
<button class="modal-close" on:click={closeEndpointModal}>&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" class="input" bind:value={endpointData.name} placeholder="Endpoint name" />
</div>
<div class="form-group">
<label for="url">URL</label>
<input type="text" id="url" class="input" bind:value={endpointData.url} placeholder="https://api.example.com/v1" />
</div>
<div class="form-group">
<label for="apiKey">API Key</label>
<input type="password" id="apiKey" class="input" bind:value={endpointData.api_key} placeholder="Enter API key" />
</div>
<div class="form-group">
<label for="model">Model</label>
<input type="text" id="model" class="input" bind:value={endpointData.model} placeholder="gpt-4" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" on:click={closeEndpointModal}>Cancel</button>
<button class="btn btn-primary" on:click={saveEndpoint}>Save</button>
</div>
</div>
</div>
{/if}
<style>
.admin-page {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.admin-header {
margin-bottom: 1.5rem;
}
.admin-header h1 {
font-size: 1.75rem;
}
.loading {
display: flex;
justify-content: center;
padding: 3rem;
}
.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); }
}
/* Dashboard */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.users { background-color: rgba(99, 102, 241, 0.2); color: var(--primary); }
.stat-icon.endpoints { background-color: rgba(34, 197, 94, 0.2); color: var(--success); }
.stat-icon.messages { background-color: rgba(245, 158, 11, 0.2); color: var(--warning); }
.stat-icon.active { background-color: rgba(99, 102, 241, 0.2); color: var(--primary); }
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
}
.stat-label {
color: var(--text-muted);
font-size: 0.875rem;
}
/* Users */
.users-tab .card {
overflow-x: auto;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: var(--primary);
color: white;
font-size: 0.75rem;
border-radius: 0.25rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
/* Endpoints */
.endpoints-tab .tab-header {
margin-bottom: 1.5rem;
}
.endpoints-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.endpoint-card {
display: flex;
flex-direction: column;
}
.endpoint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.endpoint-header h3 {
font-size: 1.125rem;
}
.endpoint-status {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.endpoint-status.active {
background-color: rgba(34, 197, 94, 0.2);
color: var(--success);
}
.endpoint-status.inactive {
background-color: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.endpoint-details {
flex: 1;
margin-bottom: 1rem;
}
.endpoint-details p {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.endpoint-actions {
display: flex;
gap: 0.5rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: var(--surface);
border-radius: 0.75rem;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1.125rem;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
</style>

View File

@ -0,0 +1,420 @@
<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';
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;
// 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;
}
});
onMount(async () => {
await loadData();
});
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;
}
}
async function selectConversation(conv) {
currentConversation = conv;
messages = conv.messages || [];
}
function newChat() {
currentConversation = null;
messages = [];
}
async function sendMessage() {
if (!newMessage.trim() || !selectedEndpoint) return;
sending = true;
const userMessage = newMessage;
newMessage = '';
// Add user message to UI immediately
messages = [...messages, { role: 'user', content: userMessage }];
try {
const response = await api.sendMessage(
selectedEndpoint.id,
userMessage,
currentConversation?.id
);
// Add assistant response
messages = [...messages, { role: 'assistant', content: response.response }];
// 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;
}
}
async function handleFileUpload(e) {
const file = e.target.files[0];
if (!file) return;
try {
const result = await api.uploadFile(file);
uploadedFile = result;
newMessage += `\n[File: ${file.name}]`;
} catch (err) {
error = err.message;
}
}
function renderMarkdown(content) {
return marked(content);
}
</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>
<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>
<!-- 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>
<!-- 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}
{#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>
{#if error}
<div class="error-message">{error}</div>
{/if}
<!-- 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>
</div>
<style>
.chat-page {
display: flex;
height: 100vh;
}
.conversations-sidebar {
width: 260px;
background-color: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.conversations-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.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;
}
.conversation-item:hover {
background-color: var(--background);
color: var(--text);
}
.conversation-item.active {
background-color: var(--primary);
color: white;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
background-color: var(--surface);
}
.endpoint-select {
max-width: 300px;
}
.messages-area {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.logo {
width: 80px;
height: 80px;
border-radius: 16px;
margin-bottom: 1.5rem;
}
.empty-state h2 {
margin-bottom: 0.5rem;
color: var(--text);
}
.message {
display: flex;
margin-bottom: 1rem;
}
.message.user {
justify-content: flex-end;
}
.message-content {
max-width: 80%;
padding: 1rem 1.25rem;
border-radius: 1rem;
}
.message.user .message-content {
background-color: var(--primary);
color: white;
border-bottom-right-radius: 0.25rem;
}
.message.assistant .message-content {
background-color: var(--surface);
border-bottom-left-radius: 0.25rem;
}
.markdown-content :global(pre) {
margin: 0.5rem 0;
padding: 0.75rem;
background-color: var(--background) !important;
border-radius: 0.5rem;
overflow-x: auto;
}
.markdown-content :global(code) {
font-family: 'Fira Code', monospace;
font-size: 0.875rem;
}
.markdown-content :global(p) {
margin: 0.5rem 0;
}
.markdown-content :global(ul),
.markdown-content :global(ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.typing-indicator {
display: flex;
gap: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background-color: var(--text-muted);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.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);
}
.input-area {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem;
background-color: var(--surface);
border-top: 1px solid var(--border);
}
.attachment-btn {
flex-shrink: 0;
}
.message-input {
flex: 1;
}
.send-btn {
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,158 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { user } from '$lib/stores';
let email = '';
let password = '';
let error = '';
let loading = false;
onMount(() => {
if ($user) {
goto('/chat');
}
});
async function handleSubmit(e) {
e.preventDefault();
error = '';
loading = true;
try {
await api.login(email, password);
const userData = await api.getMe();
user.set(userData);
goto('/chat');
} catch (err) {
error = err.message;
} 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>
<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>
<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>
{#if error}
<div class="error-message">{error}</div>
{/if}
<button type="submit" class="btn btn-primary full-width" disabled={loading}>
{#if loading}
Signing in...
{:else}
Sign In
{/if}
</button>
</form>
<div class="demo-credentials">
<p>Demo credentials:</p>
<code>demo@moxiegen.com / demo123</code>
</div>
<div class="login-footer">
<p>Don't have an account? <a href="/register">Sign up</a></p>
</div>
</div>
</div>
<style>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
width: 64px;
height: 64px;
border-radius: 12px;
margin-bottom: 1rem;
}
.login-header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.login-header p {
color: var(--text-muted);
}
.full-width {
width: 100%;
margin-top: 1rem;
}
.demo-credentials {
margin-top: 1.5rem;
padding: 1rem;
background-color: var(--background);
border-radius: 0.5rem;
text-align: center;
}
.demo-credentials p {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.demo-credentials code {
font-size: 0.875rem;
color: var(--primary);
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
</style>

View File

@ -0,0 +1,173 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { user } from '$lib/stores';
let email = '';
let username = '';
let password = '';
let confirmPassword = '';
let error = '';
let loading = false;
onMount(() => {
if ($user) {
goto('/chat');
}
});
async function handleSubmit(e) {
e.preventDefault();
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
loading = true;
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;
}
}
</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>
<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>
<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 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 for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
class="input"
bind:value={confirmPassword}
placeholder="Confirm your password"
required
/>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
<button type="submit" class="btn btn-primary full-width" disabled={loading}>
{#if loading}
Creating account...
{:else}
Create Account
{/if}
</button>
</form>
<div class="register-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;
}
.register-card {
width: 100%;
max-width: 400px;
}
.register-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
width: 64px;
height: 64px;
border-radius: 12px;
margin-bottom: 1rem;
}
.register-header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.register-header p {
color: var(--text-muted);
}
.full-width {
width: 100%;
margin-top: 1rem;
}
.register-footer {
margin-top: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
</style>

BIN
frontend/static/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

10
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;

6
frontend/vite.config.js Normal file
View File

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