Commit 99edb79c authored by Mahmoud Aglan's avatar Mahmoud Aglan

cool on mobile

parent c0e521e8
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -3,10 +3,11 @@ Son of Anton — Main FastAPI Application
"""
import os
import time
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
......@@ -21,6 +22,9 @@ from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router
from backend.services.bedrock_service import close_http_client
APP_VERSION = "2.1.0"
APP_BUILD_TIME = str(int(time.time()))
def _run_migrations():
"""Add new columns/tables to existing DB if they're missing."""
......@@ -54,7 +58,7 @@ async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
_run_migrations()
seed_superadmin()
print("Son of Anton is online.")
print(f"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online.")
yield
await close_http_client()
print("Son of Anton shutting down.")
......@@ -63,7 +67,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Son of Anton",
description="Avatar of All Elements of Code",
version="2.0.0",
version=APP_VERSION,
lifespan=lifespan,
)
......@@ -75,6 +79,38 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
response: Response = await call_next(request)
path = request.url.path
# API responses: never cache
if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Hashed assets (contain hash in filename): cache aggressively
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# HTML and everything else: never cache
elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Always add version header for debugging
response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME
return response
# Version endpoint for frontend to check
@app.get("/api/version")
def get_version():
return {"version": APP_VERSION, "build": APP_BUILD_TIME}
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(chat_router, prefix="/api/chats", tags=["Chats"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
......@@ -98,8 +134,15 @@ async def serve_frontend(full_path: str):
raise HTTPException(status_code=404, detail="Not found")
file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file():
return FileResponse(str(file_path))
resp = FileResponse(str(file_path))
# Don't cache non-hashed static files
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp
index = FRONTEND_DIR / "index.html"
if index.is_file():
return FileResponse(str(index))
return {"message": "Son of Anton API is running. Frontend not built."}
resp = FileResponse(str(index))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Son of Anton</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>" />
</head>
<body class="bg-anton-bg text-anton-text font-sans">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Son of Anton</title>
<!-- KILL BROWSER CACHE FOR THIS HTML -->
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- PWA / Mobile -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090f" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" />
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>" />
</head>
<body class="bg-anton-bg text-anton-text font-sans overscroll-none">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
\ No newline at end of file
......@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { Routes, Route } from "react-router-dom";
import { useApp } from "./store";
import { getMe } from "./api";
import * as streamManager from "./streamManager";
import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage";
......@@ -11,6 +12,11 @@ export default function App() {
const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token);
// Connect streamManager to store dispatch
useEffect(() => {
streamManager.setDispatch(dispatch);
}, [dispatch]);
useEffect(() => {
if (!state.token) {
setAuthChecked(true);
......@@ -34,7 +40,7 @@ export default function App() {
if (!authChecked) {
return (
<div className="h-full flex items-center justify-center bg-anton-bg">
<div className="h-dvh flex items-center justify-center bg-anton-bg">
<div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={32} className="text-white animate-pulse" />
......
......@@ -15,13 +15,6 @@ const FILE_TYPE_ICONS = {
text: FileCode,
};
const FILE_TYPE_BADGE_COLORS = {
image: "bg-blue-500/20 text-blue-400",
video: "bg-purple-500/20 text-purple-400",
document: "bg-amber-500/20 text-amber-400",
text: "bg-green-500/20 text-green-400",
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user";
......@@ -38,16 +31,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const hasAttachments = attachments && attachments.length > 0;
return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
<div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" />
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={14} className="text-white sm:w-4 sm:h-4" />
</div>
</div>
)}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
<div className={`max-w-[90%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking block */}
{thinking_content && (
<div className="mb-2">
......@@ -60,7 +53,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button>
{(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto">
{thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div>
......@@ -70,10 +63,9 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{/* Attachments */}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
<div className="mb-2 flex flex-wrap gap-1.5 sm:gap-2">
{attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || File;
const badgeColor = FILE_TYPE_BADGE_COLORS[att.file_type] || "bg-anton-border text-anton-muted";
const url = getAttachmentUrl(att.id);
if (att.file_type === "image") {
......@@ -82,101 +74,58 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-[280px] max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg"
className="max-w-[200px] sm:max-w-[280px] max-h-[160px] sm:max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => {
e.target.style.display = "none";
e.target.nextSibling && (e.target.nextSibling.style.display = "flex");
}}
onError={(e) => { e.target.style.display = "none"; }}
/>
{/* Fallback if image fails */}
<div className="hidden items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2">
<Image size={16} className="text-blue-400" />
<span className="text-xs text-white">{att.original_filename}</span>
</div>
{expandedImage === att.id && (
<div
className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-8 cursor-pointer"
className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4 sm:p-8 cursor-pointer"
onClick={() => setExpandedImage(null)}
>
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
className="max-w-full max-h-full object-contain rounded-lg"
/>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-2 rounded-lg">
{att.original_filename} — Click anywhere to close
</div>
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/70 text-[9px] text-white px-1.5 py-0.5 rounded flex items-center gap-1">
<Image size={8} />
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] sm:text-[9px] text-white px-1.5 py-0.5 rounded">
{att.original_filename}
</div>
</div>
);
}
if (att.file_type === "video") {
return (
<a
key={att.id}
href={`${url}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-anton-card border border-purple-500/30 rounded-lg px-4 py-3 hover:border-purple-400 transition group shadow-sm"
>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Film size={20} className="text-purple-400" />
</div>
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[180px] font-medium">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5">
<span className={`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`}>Video</span>
<span>{att.file_size ? (att.file_size / 1024 / 1024).toFixed(1) + " MB" : ""}</span>
</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-purple-400 shrink-0" />
</a>
);
}
return (
<a
key={att.id}
href={`${url}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-anton-card border border-anton-border rounded-lg px-4 py-3 hover:border-anton-accent transition group shadow-sm"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 hover:border-anton-accent transition group"
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${att.file_type === "document" ? "bg-amber-500/20" : "bg-green-500/20"}`}>
<Icon size={20} className={att.file_type === "document" ? "text-amber-400" : "text-green-400"} />
</div>
<Icon size={14} className="shrink-0 text-blue-400 sm:w-4 sm:h-4" />
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[180px] font-medium">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5">
<span className={`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`}>
{att.file_type}
</span>
<span>{att.file_size ? (att.file_size / 1024).toFixed(0) + " KB" : ""}</span>
</div>
<div className="text-[11px] sm:text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] sm:text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0 sm:w-3 sm:h-3" />
</a>
);
})}
</div>
)}
{/* Message bubble */}
<div className={`rounded-2xl px-4 py-3 ${isUser
{/* Message content */}
<div className={`rounded-2xl px-3 py-2.5 sm:px-4 sm:py-3 ${isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
<div className="text-sm whitespace-pre-wrap break-words">{_stripPrefixes(content)}</div>
) : (
<div className="prose-anton text-sm">
<div className="prose-anton text-sm break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
......@@ -204,19 +153,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
</div>
{/* Meta info */}
{/* Actions bar */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1">
<div className="flex items-center gap-3 mt-1 px-1 flex-wrap">
<button
onClick={handleCopy}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition p-1 -ml-1 rounded active:scale-95"
>
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()} tokens
<span className="text-[10px] sm:text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}
</span>
)}
</div>
......@@ -225,8 +174,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={16} className="text-anton-muted" />
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted sm:w-4 sm:h-4" />
</div>
</div>
)}
......
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import {
createChat, deleteChat, renameChat,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments,
} from "../api";
import * as streamManager from "../streamManager";
import {
Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen,
MessageSquare, BookOpen, Upload, X, ChevronDown, ChevronRight, Edit2, Check,
Radio,
Flame, Plus, MessageSquare, Trash2, Pencil, Check, X,
Settings, LogOut, BookOpen, Shield, ChevronLeft,
} from "lucide-react";
export default function Sidebar({ onRefresh }) {
export default function Sidebar() {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const [tab, setTab] = useState("chats");
const [kbs, setKbs] = useState([]);
const [kbLoaded, setKbLoaded] = useState(false);
const [newKbName, setNewKbName] = useState("");
const [showNewKb, setShowNewKb] = useState(false);
const [expandedKb, setExpandedKb] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadCount, setUploadCount] = useState(0);
const [renamingId, setRenamingId] = useState(null);
const [renameVal, setRenameVal] = useState("");
const open = state.sidebarOpen;
const streamingChatIds = Object.keys(state.activeStreams);
const streamingCount = streamingChatIds.length;
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState("");
const [deletingId, setDeletingId] = useState(null);
async function handleNewChat() {
try {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
onRefresh();
} catch { /* */ }
}
async function handleDelete(id) {
try {
streamManager.abortStream(id);
await deleteChat(state.token, id);
dispatch({ type: "DELETE_CHAT", chatId: id });
} catch { /* */ }
}
async function handleRename(id) {
if (!renameVal.trim()) return;
try {
await renameChat(state.token, id, renameVal.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id, title: renameVal.trim() } });
setRenamingId(null);
} catch { /* */ }
}
async function loadKbs() {
try {
const data = await listKnowledgeBases(state.token);
setKbs(data);
setKbLoaded(true);
} catch { /* */ }
}
async function handleCreateKb() {
if (!newKbName.trim()) return;
try {
await createKnowledgeBase(state.token, newKbName.trim());
setNewKbName("");
setShowNewKb(false);
loadKbs();
} catch { /* */ }
} catch { }
}
async function handleDeleteKb(id) {
if (!confirm("Delete this knowledge base?")) return;
async function handleDelete(chatId) {
try {
await deleteKnowledgeBase(state.token, id);
loadKbs();
} catch { /* */ }
await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId });
} catch { }
setDeletingId(null);
}
async function handleUpload(kbId, files) {
setUploading(true);
setUploadCount(files.length);
try {
const result = await uploadDocuments(state.token, kbId, files);
const errors = (result.files || []).filter((f) => f.error);
if (errors.length > 0) {
alert(
`Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` +
errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n")
);
}
loadKbs();
} catch (e) {
alert(e.message);
} finally {
setUploading(false);
setUploadCount(0);
async function handleRename(chatId) {
if (!editTitle.trim()) {
setEditingId(null);
return;
}
try {
await renameChat(state.token, chatId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
} catch { }
setEditingId(null);
}
function switchTab(t) {
setTab(t);
if (t === "knowledge" && !kbLoaded) loadKbs();
function startEdit(chat) {
setEditingId(chat.id);
setEditTitle(chat.title);
}
if (!open) {
return (
<div className="w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0">
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<PanelLeftOpen size={18} />
</button>
<button onClick={handleNewChat} className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition">
<Plus size={18} />
</button>
{streamingCount > 0 && (
<div className="w-7 h-7 rounded-full bg-anton-accent/20 flex items-center justify-center" title={`${streamingCount} chat${streamingCount !== 1 ? "s" : ""} streaming`}>
<Radio size={14} className="text-anton-accent animate-pulse" />
</div>
)}
</div>
);
}
const isSuperadmin = state.user?.role === "superadmin";
return (
<div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0">
<div className="h-full flex flex-col bg-anton-surface border-r border-anton-border w-full">
{/* Header */}
<div className="p-3 border-b border-anton-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame size={20} className="text-anton-accent" />
<span className="font-bold text-white text-sm">Son of Anton</span>
</div>
<div className="flex items-center gap-1">
{streamingCount > 0 && (
<span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse flex items-center gap-1">
<Radio size={10} /> {streamingCount}
</span>
)}
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<PanelLeftClose size={16} />
</button>
</div>
</div>
{/* Tab bar */}
<div className="flex border-b border-anton-border">
{[
{ key: "chats", label: "Chats", icon: MessageSquare },
{ key: "knowledge", label: "Knowledge", icon: BookOpen },
].map((t) => (
<div className="p-4 border-b border-anton-border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={18} className="text-white" />
</div>
<div>
<h1 className="text-sm font-bold text-white leading-tight">Son of Anton</h1>
<span className="text-[10px] text-anton-muted">v2.1.0</span>
</div>
</div>
{/* Close button — mobile only */}
<button
key={t.key}
onClick={() => switchTab(t.key)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${tab === t.key ? "text-anton-accent border-b-2 border-anton-accent" : "text-anton-muted hover:text-white"
}`}
onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })}
className="lg:hidden p-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<t.icon size={13} />
{t.label}
<ChevronLeft size={18} />
</button>
))}
</div>
<button
onClick={handleNewChat}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-anton-accent text-white rounded-xl hover:opacity-90 transition text-sm font-medium active:scale-[0.98]"
>
<Plus size={16} />
New Chat
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{tab === "chats" && (
<>
<button
onClick={handleNewChat}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
>
<Plus size={15} /> New Chat
</button>
{state.chats.map((c) => {
const chatStreaming = !!state.activeStreams[c.id];
const isActive = state.activeChatId === c.id;
return (
<div
key={c.id}
className={`group flex items-center rounded-lg cursor-pointer transition ${isActive
? "bg-anton-accent/10 text-anton-accent"
: chatStreaming
? "bg-purple-500/5 text-anton-text hover:bg-purple-500/10"
: "text-anton-text hover:bg-anton-card"
}`}
>
{renamingId === c.id ? (
<div className="flex items-center gap-1 flex-1 p-1">
<input
value={renameVal}
onChange={(e) => setRenameVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
<button onClick={() => handleRename(c.id)} className="p-1 text-anton-success"><Check size={12} /></button>
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted"><X size={12} /></button>
</div>
) : (
<>
<button
onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })}
className="flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
>
{chatStreaming && (
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" title="Streaming" />
)}
<span className="truncate">{c.title}</span>
</button>
<div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0">
<button
onClick={() => { setRenamingId(c.id); setRenameVal(c.title); }}
className="p-1 rounded hover:bg-anton-border text-anton-muted"
>
<Edit2 size={11} />
</button>
<button onClick={() => handleDelete(c.id)} className="p-1 rounded hover:bg-red-500/20 text-anton-danger">
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
);
})}
</>
{/* Chat list */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{state.chats.length === 0 && (
<div className="text-center py-10 text-anton-muted text-xs">
No chats yet. Start a new one!
</div>
)}
{tab === "knowledge" && (
<>
<button
onClick={() => setShowNewKb(!showNewKb)}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
{state.chats.map((chat) => {
const isActive = state.activeChatId === chat.id;
const isEditing = editingId === chat.id;
const isDeleting = deletingId === chat.id;
return (
<div
key={chat.id}
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${isActive
? "bg-anton-accent/15 border border-anton-accent/30 text-white"
: "hover:bg-anton-card text-anton-muted hover:text-white border border-transparent"
}`}
onClick={() => {
if (!isEditing && !isDeleting) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId: chat.id });
}
}}
>
<Plus size={15} /> New Knowledge Base
</button>
{showNewKb && (
<div className="flex gap-1 p-1">
<input
value={newKbName}
onChange={(e) => setNewKbName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
placeholder="Name…"
autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
<button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">Add</button>
</div>
)}
{kbs.map((kb) => (
<div key={kb.id} className="rounded-lg border border-anton-border/50 overflow-hidden">
<button
onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
>
{expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
<BookOpen size={13} className="text-anton-accent shrink-0" />
<span className="flex-1 truncate">{kb.name}</span>
<span className="text-xs text-anton-muted">{kb.document_count} docs</span>
</button>
{expandedKb === kb.id && (
<div className="px-3 pb-3 space-y-2 bg-anton-card/50">
<div className="text-xs text-anton-muted space-y-0.5">
<div>Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K tokens</div>
</div>
<label className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`}>
<Upload size={12} />
{uploading ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…` : "Upload files (.txt, .pdf, .md, .json, .csv …)"}
<input
type="file"
className="hidden"
multiple
accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) handleUpload(kb.id, Array.from(e.target.files));
e.target.value = "";
}}
/>
</label>
<button onClick={() => handleDeleteKb(kb.id)} className="flex items-center gap-1 text-xs text-anton-danger hover:underline">
<Trash2 size={11} /> Delete KB
<MessageSquare size={15} className={`shrink-0 ${isActive ? "text-anton-accent" : ""}`} />
{isEditing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(chat.id);
if (e.key === "Escape") setEditingId(null);
}}
autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
/>
<button onClick={() => handleRename(chat.id)} className="p-1 text-anton-success hover:bg-anton-success/10 rounded">
<Check size={12} />
</button>
<button onClick={() => setEditingId(null)} className="p-1 text-anton-muted hover:bg-anton-card rounded">
<X size={12} />
</button>
</div>
) : isDeleting ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-anton-danger truncate flex-1">Delete?</span>
<button onClick={() => handleDelete(chat.id)} className="p-1 text-anton-danger hover:bg-anton-danger/10 rounded">
<Check size={12} />
</button>
<button onClick={() => setDeletingId(null)} className="p-1 text-anton-muted hover:bg-anton-card rounded">
<X size={12} />
</button>
</div>
) : (
<>
<span className="flex-1 text-xs truncate">{chat.title}</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
<button onClick={() => startEdit(chat)} className="p-1 text-anton-muted hover:text-white hover:bg-anton-card rounded transition">
<Pencil size={11} />
</button>
<button onClick={() => setDeletingId(chat.id)} className="p-1 text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 rounded transition">
<Trash2 size={11} />
</button>
</div>
)}
</div>
))}
</>
)}
</>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="p-3 border-t border-anton-border space-y-2">
{state.user?.role === "superadmin" && (
<div className="p-3 border-t border-anton-border space-y-1">
<button
onClick={() => { navigate("/"); dispatch({ type: "CLOSE_SIDEBAR" }); }}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<BookOpen size={14} />
Knowledge Bases
</button>
{isSuperadmin && (
<button
onClick={() => navigate("/admin")}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent hover:bg-anton-card transition"
onClick={() => { navigate("/admin"); dispatch({ type: "CLOSE_SIDEBAR" }); }}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<Shield size={15} /> Admin Panel
<Shield size={14} />
Admin Panel
</button>
)}
<div className="flex items-center justify-between px-2">
<div>
<div className="text-sm font-medium text-white">{state.user?.username}</div>
<div className="text-xs text-anton-muted">
{((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}K /{" "}
{((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-md bg-anton-card flex items-center justify-center shrink-0">
<span className="text-[10px] font-bold text-anton-accent uppercase">
{(state.user?.username || "?")[0]}
</span>
</div>
<span className="text-xs text-anton-muted truncate">{state.user?.username}</span>
</div>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition">
<LogOut size={16} />
<button
onClick={() => dispatch({ type: "LOGOUT" })}
className="p-1.5 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition"
title="Logout"
>
<LogOut size={14} />
</button>
</div>
</div>
......
......@@ -2,137 +2,287 @@
@tailwind components;
@tailwind utilities;
/* ── Globals ───────────────────────────────── */
* {
scrollbar-width: thin;
scrollbar-color: #2a2a3a #0a0a0f;
/* ═══════════════════════════════════════════ */
/* Use dvh for full-height on mobile */
/* ═══════════════════════════════════════════ */
:root {
--color-anton-bg: #09090f;
--color-anton-surface: #0f0f18;
--color-anton-card: #161622;
--color-anton-border: #1e1e30;
--color-anton-text: #e2e2f0;
--color-anton-muted: #6b6b8a;
--color-anton-accent: #e63946;
--color-anton-success: #2ecc71;
--color-anton-danger: #e74c3c;
/* safe area insets for notched phones */
--sat: env(safe-area-inset-top, 0px);
--sab: env(safe-area-inset-bottom, 0px);
--sal: env(safe-area-inset-left, 0px);
--sar: env(safe-area-inset-right, 0px);
}
*::-webkit-scrollbar {
width: 6px;
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
*::-webkit-scrollbar-track {
background: #0a0a0f;
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
*::-webkit-scrollbar-thumb {
background: #2a2a3a;
border-radius: 3px;
body {
margin: 0;
padding: 0;
background: var(--color-anton-bg);
color: var(--color-anton-text);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden;
overscroll-behavior: none;
/* Prevent pull-to-refresh on mobile */
-webkit-overflow-scrolling: touch;
}
/* Fix iOS textarea zoom — font MUST be >= 16px */
textarea,
input,
select {
font-size: 16px !important;
}
@media (min-width: 640px) {
textarea,
input,
select {
font-size: 14px !important;
}
}
html,
body,
#root {
height: 100%;
margin: 0;
height: 100dvh;
width: 100vw;
overflow: hidden;
}
/* ── Markdown prose adjustments ────────────── */
.prose-anton h1,
.prose-anton h2,
.prose-anton h3 {
color: #f97316;
margin-top: 1em;
margin-bottom: 0.5em;
font-weight: 600;
/* ═══════════════════════════════════════════ */
/* Scrollbar styling */
/* ═══════════════════════════════════════════ */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.prose-anton h1 { font-size: 1.5rem; }
.prose-anton h2 { font-size: 1.25rem; }
.prose-anton h3 { font-size: 1.1rem; }
.prose-anton p {
margin-bottom: 0.75em;
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-anton-border);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-anton-muted);
}
/* Hide scrollbar on mobile for cleaner look */
@media (max-width: 640px) {
::-webkit-scrollbar {
width: 2px;
}
}
/* ═══════════════════════════════════════════ */
/* Animations */
/* ═══════════════════════════════════════════ */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.25s ease-out;
}
.thinking-pulse {
animation: pulse 1.5s ease-in-out infinite;
}
/* ═══════════════════════════════════════════ */
/* Prose (Markdown) styling */
/* ═══════════════════════════════════════════ */
.prose-anton {
line-height: 1.7;
word-break: break-word;
overflow-wrap: break-word;
}
.prose-anton p {
margin: 0.5em 0;
}
.prose-anton p:first-child {
margin-top: 0;
}
.prose-anton p:last-child {
margin-bottom: 0;
}
.prose-anton ul,
.prose-anton ol {
margin-left: 1.5em;
margin-bottom: 0.75em;
padding-left: 1.25em;
margin: 0.5em 0;
}
.prose-anton li {
margin-bottom: 0.25em;
margin: 0.15em 0;
}
.prose-anton ul { list-style-type: disc; }
.prose-anton ol { list-style-type: decimal; }
.prose-anton a {
color: #f97316;
text-decoration: underline;
.prose-anton code:not(pre code) {
background: var(--color-anton-border);
border-radius: 4px;
padding: 0.15em 0.35em;
font-family: "JetBrains Mono", monospace;
font-size: 0.85em;
color: #f0a0a0;
word-break: break-all;
}
.prose-anton blockquote {
border-left: 3px solid #f97316;
padding-left: 1em;
color: #8888a0;
margin: 0.75em 0;
border-left: 3px solid var(--color-anton-accent);
padding-left: 0.75em;
margin: 0.5em 0;
color: var(--color-anton-muted);
}
.prose-anton a {
color: var(--color-anton-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-anton table {
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.85em;
width: 100%;
display: block;
overflow-x: auto;
}
.prose-anton th,
.prose-anton td {
border: 1px solid #2a2a3a;
padding: 0.4em 0.75em;
border: 1px solid var(--color-anton-border);
padding: 0.4em 0.6em;
text-align: left;
white-space: nowrap;
}
.prose-anton th {
background: #1a1a28;
background: var(--color-anton-card);
font-weight: 600;
}
.prose-anton code:not(pre code) {
background: #1a1a28;
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: "JetBrains Mono", monospace;
color: #f97316;
.prose-anton h1,
.prose-anton h2,
.prose-anton h3,
.prose-anton h4 {
font-weight: 600;
color: white;
margin: 0.75em 0 0.35em;
}
/* ── Thinking block animation ──────────────── */
@keyframes thinkPulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
.prose-anton h1 {
font-size: 1.35em;
}
.thinking-pulse {
animation: thinkPulse 2s ease-in-out infinite;
.prose-anton h2 {
font-size: 1.2em;
}
/* ── Slider custom styling ─────────────────── */
.prose-anton h3 {
font-size: 1.05em;
}
.prose-anton hr {
border: none;
border-top: 1px solid var(--color-anton-border);
margin: 1em 0;
}
/* ═══════════════════════════════════════════ */
/* Range input styling */
/* ═══════════════════════════════════════════ */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
width: 100%;
}
input[type="range"]::-webkit-slider-track {
height: 4px;
border-radius: 2px;
background: #2a2a3a;
border-radius: 999px;
background: var(--color-anton-border);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #f97316;
margin-top: -6px;
background: var(--color-anton-accent);
cursor: pointer;
border: 2px solid var(--color-anton-bg);
}
input[type="range"]::-moz-range-track {
height: 4px;
border-radius: 2px;
background: #2a2a3a;
}
input[type="range"]::-moz-range-thumb {
height: 16px;
width: 16px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #f97316;
border: none;
background: var(--color-anton-accent);
cursor: pointer;
border: 2px solid var(--color-anton-bg);
}
/* ═══════════════════════════════════════════ */
/* Mobile-specific utilities */
/* ═══════════════════════════════════════════ */
@supports (height: 100dvh) {
.h-dvh {
height: 100dvh;
}
}
@supports not (height: 100dvh) {
.h-dvh {
height: 100vh;
}
}
/* Ensure touch targets are at least 44px */
@media (max-width: 640px) {
button,
a,
[role="button"] {
min-height: 44px;
min-width: 44px;
}
/* Exception for inline/tiny buttons */
.prose-anton button,
.text-\[11px\] button {
min-height: auto;
min-width: auto;
}
}
\ No newline at end of file
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AppProvider } from "./store";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
......
import React, { useEffect, useCallback } from "react";
import React, { useEffect } from "react";
import { useApp } from "../store";
import { listChats } from "../api";
import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView";
import { Flame, Paperclip, Layers, Zap } from "lucide-react";
import { Flame, MessageSquarePlus, Menu } from "lucide-react";
export default function ChatPage() {
const { state, dispatch } = useApp();
const loadChats = useCallback(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { /* ignore */ }
}, [state.token, dispatch]);
useEffect(() => {
loadChats();
}, [loadChats]);
return (
<div className="h-full flex">
<Sidebar onRefresh={loadChats} />
<main className="flex-1 flex flex-col min-w-0">
{state.activeChatId ? (
<ChatView key={state.activeChatId} chatId={state.activeChatId} />
) : (
<EmptyState />
)}
</main>
</div>
);
}
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { }
})();
}, [state.token, dispatch]);
function EmptyState() {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center animate-fade-in max-w-lg">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-anton-accent/20 to-transparent border border-anton-accent/20 mb-6">
<Flame size={44} className="text-anton-accent" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Son of Anton</h2>
<p className="text-anton-muted mb-6">
Avatar of All Elements of Code. Create a new chat to begin — but bring
real questions, not that first-result-of-Google garbage.
</p>
<div className="h-dvh flex overflow-hidden relative">
{/* Mobile overlay backdrop */}
{state.sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })}
/>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-left">
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Paperclip size={16} className="text-blue-400" />
</div>
<span className="text-sm font-medium text-white">File Upload</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Drop images, videos, PDFs, or code files directly into chat. AI describes and analyzes them.
</p>
</div>
{/* Sidebar — slides in on mobile, always visible on desktop */}
<div
className={`
fixed inset-y-0 left-0 z-40 w-72
transform transition-transform duration-300 ease-in-out
lg:relative lg:translate-x-0 lg:z-auto
${state.sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<Sidebar />
</div>
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Layers size={16} className="text-purple-400" />
</div>
<span className="text-sm font-medium text-white">Parallel Chats</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Run multiple conversations simultaneously. Switch between them while they stream.
</p>
{/* Main content area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile top bar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-anton-border bg-anton-surface lg:hidden">
<button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-2 -ml-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<Menu size={22} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Flame size={18} className="text-anton-accent shrink-0" />
<span className="text-sm font-semibold text-white truncate">
{state.activeChatId
? state.chats.find((c) => c.id === state.activeChatId)?.title || "Chat"
: "Son of Anton"}
</span>
</div>
</div>
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
<Zap size={16} className="text-green-400" />
{/* Chat view or empty state */}
{state.activeChatId ? (
<ChatView chatId={state.activeChatId} />
) : (
<div className="flex-1 flex items-center justify-center p-6">
<div className="text-center max-w-md">
<div className="w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 mb-6">
<Flame size={40} className="text-white" />
</div>
<span className="text-sm font-medium text-white">Full Code</span>
<h1 className="text-2xl font-bold text-white mb-2">Son of Anton</h1>
<p className="text-anton-muted text-sm mb-6 leading-relaxed">
Avatar of All Elements of Code. Select an existing chat from the sidebar
or create a new one to begin.
</p>
<button
onClick={() => dispatch({ type: "OPEN_SIDEBAR" })}
className="lg:hidden inline-flex items-center gap-2 px-5 py-2.5 bg-anton-accent text-white rounded-xl hover:opacity-90 transition active:scale-95 text-sm font-medium"
>
<MessageSquarePlus size={16} />
Open Chats
</button>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Production-ready code with syntax highlighting, download buttons, and ZIP export.
</p>
</div>
</div>
)}
</div>
</div>
);
......
import React, { createContext, useContext, useReducer, useEffect } from "react";
import { setDispatch } from "./streamManager";
const AppContext = createContext();
const AppContext = createContext(null);
const initialState = {
token: localStorage.getItem("token") || null,
user: JSON.parse(localStorage.getItem("user") || "null"),
user: null,
chats: [],
activeChatId: null,
sidebarOpen: true,
chatMessages: {},
activeStreams: {},
sidebarOpen: false, // mobile sidebar toggle
};
function reducer(state, action) {
switch (action.type) {
case "LOGIN": {
localStorage.setItem("token", action.token);
localStorage.setItem("user", JSON.stringify(action.user));
return { ...state, token: action.token, user: action.user };
}
case "SET_TOKEN":
if (action.token) localStorage.setItem("token", action.token);
else localStorage.removeItem("token");
localStorage.setItem("token", action.token);
return { ...state, token: action.token };
case "SET_USER":
localStorage.setItem("user", JSON.stringify(action.user));
return { ...state, user: action.user };
case "LOGOUT":
localStorage.removeItem("token");
localStorage.removeItem("user");
return { ...initialState, token: null, user: null };
return { ...initialState, token: null };
case "SET_CHATS":
return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT":
return {
...state,
chats: [action.chat, ...state.chats],
activeChatId: action.chat.id,
sidebarOpen: false,
};
case "UPDATE_CHAT": {
......@@ -52,64 +46,52 @@ function reducer(state, action) {
return { ...state, chats: updated };
}
case "REMOVE_CHAT":
case "DELETE_CHAT": {
const chatId = action.chatId;
const filtered = state.chats.filter((c) => c.id !== chatId);
case "REMOVE_CHAT": {
const filtered = state.chats.filter((c) => c.id !== action.chatId);
const newMessages = { ...state.chatMessages };
delete newMessages[chatId];
const newStreams = { ...state.activeStreams };
delete newStreams[chatId];
delete newMessages[action.chatId];
return {
...state,
chats: filtered,
chatMessages: newMessages,
activeStreams: newStreams,
activeChatId:
state.activeChatId === chatId
? filtered[0]?.id || null
: state.activeChatId,
activeChatId: state.activeChatId === action.chatId ? null : state.activeChatId,
};
}
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId };
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
case "SET_MESSAGES":
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: action.messages,
},
chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
};
case "ADD_MESSAGE":
case "ADD_MESSAGE": {
const prev = state.chatMessages[action.chatId] || [];
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: [
...(state.chatMessages[action.chatId] || []),
action.message,
],
[action.chatId]: [...prev, action.message],
},
};
}
case "SET_STREAMING": {
case "SET_STREAMING":
if (action.streaming) {
return {
...state,
activeStreams: { ...state.activeStreams, [action.chatId]: true },
};
return { ...state, activeStreams: { ...state.activeStreams, [action.chatId]: true } };
} else {
const s = { ...state.activeStreams };
delete s[action.chatId];
return { ...state, activeStreams: s };
}
const next = { ...state.activeStreams };
delete next[action.chatId];
return { ...state, activeStreams: next };
}
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
case "CLOSE_SIDEBAR":
return { ...state, sidebarOpen: false };
case "OPEN_SIDEBAR":
return { ...state, sidebarOpen: true };
default:
return state;
......@@ -118,11 +100,6 @@ function reducer(state, action) {
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
setDispatch(dispatch);
}, [dispatch]);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
......
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
anton: {
bg: "#0a0a0f",
surface: "#12121a",
card: "#1a1a28",
border: "#2a2a3a",
accent: "#f97316",
accentDim: "#c2410c",
text: "#e4e4ef",
muted: "#8888a0",
user: "#1e293b",
assistant: "#15151f",
danger: "#ef4444",
success: "#22c55e",
},
"anton-bg": "#09090f",
"anton-surface": "#0f0f18",
"anton-card": "#161622",
"anton-border": "#1e1e30",
"anton-text": "#e2e2f0",
"anton-muted": "#6b6b8a",
"anton-accent": "#e63946",
"anton-success": "#2ecc71",
"anton-danger": "#e74c3c",
},
fontFamily: {
sans: ['"Inter"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', '"Fira Code"', "monospace"],
mono: ['"JetBrains Mono"', "monospace"],
},
animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4,0,0.6,1) infinite",
"fade-in": "fadeIn 0.3s ease-out",
height: {
dvh: "100dvh",
},
keyframes: {
fadeIn: {
"0%": { opacity: 0, transform: "translateY(8px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
},
minHeight: {
dvh: "100dvh",
},
screens: {
xs: "480px",
},
},
},
......
......@@ -5,11 +5,20 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
"/api": "http://localhost:80",
},
},
build: {
outDir: "dist",
// Content-hash all chunks so browsers fetch new versions
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash].[ext]",
},
},
// Generate a manifest for cache-busting verification
manifest: true,
sourcemap: false,
},
});
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment