add initial complete webui, more ai commands for moderation, add api
This commit is contained in:
parent
19e794e34c
commit
173d4e7a52
112 changed files with 8176 additions and 780 deletions
41
webui/.gitignore
vendored
Executable file
41
webui/.gitignore
vendored
Executable file
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
24
webui/LICENSE
Normal file
24
webui/LICENSE
Normal file
|
@ -0,0 +1,24 @@
|
|||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
549
webui/app/about/page.tsx
Executable file
549
webui/app/about/page.tsx
Executable file
|
@ -0,0 +1,549 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sparkles,
|
||||
Users,
|
||||
Download,
|
||||
Brain,
|
||||
Shield,
|
||||
Zap,
|
||||
Tv,
|
||||
Heart,
|
||||
Code,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
Layers,
|
||||
Network,
|
||||
Lock,
|
||||
UserCheck,
|
||||
BarChart3,
|
||||
Languages,
|
||||
Trash2,
|
||||
FileText,
|
||||
Headphones,
|
||||
CloudSun,
|
||||
Smartphone,
|
||||
Dices,
|
||||
Cat,
|
||||
Music,
|
||||
Bot
|
||||
} from "lucide-react";
|
||||
import { SiTypescript, SiPostgresql, SiDocker, SiNextdotjs, SiBun, SiForgejo } from "react-icons/si";
|
||||
import { RiTelegram2Line } from "react-icons/ri";
|
||||
import { BsInfoLg } from "react-icons/bs";
|
||||
import { TbRocket, TbSparkles } from "react-icons/tb";
|
||||
import Link from "next/link";
|
||||
import { TbPalette } from "react-icons/tb";
|
||||
import Footer from "@/components/footer";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<section className="flex flex-col items-center justify-center py-24 px-6 text-center bg-gradient-to-br from-background to-muted">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 p-4">
|
||||
<BsInfoLg className="w-10 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
About Kowalski
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
Kowalski is an open-source, feature-rich Telegram bot built with modern web technologies.
|
||||
From AI-powered conversations to video downloads, user management, and community features —
|
||||
it's designed to enhance your Telegram experience while respecting your privacy.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
|
||||
<Button size="lg" className="min-w-32" asChild>
|
||||
<Link href="https://git.p0ntus.com/ABOCN/TelegramBot" target="_blank">
|
||||
<SiForgejo />
|
||||
View Source Code
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="min-w-32" asChild>
|
||||
<Link href="https://p0ntus.com/services/hosting" target="_blank">
|
||||
<TbRocket />
|
||||
Deploy free with p0ntus
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="min-w-32" asChild>
|
||||
<Link href="https://t.me/KowalskiNodeBot" target="_blank">
|
||||
<RiTelegram2Line />
|
||||
Try on Telegram
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">Architecture</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
We've built Kowalski with modern technologies and best practices for reliability and maintainability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<Code className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Tech Stack</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kowalski is built completely in TypeScript with Node.js and Telegraf.
|
||||
The web interface uses Next.js with Tailwind CSS, while data persistence is handled by PostgreSQL with Drizzle ORM.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<SiTypescript className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">TypeScript + Node.js</div>
|
||||
<div className="text-sm text-muted-foreground">Type-safe backend w/ Telegraf</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<SiNextdotjs className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">Next.js WebUI</div>
|
||||
<div className="text-sm text-muted-foreground">Modern, responsive admin and user panel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<SiPostgresql className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">PostgreSQL + Drizzle ORM</div>
|
||||
<div className="text-sm text-muted-foreground">Reliable data persistence</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-green-500/10 text-green-500">
|
||||
<SiDocker className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Deployment</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kowalski is built to be deployed anywhere, and has been tested on multiple platforms.
|
||||
We prioritize support for Docker and Bun for easy deployment.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<SiDocker className="w-5 h-5 mx-3 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">Docker Support</div>
|
||||
<div className="text-sm text-muted-foreground">Easy containerized deployment w/ Docker Compose</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<SiBun className="w-5 h-5 mx-3 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">Bun</div>
|
||||
<div className="text-sm text-muted-foreground">A fast JavaScript runtime for best performance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Layers className="w-5 h-5 mx-3 text-green-500" />
|
||||
<div> {/* some ppl probably don't know what af means :( */}
|
||||
<div className="font-medium">Modular AF Backend</div>
|
||||
<div className="text-sm text-muted-foreground">Command-based structure for easy feature addition</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-24 px-6 bg-muted/30">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">AI Integrations</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Powered by Ollama, Kowalski has support for 50+ AI models, with customizable
|
||||
options for users and admins.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<Brain className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Vast Model Support</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kowalski has support for 50+ models, both thinking and non-thinking. We have
|
||||
good Markdown parsing, with customizable options for both users and admins.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<TbSparkles className="w-5 h-5 mx-3 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">/ask - Quick Responses</div>
|
||||
<div className="text-sm text-muted-foreground">Fast answers using smaller non-thinking models</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Brain className="w-5 h-5 mx-3 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">/think - Deep Reasoning</div>
|
||||
<div className="text-sm text-muted-foreground">Advanced thinking models with togglable reasoning visibility</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Bot className="w-5 h-5 mx-3 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">/ai - Your Custom Model!</div>
|
||||
<div className="text-sm text-muted-foreground">Use your personally configured AI model</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-orange-500/10 text-orange-500">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Kowalski's <span className="italic">Powerful</span></h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
We have amazing Markdown V2 parsing, queue management, and usage statistics tracking.
|
||||
It's hella private, too. AI is disabled by default for the best user experience.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Network className="w-5 h-5 mx-3 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">Streaming</div>
|
||||
<div className="text-sm text-muted-foreground">Real-time Markdown V2 message updates as the model generates</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<BarChart3 className="w-5 h-5 mx-3 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">Usage Stats</div>
|
||||
<div className="text-sm text-muted-foreground">Track your AI requests and usage with /aistats</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<UserCheck className="w-5 h-5 mx-3 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">Queues</div>
|
||||
<div className="text-sm text-muted-foreground">High usage limits with intelligent request queuing</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">We're User-First</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Kowalski has privacy-focused user management with customizable settings,
|
||||
multilingual support, and transparent data handling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<Lock className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Privacy</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
User data is minimized and linked only by Telegram ID. No personal information
|
||||
is shared with third parties, and users maintain full control over their data
|
||||
with easy account deletion options.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Shield className="w-5 h-5 mx-3 text-emerald-500" />
|
||||
<div>
|
||||
<div className="font-medium">Limited Data Collection</div>
|
||||
<div className="text-sm text-muted-foreground">Only essential data is stored, linked by Telegram ID</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<FileText className="w-5 h-5 mx-3 text-emerald-500" />
|
||||
<div>
|
||||
<div className="font-medium">Transparent Policies</div>
|
||||
<div className="text-sm text-muted-foreground">Clear privacy policy accessible via /privacy</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Trash2 className="w-5 h-5 mx-3 text-emerald-500" />
|
||||
<div>
|
||||
<div className="font-medium">Easy Account Deletion</div>
|
||||
<div className="text-sm text-muted-foreground">You can delete your data at any time</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<TbPalette className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Customization</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Personalize your experience with custom AI preferences,
|
||||
temperature settings, language selection, and detailed usage statistics.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Bot className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">AI Preferences</div>
|
||||
<div className="text-sm text-muted-foreground">Choose default models and configure temperature</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Languages className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">Multilingual Support</div>
|
||||
<div className="text-sm text-muted-foreground">English and Portuguese language options</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<BarChart3 className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">Usage Analytics</div>
|
||||
<div className="text-sm text-muted-foreground">Personal statistics and usage tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-24 px-6 bg-muted/30">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">There's <span className="text-5xl">WAYYYYY</span> more!</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Beyond AI, Kowalski has a ton of entertainment, utility, fun, configuration, and information
|
||||
commands.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-red-500/10 text-red-500">
|
||||
<Download className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">Media Downloads</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Download videos from YouTube and 1000s of other platforms using yt-dlp.
|
||||
Featuring automatic size checking for Telegram'.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tv className="w-4 h-4 text-red-500" />
|
||||
<span>/yt [URL] - Video downloads</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Shield className="w-4 h-4 text-red-500" />
|
||||
<span>Automatic size limit handling</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">Information & Utilities</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Access real-world information like weather reports, device specifications,
|
||||
HTTP status codes, and a Last.fm music integration.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CloudSun className="w-4 h-4 text-blue-500" />
|
||||
<span>/weather - Weather reports</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Smartphone className="w-4 h-4 text-blue-500" />
|
||||
<span>/device - GSMArena specs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Headphones className="w-4 h-4 text-blue-500" />
|
||||
<span>/last - Last.fm integration</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-green-500/10 text-green-500">
|
||||
<Heart className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">Entertainment</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Interactive emojis, random animal pictures, My Little Pony,
|
||||
and fun commands to engage you and your community.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Dices className="w-4 h-4 text-green-500" />
|
||||
<span>/dice, /slot - Interactive games</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cat className="w-4 h-4 text-green-500" />
|
||||
<span>/cat, /dog - Random animals</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Music className="w-4 h-4 text-green-500" />
|
||||
<span>/mlp - My Little Pony DB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">Our Community</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Kowalski is built by developers, for developers. We use open licenses and
|
||||
take input from our development communities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<SiForgejo className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Open Development</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kowalski is licensed under BSD-3-Clause with components under Unlicense. Our
|
||||
codebase is available on our Forgejo and GitHub, with lots of documentation.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<SiForgejo className="w-5 h-5 mx-3 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">Public Code</div>
|
||||
<div className="text-sm text-muted-foreground">Feel free to contribute or review our code</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<FileText className="w-5 h-5 mx-3 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">Documentation</div>
|
||||
<div className="text-sm text-muted-foreground">We have documentation to help contributors, users, and admins</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Users className="w-5 h-5 mx-3 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">Contributor Friendly</div>
|
||||
<div className="text-sm text-muted-foreground">Our communities are welcoming to new contributors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-orange-500/10 text-orange-500">
|
||||
<Heart className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Community Centric</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kowalski was created by Lucas Gabriel (lucmsilva). It is now also maintained by ihatenodejs,
|
||||
givfnz2, and other contributors. Thank you to all of our contributors!
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<MessageSquare className="w-5 h-5 mx-3 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">Active Maintenance</div>
|
||||
<div className="text-sm text-muted-foreground">Regular updates and fixes w/ room for input and feedback</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Code className="w-5 h-5 mx-3 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">Quality Code</div>
|
||||
<div className="text-sm text-muted-foreground">We use TypeScript, linting, and modern standards</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Sparkles className="w-5 h-5 mx-3 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">Focus on New Features</div>
|
||||
<div className="text-sm text-muted-foreground">We are always looking for new features to add</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<div className="inline-flex items-center gap-4 p-6 rounded-lg bg-muted/50 border">
|
||||
<div className="flex items-center gap-2">
|
||||
<SiForgejo className="w-5 h-5" />
|
||||
<span className="font-medium">Ready to contribute?</span>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="https://git.p0ntus.com/ABOCN/TelegramBot" target="_blank">
|
||||
<SiForgejo />
|
||||
View on Forgejo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
204
webui/app/account/delete/page.tsx
Executable file
204
webui/app/account/delete/page.tsx
Executable file
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Trash2, ArrowLeft, AlertTriangle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function DeleteAccountPage() {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/delete', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Your account has been deleted. You will now be redirected to the home page. Thanks for using Kowalski!');
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to delete account: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
alert('An error occurred while deleting your account. Please try again.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-background">
|
||||
<div className="container mx-auto px-6 py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/account">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Account
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<Trash2 className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Delete Account</h1>
|
||||
<p className="text-muted-foreground">Permanently remove your account and data</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-yellow-800 dark:text-yellow-200">
|
||||
This action cannot be undone
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
Deleting your account will permanently remove all your data, including:
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 dark:text-yellow-300 list-disc list-inside space-y-1 ml-2">
|
||||
<li>Your user profile and settings</li>
|
||||
<li>AI usage statistics and request history</li>
|
||||
<li>Custom AI model preferences</li>
|
||||
<li>Command configuration and disabled commands</li>
|
||||
<li>All associated sessions and authentication data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Account Information</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Username:</span>
|
||||
<span className="font-medium">@{user?.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Name:</span>
|
||||
<span className="font-medium">{user?.firstName} {user?.lastName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Telegram ID:</span>
|
||||
<span className="font-medium">{user?.telegramId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">AI Requests:</span>
|
||||
<span className="font-medium">{user?.aiRequests.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Ready to delete your account?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will immediately and permanently delete your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
Confirm Account Deletion
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>
|
||||
Are you absolutely sure you want to delete your account? This action cannot be undone.
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Your account <span className="font-bold">@{user?.username}</span> and all associated data will be permanently removed.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={isDeleting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Yes, Delete Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
725
webui/app/account/page.tsx
Executable file
725
webui/app/account/page.tsx
Executable file
|
@ -0,0 +1,725 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
User,
|
||||
Bot,
|
||||
Brain,
|
||||
Settings,
|
||||
CloudSun,
|
||||
Smartphone,
|
||||
Heart,
|
||||
Cat,
|
||||
Dices,
|
||||
Thermometer,
|
||||
BarChart3,
|
||||
LogOut,
|
||||
Edit3,
|
||||
Save,
|
||||
X,
|
||||
Network,
|
||||
Cpu,
|
||||
Languages,
|
||||
Bug,
|
||||
Lightbulb,
|
||||
ExternalLink,
|
||||
Quote,
|
||||
Info,
|
||||
Shuffle,
|
||||
Rainbow,
|
||||
Database,
|
||||
Hash,
|
||||
Download,
|
||||
Archive
|
||||
} from "lucide-react";
|
||||
import { RiTelegram2Line } from "react-icons/ri";
|
||||
import { motion } from "framer-motion";
|
||||
import { ModelPicker } from "@/components/account/model-picker";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { FaLastfm } from "react-icons/fa";
|
||||
import { TiInfinity } from "react-icons/ti";
|
||||
|
||||
interface CommandCard {
|
||||
id: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
title: string;
|
||||
description: string;
|
||||
commands: string[];
|
||||
category: "ai" | "entertainment" | "utility" | "media" | "admin" | "animals";
|
||||
gradient: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const allCommands: CommandCard[] = [
|
||||
{
|
||||
id: "ai-ask-think",
|
||||
icon: Brain,
|
||||
title: "AI Chats",
|
||||
description: "Chat with AI models and use deep thinking",
|
||||
commands: ["/ask", "/think"],
|
||||
category: "ai",
|
||||
gradient: "from-purple-500 to-pink-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "ai-custom",
|
||||
icon: Bot,
|
||||
title: "Custom AI Model",
|
||||
description: "Use your personally configured AI model",
|
||||
commands: ["/ai"],
|
||||
category: "ai",
|
||||
gradient: "from-indigo-500 to-purple-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "ai-stats",
|
||||
icon: BarChart3,
|
||||
title: "AI Statistics",
|
||||
description: "View your AI usage statistics",
|
||||
commands: ["/aistats"],
|
||||
category: "ai",
|
||||
gradient: "from-purple-600 to-indigo-600",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "games-dice",
|
||||
icon: Dices,
|
||||
title: "Interactive Emojis",
|
||||
description: "Roll dice, play slots, and other interactive emojis",
|
||||
commands: ["/dice", "/slot", "/ball", "/dart", "/bowling"],
|
||||
category: "entertainment",
|
||||
gradient: "from-green-500 to-teal-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "fun-random",
|
||||
icon: Shuffle,
|
||||
title: "Fun Commands",
|
||||
description: "Random numbers and fun responses",
|
||||
commands: ["/random", "/furry", "/gay"],
|
||||
category: "entertainment",
|
||||
gradient: "from-pink-500 to-rose-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "infinite-dice",
|
||||
icon: TiInfinity,
|
||||
title: "Infinite Dice",
|
||||
description: "Sends an infinite dice sticker",
|
||||
commands: ["/idice"],
|
||||
category: "entertainment",
|
||||
gradient: "from-yellow-500 to-orange-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "animals-basic",
|
||||
icon: Cat,
|
||||
title: "Animal Pictures",
|
||||
description: "Get random cute animal pictures",
|
||||
commands: ["/cat", "/dog", "/duck", "/fox"],
|
||||
category: "animals",
|
||||
gradient: "from-orange-500 to-red-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "soggy-cat",
|
||||
icon: Heart,
|
||||
title: "Soggy Cat",
|
||||
description: "Wet cats!",
|
||||
commands: ["/soggy", "/soggycat"],
|
||||
category: "animals",
|
||||
gradient: "from-blue-500 to-purple-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "weather",
|
||||
icon: CloudSun,
|
||||
title: "Weather",
|
||||
description: "Get current weather for any location",
|
||||
commands: ["/weather", "/clima"],
|
||||
category: "utility",
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "device-specs",
|
||||
icon: Smartphone,
|
||||
title: "Device Specifications",
|
||||
description: "Look up phone specifications via GSMArena",
|
||||
commands: ["/device", "/d"],
|
||||
category: "utility",
|
||||
gradient: "from-slate-500 to-gray-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "http-status",
|
||||
icon: Network,
|
||||
title: "HTTP Status Codes",
|
||||
description: "Look up HTTP status codes and meanings",
|
||||
commands: ["/http", "/httpcat"],
|
||||
category: "utility",
|
||||
gradient: "from-emerald-500 to-green-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "codename-lookup",
|
||||
icon: Hash,
|
||||
title: "Codename Lookup",
|
||||
description: "Look up codenames and meanings",
|
||||
commands: ["/codename", "/whatis"],
|
||||
category: "utility",
|
||||
gradient: "from-teal-500 to-cyan-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "info-commands",
|
||||
icon: Info,
|
||||
title: "Information",
|
||||
description: "Get chat and user information",
|
||||
commands: ["/chatinfo", "/userinfo"],
|
||||
category: "utility",
|
||||
gradient: "from-indigo-500 to-blue-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "quotes",
|
||||
icon: Quote,
|
||||
title: "Random Quotes",
|
||||
description: "Get random quotes",
|
||||
commands: ["/quote"],
|
||||
category: "utility",
|
||||
gradient: "from-amber-500 to-yellow-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "youtube-download",
|
||||
icon: Download,
|
||||
title: "Video Downloads",
|
||||
description: "Download videos from YouTube and 1000+ platforms",
|
||||
commands: ["/yt", "/ytdl", "/video", "/dl"],
|
||||
category: "media",
|
||||
gradient: "from-red-500 to-pink-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "lastfm",
|
||||
icon: FaLastfm,
|
||||
title: "Last.fm Integration",
|
||||
description: "Connect your music listening history",
|
||||
commands: ["/last", "/lfm", "/setuser"],
|
||||
category: "media",
|
||||
gradient: "from-violet-500 to-purple-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "mlp-content",
|
||||
icon: Database,
|
||||
title: "MLP Database",
|
||||
description: "My Little Pony content and information",
|
||||
commands: ["/mlp", "/mlpchar", "/mlpep", "/mlpcomic"],
|
||||
category: "media",
|
||||
gradient: "from-fuchsia-500 to-pink-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "modarchive",
|
||||
icon: Archive,
|
||||
title: "Mod Archive",
|
||||
description: "Access classic tracker music files",
|
||||
commands: ["/modarchive", "/tma"],
|
||||
category: "media",
|
||||
gradient: "from-cyan-500 to-blue-500",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "random-pony",
|
||||
icon: Rainbow,
|
||||
title: "Random Pony Art",
|
||||
description: "Get random My Little Pony artwork",
|
||||
commands: ["/rpony", "/randompony", "/mlpart"],
|
||||
category: "media",
|
||||
gradient: "from-pink-500 to-purple-500",
|
||||
enabled: true
|
||||
},
|
||||
];
|
||||
|
||||
const categoryColors = {
|
||||
ai: "bg-purple-500/10 text-purple-600 border-purple-200 dark:border-purple-800",
|
||||
entertainment: "bg-green-500/10 text-green-600 border-green-200 dark:border-green-800",
|
||||
utility: "bg-blue-500/10 text-blue-600 border-blue-200 dark:border-blue-800",
|
||||
media: "bg-red-500/10 text-red-600 border-red-200 dark:border-red-800",
|
||||
admin: "bg-orange-500/10 text-orange-600 border-orange-200 dark:border-orange-800",
|
||||
animals: "bg-emerald-500/10 text-emerald-600 border-emerald-200 dark:border-emerald-800"
|
||||
};
|
||||
|
||||
const languageOptions = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
|
||||
];
|
||||
|
||||
export default function AccountPage() {
|
||||
const [editingTemp, setEditingTemp] = useState(false);
|
||||
const [tempValue, setTempValue] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [reportTab, setReportTab] = useState("bug");
|
||||
const [commands, setCommands] = useState<CommandCard[]>(allCommands);
|
||||
|
||||
const { user, loading, logout, refreshUser } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setTempValue(user.aiTemperature.toString());
|
||||
setCommands(allCommands.map(cmd => ({
|
||||
...cmd,
|
||||
enabled: !user.disabledCommands.includes(cmd.id)
|
||||
})));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const updateSetting = async (setting: string, value: boolean | number | string) => {
|
||||
try {
|
||||
const response = await fetch('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ [setting]: value }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await refreshUser();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTemperature = () => {
|
||||
const temp = parseFloat(tempValue);
|
||||
if (temp >= 0.1 && temp <= 2.0) {
|
||||
updateSetting('aiTemperature', temp);
|
||||
setEditingTemp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCommand = async (commandId: string) => {
|
||||
if (!user) return;
|
||||
|
||||
const commandToToggle = commands.find(cmd => cmd.id === commandId);
|
||||
if (!commandToToggle) return;
|
||||
|
||||
const newEnabledState = !commandToToggle.enabled;
|
||||
|
||||
setCommands(prev => prev.map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, enabled: newEnabledState } : cmd
|
||||
));
|
||||
|
||||
try {
|
||||
let newDisabledCommands: string[];
|
||||
|
||||
if (newEnabledState) {
|
||||
newDisabledCommands = user.disabledCommands.filter(id => id !== commandId);
|
||||
} else {
|
||||
newDisabledCommands = [...user.disabledCommands, commandId];
|
||||
}
|
||||
|
||||
const response = await fetch('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ disabledCommands: newDisabledCommands }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await refreshUser();
|
||||
} else {
|
||||
setCommands(prev => prev.map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, enabled: !newEnabledState } : cmd
|
||||
));
|
||||
console.error('Failed to update command state');
|
||||
}
|
||||
} catch (error) {
|
||||
setCommands(prev => prev.map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, enabled: !newEnabledState } : cmd
|
||||
));
|
||||
console.error('Error updating command state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCommands = selectedCategory
|
||||
? commands.filter(cmd => cmd.category === selectedCategory)
|
||||
: commands;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
|
||||
<Button onClick={() => window.location.href = '/login'}>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-background">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 items-center justify-center hidden md:flex">
|
||||
<User className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Welcome back, {user.firstName}!</h1>
|
||||
<p className="text-muted-foreground">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={logout} className="gap-2">
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<motion.div
|
||||
className="p-6 rounded-lg border bg-gradient-to-br from-purple-500/10 to-pink-500/10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<BarChart3 className="w-8 h-8 text-purple-600" />
|
||||
<h3 className="text-xl font-semibold">AI Usage</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-2xl font-bold">{user.aiRequests}</p>
|
||||
<p className="text-sm text-muted-foreground">Total AI Requests</p>
|
||||
<p className="text-lg">{user.aiCharacters.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Characters Generated</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="p-6 rounded-lg border bg-gradient-to-br from-blue-500/10 to-cyan-500/10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Settings className="w-8 h-8 text-blue-600" />
|
||||
<h3 className="text-xl font-semibold">AI Settings</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">AI Enabled</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={user.aiEnabled ? "default" : "outline"}
|
||||
onClick={() => updateSetting('aiEnabled', !user.aiEnabled)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{user.aiEnabled ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Show Thinking</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={user.showThinking ? "default" : "outline"}
|
||||
onClick={() => updateSetting('showThinking', !user.showThinking)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{user.showThinking ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="p-6 rounded-lg border bg-gradient-to-br from-green-500/10 to-emerald-500/10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Thermometer className="w-8 h-8 text-green-600" />
|
||||
<h3 className="text-xl font-semibold">Temperature</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{editingTemp ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={tempValue}
|
||||
onChange={(e) => setTempValue(e.target.value)}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<Button size="sm" onClick={saveTemperature} className="h-8 w-8 p-0">
|
||||
<Save className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingTemp(false)} className="h-8 w-8 p-0">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-2xl font-bold">{user.aiTemperature}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingTemp(true)} className="h-8 w-8 p-0">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Controls randomness in AI responses. Lower values (0.1-0.5) = more focused, higher values (0.7-2.0) = more creative.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="p-6 rounded-lg border bg-gradient-to-br from-teal-500/10 to-cyan-500/10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Languages className="w-8 h-8 text-teal-600" />
|
||||
<h3 className="text-xl font-semibold">Language Options</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{languageOptions.map((lang) => (
|
||||
<Button
|
||||
key={lang.code}
|
||||
variant={user.languageCode === lang.code ? "default" : "outline"}
|
||||
onClick={() => updateSetting('languageCode', lang.code)}
|
||||
className="justify-start gap-3 h-10"
|
||||
>
|
||||
<span className="text-lg">{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Choose your preferred language for bot responses and interface text.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="p-6 rounded-lg border bg-gradient-to-br from-indigo-500/10 to-violet-500/10 col-span-1 md:col-span-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Cpu className="w-8 h-8 text-indigo-600" />
|
||||
<h3 className="text-xl font-semibold">My Model</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<ModelPicker
|
||||
value={user.customAiModel}
|
||||
onValueChange={(newModel) => updateSetting('customAiModel', newModel)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Your selected AI model for custom /ai commands. Different models have varying capabilities, speeds, and response styles.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="p-6 rounded-lg border bg-gradient-to-br from-orange-500/10 to-red-500/10 col-span-1 md:col-span-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.6 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Bug className="w-8 h-8 text-orange-600" />
|
||||
<h3 className="text-xl font-semibold">Report An Issue</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Tabs value={reportTab} onValueChange={setReportTab}>
|
||||
<TabsList className="grid w-full grid-cols-2 gap-2">
|
||||
<TabsTrigger value="bug" className="gap-2">
|
||||
<Bug className="w-4 h-4" />
|
||||
Bug Report
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feature" className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Feature Request
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mt-4">
|
||||
<TabsContent value="bug" className="space-y-12">
|
||||
<p className="text-sm text-muted-foreground">Found a bug or issue? Report it to help us improve Kowalski.</p>
|
||||
<Button asChild className="w-full gap-2">
|
||||
<a
|
||||
href="https://libre-cloud.atlassian.net/jira/software/c/form/4a535b59-dc7e-4b55-b905-a79ff831928e?atlOrigin=eyJpIjoiNzQwYTcxZDdmMjJkNDljNzgzNTY2MjliYjliMjMzMDkiLCJwIjoiaiJ9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Bug className="w-4 h-4" />
|
||||
Report Bug
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TabsContent>
|
||||
<TabsContent value="feature" className="space-y-12">
|
||||
<p className="text-sm text-muted-foreground">Have an idea for a new feature? Let us know what you'd like to see!</p>
|
||||
<Button asChild className="w-full gap-2">
|
||||
<a
|
||||
href="https://libre-cloud.atlassian.net/jira/software/c/form/5ce1e6e9-9618-4b46-94ee-122e7bde2ba1?atlOrigin=eyJpIjoiZjMwZTc3MDVlY2MwNDBjODliYWNhMTgzN2ZjYzI5MDAiLCJwIjoiaiJ9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Request Feature
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.7 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-4">Command Management</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === null ? "default" : "outline"}
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className="mb-2"
|
||||
>
|
||||
All Commands
|
||||
</Button>
|
||||
{Object.entries(categoryColors).map(([category, colorClass]) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? "default" : "outline"}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`mb-2 capitalize ${selectedCategory === category ? '' : colorClass}`}
|
||||
>
|
||||
{category === "ai" ? "AI" : category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.8 }}
|
||||
>
|
||||
{filteredCommands.map((command) => (
|
||||
<div
|
||||
key={command.id}
|
||||
className={`p-4 rounded-lg border transition-all duration-200 ${
|
||||
command.enabled
|
||||
? 'bg-card hover:shadow-md shadow-sm'
|
||||
: 'bg-muted/30 border-muted-foreground/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${command.gradient} flex items-center justify-center ${
|
||||
command.enabled ? '' : 'grayscale opacity-50'
|
||||
}`}>
|
||||
<command.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-11 h-6 rounded-full cursor-pointer transition-colors duration-200 ${
|
||||
command.enabled
|
||||
? 'bg-green-500 dark:bg-green-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => toggleCommand(command.id)}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded-full shadow-sm transition-transform duration-200 ${
|
||||
command.enabled
|
||||
? 'translate-x-5 bg-white dark:bg-gray-100'
|
||||
: 'translate-x-0.5 bg-white dark:bg-gray-200'
|
||||
} mt-0.5`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className={`text-base font-semibold mb-2 ${command.enabled ? '' : 'text-muted-foreground'}`}>
|
||||
{command.title}
|
||||
</h3>
|
||||
<p className={`text-sm mb-3 ${command.enabled ? 'text-muted-foreground' : 'text-muted-foreground/60'}`}>
|
||||
{command.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{command.commands.slice(0, 2).map((cmd, idx) => (
|
||||
<code key={idx} className={`px-1.5 py-0.5 rounded text-xs ${
|
||||
command.enabled
|
||||
? 'bg-muted text-foreground'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground/60'
|
||||
}`}>
|
||||
{cmd}
|
||||
</code>
|
||||
))}
|
||||
{command.commands.length > 2 && (
|
||||
<span className={`text-xs ${command.enabled ? 'text-muted-foreground' : 'text-muted-foreground/60'}`}>
|
||||
+{command.commands.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`px-2 py-1 rounded-full text-xs border ${
|
||||
command.enabled
|
||||
? categoryColors[command.category]
|
||||
: 'bg-muted-foreground/10 text-muted-foreground/60 border-muted-foreground/20'
|
||||
}`}>
|
||||
{command.category === "ai" ? "AI" : command.category}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-12 text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 1.0 }}
|
||||
>
|
||||
<div className="inline-flex items-center gap-16 p-6 px-8 rounded-lg bg-muted/50 border">
|
||||
<span className="font-medium">Ready to start using Kowalski?</span>
|
||||
<Button asChild>
|
||||
<a href="https://t.me/KowalskiNodeBot" target="_blank" rel="noopener noreferrer">
|
||||
<RiTelegram2Line />
|
||||
Open on Telegram
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
34
webui/app/api/auth/logout/route.ts
Executable file
34
webui/app/api/auth/logout/route.ts
Executable file
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { invalidateSession } from "@/lib/auth";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
const sessionToken = bearerToken || cookieToken;
|
||||
|
||||
if (sessionToken) {
|
||||
await invalidateSession(sessionToken);
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in logout API:", error);
|
||||
return NextResponse.json({
|
||||
error: "Internal server error"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
91
webui/app/api/auth/username/route.ts
Executable file
91
webui/app/api/auth/username/route.ts
Executable file
|
@ -0,0 +1,91 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "@/lib/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const requestContentType = request.headers.get('content-type');
|
||||
if (!requestContentType || !requestContentType.includes('application/json')) {
|
||||
return NextResponse.json({ success: false, error: "Invalid content type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { username } = body;
|
||||
|
||||
if (!username) {
|
||||
return NextResponse.json({ success: false, error: "Username is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof username !== 'string' || username.length < 3 || username.length > 32) {
|
||||
return NextResponse.json({ success: false, error: "Invalid username format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cleanUsername = username.replace('@', '');
|
||||
|
||||
const user = await db.query.usersTable.findFirst({
|
||||
where: eq(schema.usersTable.username, cleanUsername),
|
||||
columns: {
|
||||
telegramId: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const botUsername = process.env.botUsername || "KowalskiNodeBot";
|
||||
return NextResponse.json({ success: false, error: `Please DM @${botUsername} before signing in.` }, { status: 404 });
|
||||
}
|
||||
|
||||
const botApiUrl = process.env.botApiUrl || "http://kowalski:3030";
|
||||
const fullUrl = `${botApiUrl}/2fa/get`;
|
||||
|
||||
const botApiResponse = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userId: user.telegramId }),
|
||||
});
|
||||
|
||||
if (!botApiResponse.ok) {
|
||||
const errorText = await botApiResponse.text();
|
||||
console.error("Bot API error response:", errorText);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `Bot API error: ${botApiResponse.status} - ${errorText.slice(0, 200)}`
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
const contentType = botApiResponse.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const errorText = await botApiResponse.text();
|
||||
console.error("Bot API returned non-JSON:", errorText.slice(0, 200));
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Bot API returned invalid response format"
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
const botApiResult = await botApiResponse.json();
|
||||
|
||||
if (!botApiResult.generated) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: botApiResult.error || "Failed to send 2FA code"
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "2FA code sent successfully",
|
||||
userId: user.telegramId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in username API:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Internal server error"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
107
webui/app/api/auth/verify/route.ts
Executable file
107
webui/app/api/auth/verify/route.ts
Executable file
|
@ -0,0 +1,107 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import * as schema from "@/lib/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { createSession, getSessionCookieOptions } from "@/lib/auth";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Invalid content type"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, code } = body;
|
||||
|
||||
if (!userId || !code) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "User ID and code are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof userId !== 'string' || typeof code !== 'string') {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Invalid input format"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Invalid code format"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const twoFactorRecord = await db.query.twoFactorTable.findFirst({
|
||||
where: and(
|
||||
eq(schema.twoFactorTable.userId, userId),
|
||||
gt(schema.twoFactorTable.codeExpiresAt, new Date())
|
||||
),
|
||||
});
|
||||
|
||||
if (!twoFactorRecord) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "No valid 2FA code found or code has expired"
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
if (twoFactorRecord.codeAttempts >= 5) {
|
||||
await db.delete(schema.twoFactorTable)
|
||||
.where(eq(schema.twoFactorTable.userId, userId));
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Too many failed attempts. Please request a new code."
|
||||
}, { status: 429 });
|
||||
}
|
||||
|
||||
if (twoFactorRecord.currentCode !== code) {
|
||||
await db.update(schema.twoFactorTable)
|
||||
.set({
|
||||
codeAttempts: twoFactorRecord.codeAttempts + 1,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(schema.twoFactorTable.userId, userId));
|
||||
|
||||
console.log(`2FA verification failed for user: ${userId}, attempts: ${twoFactorRecord.codeAttempts + 1}`);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Invalid 2FA code"
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const session = await createSession(userId);
|
||||
|
||||
await db.delete(schema.twoFactorTable)
|
||||
.where(eq(schema.twoFactorTable.userId, userId));
|
||||
|
||||
console.log("2FA verification successful for user:", userId);
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: "2FA verification successful",
|
||||
redirectTo: "/account",
|
||||
sessionToken: session.sessionToken
|
||||
});
|
||||
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
response.cookies.set(SESSION_COOKIE_NAME, session.sessionToken, cookieOptions);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in verify API:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Internal server error"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
59
webui/app/api/user/delete/route.ts
Executable file
59
webui/app/api/user/delete/route.ts
Executable file
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { validateSession } from "@/lib/auth";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { usersTable, sessionsTable, twoFactorTable } from "@/lib/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
const sessionToken = bearerToken || cookieToken;
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionData = await validateSession(sessionToken);
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
return NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = sessionData.user.telegramId;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(sessionsTable)
|
||||
.where(eq(sessionsTable.userId, userId));
|
||||
|
||||
await tx.delete(twoFactorTable)
|
||||
.where(eq(twoFactorTable.userId, userId));
|
||||
|
||||
await tx.delete(usersTable)
|
||||
.where(eq(usersTable.telegramId, userId));
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: "Account deleted successfully"
|
||||
});
|
||||
|
||||
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting account:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to delete account"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
46
webui/app/api/user/profile/route.ts
Executable file
46
webui/app/api/user/profile/route.ts
Executable file
|
@ -0,0 +1,46 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { validateSession } from "@/lib/auth";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
const sessionToken = bearerToken || cookieToken;
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionData = await validateSession(sessionToken);
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
return NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { user } = sessionData;
|
||||
const sanitizedUser = {
|
||||
telegramId: user.telegramId,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
aiEnabled: user.aiEnabled,
|
||||
showThinking: user.showThinking,
|
||||
customAiModel: user.customAiModel,
|
||||
aiTemperature: user.aiTemperature,
|
||||
aiRequests: user.aiRequests,
|
||||
aiCharacters: user.aiCharacters,
|
||||
disabledCommands: user.disabledCommands,
|
||||
languageCode: user.languageCode,
|
||||
};
|
||||
|
||||
return NextResponse.json(sanitizedUser);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in profile API:", error);
|
||||
return NextResponse.json({
|
||||
error: "Internal server error"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
103
webui/app/api/user/settings/route.ts
Executable file
103
webui/app/api/user/settings/route.ts
Executable file
|
@ -0,0 +1,103 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { validateSession } from "@/lib/auth";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@/lib/schema";
|
||||
|
||||
interface UserUpdates {
|
||||
aiEnabled?: boolean;
|
||||
showThinking?: boolean;
|
||||
customAiModel?: string;
|
||||
aiTemperature?: number;
|
||||
disabledCommands?: string[];
|
||||
languageCode?: string;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
const sessionToken = bearerToken || cookieToken;
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionData = await validateSession(sessionToken);
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
return NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return NextResponse.json({ error: "Invalid content type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates = await request.json();
|
||||
const userId = sessionData.user.telegramId;
|
||||
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
'aiEnabled',
|
||||
'showThinking',
|
||||
'customAiModel',
|
||||
'aiTemperature',
|
||||
'disabledCommands',
|
||||
'languageCode'
|
||||
];
|
||||
|
||||
const filteredUpdates: UserUpdates = {};
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
if (key === 'aiEnabled' || key === 'showThinking') {
|
||||
filteredUpdates[key] = Boolean(value);
|
||||
} else if (key === 'aiTemperature') {
|
||||
const temp = Number(value);
|
||||
if (temp >= 0.1 && temp <= 2.0) {
|
||||
filteredUpdates[key] = temp;
|
||||
} else {
|
||||
return NextResponse.json({ error: "Temperature must be between 0.1 and 2.0" }, { status: 400 });
|
||||
}
|
||||
} else if (key === 'customAiModel' || key === 'languageCode') {
|
||||
if (typeof value === 'string' && value.length > 0 && value.length < 100) {
|
||||
filteredUpdates[key] = value;
|
||||
} else {
|
||||
return NextResponse.json({ error: `Invalid ${key}` }, { status: 400 });
|
||||
}
|
||||
} else if (key === 'disabledCommands') {
|
||||
if (Array.isArray(value) && value.every(item => typeof item === 'string' && item.length < 50) && value.length < 100) {
|
||||
filteredUpdates[key] = value;
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid disabled commands" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filteredUpdates).length === 0) {
|
||||
return NextResponse.json({ error: "No valid updates provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
filteredUpdates.updatedAt = new Date();
|
||||
|
||||
await db.update(schema.usersTable)
|
||||
.set(filteredUpdates)
|
||||
.where(eq(schema.usersTable.telegramId, userId));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in settings API:", error);
|
||||
return NextResponse.json({
|
||||
error: "Internal server error"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
126
webui/app/globals.css
Executable file
126
webui/app/globals.css
Executable file
|
@ -0,0 +1,126 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sora);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
54
webui/app/layout.tsx
Executable file
54
webui/app/layout.tsx
Executable file
|
@ -0,0 +1,54 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Sora } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/providers";
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { AuthProvider } from "@/contexts/auth-context";
|
||||
import { HeaderAuth } from "@/components/header-auth";
|
||||
|
||||
const sora = Sora({
|
||||
variable: "--font-sora",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kowalski",
|
||||
description: "A powerful, multi-function Telegram bot",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning className="scroll-smooth">
|
||||
<body className={`${sora.variable} antialiased`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-[calc(100vh-16px)] overflow-hidden rounded-lg border bg-background flex flex-col">
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 px-4 border-b bg-background">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="ml-auto">
|
||||
<HeaderAuth />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto scroll-smooth">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
311
webui/app/login/page.tsx
Executable file
311
webui/app/login/page.tsx
Executable file
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RiTelegram2Line } from "react-icons/ri";
|
||||
import { TbLoader } from "react-icons/tb";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type FormStep = "username" | "twofa";
|
||||
|
||||
type VerifyResponse = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
redirectTo?: string;
|
||||
sessionToken?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
initial: { scale: 1 },
|
||||
tap: { scale: 0.98 },
|
||||
};
|
||||
|
||||
function LoginForm() {
|
||||
const [step, setStep] = useState<FormStep>("username");
|
||||
const [username, setUsername] = useState("");
|
||||
const [twoFaCode, setTwoFaCode] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = searchParams.get('returnTo') || '/account';
|
||||
|
||||
const handleUsernameSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/username", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username: username.trim() }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setUserId(result.userId);
|
||||
setStep("twofa");
|
||||
} else {
|
||||
setError(result.error || "Failed to find user");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Username submission error:", err);
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTwoFaSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!twoFaCode.trim() || twoFaCode.length !== 6) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userId, code: twoFaCode }),
|
||||
});
|
||||
|
||||
const result: VerifyResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const redirectTo = result.redirectTo || returnTo;
|
||||
if (result.sessionToken) {
|
||||
try {
|
||||
localStorage.setItem('kowalski-session', result.sessionToken);
|
||||
} catch (storageError) {
|
||||
console.error('localStorage error:', storageError);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = redirectTo;
|
||||
} else {
|
||||
setError(result.error || "Invalid 2FA code");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("2FA verification error:", err);
|
||||
console.log("Error details:", {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
stack: err instanceof Error ? err.stack : 'No stack trace',
|
||||
name: err instanceof Error ? err.name : 'Unknown error type'
|
||||
});
|
||||
const errorMessage = err instanceof Error ?
|
||||
`Error: ${err.message}` :
|
||||
"Network error. Please try again.";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setStep("username");
|
||||
setUsername("");
|
||||
setTwoFaCode("");
|
||||
setUserId("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const LoadingSpinner = ({ text }: { text: string }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<TbLoader className="w-4 h-4 animate-spin" />
|
||||
<span className="text-muted-foreground">{text}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<section className="flex flex-col items-center justify-center py-24 px-6 text-center bg-gradient-to-br from-background to-muted flex-1">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 p-4">
|
||||
<RiTelegram2Line className="w-10 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "username" && (
|
||||
<motion.div
|
||||
key="username-form"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="max-w-md mx-auto"
|
||||
>
|
||||
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-4">
|
||||
Login to Kowalski
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed mb-8">
|
||||
Please enter your Telegram username to continue.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleUsernameSubmit} className="max-w-md mx-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter your Telegram username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="text-center text-lg py-6"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
className="text-red-500 text-sm"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div
|
||||
variants={buttonVariants}
|
||||
initial="initial"
|
||||
whileTap={!isLoading && username.trim() ? "tap" : undefined}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!username.trim() || isLoading}
|
||||
className="w-full py-6 text-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner text="Finding your account..." />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === "twofa" && (
|
||||
<motion.div
|
||||
key="twofa-form"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="max-w-md mx-auto"
|
||||
>
|
||||
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-4">
|
||||
Enter 2FA Code
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed mb-8">
|
||||
We've sent a 6-digit code to your Telegram. Please enter it below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleTwoFaSubmit} className="max-w-md mx-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={twoFaCode}
|
||||
onChange={(e) => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
disabled={isLoading}
|
||||
className="text-center text-2xl font-mono tracking-widest py-6"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
className="text-red-500 text-sm"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<motion.div
|
||||
variants={buttonVariants}
|
||||
initial="initial"
|
||||
whileTap="tap"
|
||||
className="flex-1"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetForm}
|
||||
disabled={isLoading}
|
||||
className="w-full py-6"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={buttonVariants}
|
||||
initial="initial"
|
||||
whileTap={!isLoading && twoFaCode.length === 6 ? "tap" : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={twoFaCode.length !== 6 || isLoading}
|
||||
className="w-full py-6 text-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner text="Verifying..." />
|
||||
) : (
|
||||
"Verify"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
251
webui/app/page.tsx
Executable file
251
webui/app/page.tsx
Executable file
|
@ -0,0 +1,251 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Bot,
|
||||
Sparkles,
|
||||
Users,
|
||||
Settings,
|
||||
Download,
|
||||
Brain,
|
||||
Shield,
|
||||
Zap,
|
||||
Tv,
|
||||
Trash,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { SiYoutube, SiForgejo } from "react-icons/si";
|
||||
import { RiTelegram2Line } from "react-icons/ri";
|
||||
import { TbEyeSpark } from "react-icons/tb";
|
||||
import Image from "next/image";
|
||||
import Footer from "@/components/footer";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<section className="flex flex-col items-center justify-center py-24 px-6 text-center bg-gradient-to-br from-background to-muted">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 p-4">
|
||||
<Image
|
||||
src="/kowalski.svg"
|
||||
alt="Kowalski Logo"
|
||||
width={48}
|
||||
height={48}
|
||||
className="dark:invert -mt-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
Kowalski
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||
A powerful, multi-function Telegram bot with AI capabilities, media downloading,
|
||||
user management, and much more. Built for communities and power users.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
|
||||
<Button size="lg" className="min-w-32" asChild>
|
||||
<Link href="https://t.me/KowalskiNodeBot">
|
||||
<RiTelegram2Line />
|
||||
Try on Telegram
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="min-w-32">
|
||||
<Settings />
|
||||
Documentation
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="min-w-32" asChild>
|
||||
<Link href="https://git.p0ntus.com/ABOCN/TelegramBot">
|
||||
<SiForgejo />
|
||||
View on Forgejo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16" id="ai-features">
|
||||
<h2 className="text-4xl font-bold mb-4">Features You'll Love</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Powered by TypeScript, Telegraf, Next.js, and AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-primary/10 text-primary">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">AI Commands</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Interact with over 50 AI models through simple commands. Get intelligent responses,
|
||||
assistance, or problem-solving help right in Telegram.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Bot className="w-5 h-5 mx-3 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">/ai</div>
|
||||
<div className="text-sm text-muted-foreground">Ask questions to a custom AI model of your choice</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Zap className="w-5 h-5 mx-3 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">/ask</div>
|
||||
<div className="text-sm text-muted-foreground">Quick AI responses for everyday questions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Brain className="w-5 h-5 mx-3 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">/think</div>
|
||||
<div className="text-sm text-muted-foreground">Deep reasoning with optional visible thinking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3" id="youtube-features">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-red-500/10 text-red-500">
|
||||
<SiYoutube className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">YouTube/Video Downloads</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Download videos directly from YouTube and other platforms and watch them in Telegram.
|
||||
Supports thousands of sites with integrated yt-dlp.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Download className="w-5 h-5 mx-3 text-red-500" />
|
||||
<div>
|
||||
<div className="font-medium">/yt [URL]</div>
|
||||
<div className="text-sm text-muted-foreground">Quickly download videos up to 50MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Shield className="w-5 h-5 mx-3 text-red-500" />
|
||||
<div>
|
||||
<div className="font-medium">Automatic Ratelimit Detection</div>
|
||||
<div className="text-sm text-muted-foreground">We'll notify you if something goes wrong</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Tv className="w-5 h-5 mx-3 text-red-500" />
|
||||
<div>
|
||||
<div className="font-medium">High Quality Downloads</div>
|
||||
<div className="text-sm text-muted-foreground">Kowalski automatically chooses the best quality for you</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="user-features" className="py-24 px-6 bg-muted/30">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">
|
||||
Control <span className="italic mr-1.5">and</span> Fun
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Your user data is always minimized and under your control. That certainly
|
||||
doesn't mean the experience is lacking!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">User Accounts</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Your user data is linked only by your Telegram ID. No data is ever sent to third parties
|
||||
or used for anything other than providing you with the best experience.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Settings className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">Personal Settings</div>
|
||||
<div className="text-sm text-muted-foreground">Custom AI models, temperature, and language preferences</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Brain className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">Account Statistics</div>
|
||||
<div className="text-sm text-muted-foreground">Track AI requests, characters processed, and more</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Trash className="w-5 h-5 mx-3 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">Leave at Any Time</div>
|
||||
<div className="text-sm text-muted-foreground">We make it easy to delete your account at any time</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-green-500/10 text-green-500">
|
||||
<Bot className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">Web Interface</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Kowalski includes a web interface, made with Next.js, to make it easier to manage your
|
||||
bot, user account, and more. It's tailored to both users and admins.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<TbEyeSpark className="w-5 h-5 mx-3 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">Everything's Clean</div>
|
||||
<div className="text-sm text-muted-foreground">We don't clutter your view with ads or distractions.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Sparkles className="w-5 h-5 mx-3 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">Do Everything!</div>
|
||||
<div className="text-sm text-muted-foreground">We aim to integrate every feature into the web interface.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||
<Lock className="w-5 h-5 mx-3 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="text-sm text-muted-foreground">We don't use any analytics, tracking, or third-party scripts.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
21
webui/components.json
Executable file
21
webui/components.json
Executable file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
434
webui/components/account/ai.ts
Executable file
434
webui/components/account/ai.ts
Executable file
|
@ -0,0 +1,434 @@
|
|||
export interface ModelInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
descriptionEn: string;
|
||||
descriptionPt: string;
|
||||
models: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
parameterSize: string;
|
||||
thinking: boolean;
|
||||
uncensored: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const defaultFlashModel = "gemma3:4b"
|
||||
export const defaultThinkingModel = "qwen3:4b"
|
||||
export const unloadModelAfterB = 12 // how many billion params until model is auto-unloaded
|
||||
export const maxUserQueueSize = 3
|
||||
|
||||
export const models: ModelInfo[] = [
|
||||
{
|
||||
name: 'gemma3n',
|
||||
label: 'gemma3n',
|
||||
descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.',
|
||||
descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
||||
models: [
|
||||
{
|
||||
name: 'gemma3n:e2b',
|
||||
label: 'Gemma3n e2b',
|
||||
parameterSize: '2B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'gemma3n:e4b',
|
||||
label: 'Gemma3n e4b',
|
||||
parameterSize: '4B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'gemma3',
|
||||
label: 'gemma3 [ & Uncensored ]',
|
||||
descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.',
|
||||
descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.',
|
||||
models: [
|
||||
{
|
||||
name: 'huihui_ai/gemma3-abliterated:1b',
|
||||
label: 'Gemma3 Uncensored 1B',
|
||||
parameterSize: '1B',
|
||||
thinking: false,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/gemma3-abliterated:4b',
|
||||
label: 'Gemma3 Uncensored 4B',
|
||||
parameterSize: '4B',
|
||||
thinking: false,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'gemma3:1b',
|
||||
label: 'Gemma3 1B',
|
||||
parameterSize: '1B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'gemma3:4b',
|
||||
label: 'Gemma3 4B',
|
||||
parameterSize: '4B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'qwen3',
|
||||
label: 'Qwen3',
|
||||
descriptionEn: 'Qwen3 is a multilingual reasoning model series.',
|
||||
descriptionPt: 'Qwen3 é uma série de modelos multilingues.',
|
||||
models: [
|
||||
{
|
||||
name: 'qwen3:0.6b',
|
||||
label: 'Qwen3 0.6B',
|
||||
parameterSize: '0.6B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'qwen3:1.7b',
|
||||
label: 'Qwen3 1.7B',
|
||||
parameterSize: '1.7B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'qwen3:4b',
|
||||
label: 'Qwen3 4B',
|
||||
parameterSize: '4B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'qwen3:8b',
|
||||
label: 'Qwen3 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'qwen3:14b',
|
||||
label: 'Qwen3 14B',
|
||||
parameterSize: '14B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'qwen3:30b',
|
||||
label: 'Qwen3 30B',
|
||||
parameterSize: '30B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'qwen3:32b',
|
||||
label: 'Qwen3 32B',
|
||||
parameterSize: '32B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'qwen3-abliterated',
|
||||
label: 'Qwen3 [ Uncensored ]',
|
||||
descriptionEn: 'Qwen3-abliterated is a multilingual reasoning model series.',
|
||||
descriptionPt: 'Qwen3-abliterated é uma série de modelos multilingues.',
|
||||
models: [
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:0.6b',
|
||||
label: 'Qwen3 Uncensored 0.6B',
|
||||
parameterSize: '0.6B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:1.7b',
|
||||
label: 'Qwen3 Uncensored 1.7B',
|
||||
parameterSize: '1.7B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:4b',
|
||||
label: 'Qwen3 Uncensored 4B',
|
||||
parameterSize: '4B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:8b',
|
||||
label: 'Qwen3 Uncensored 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:14b',
|
||||
label: 'Qwen3 Uncensored 14B',
|
||||
parameterSize: '14B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:30b',
|
||||
label: 'Qwen3 Uncensored 30B',
|
||||
parameterSize: '30B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwen3-abliterated:32b',
|
||||
label: 'Qwen3 Uncensored 32B',
|
||||
parameterSize: '32B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'qwq',
|
||||
label: 'QwQ',
|
||||
descriptionEn: 'QwQ is the reasoning model of the Qwen series.',
|
||||
descriptionPt: 'QwQ é o modelo de raciocínio da série Qwen.',
|
||||
models: [
|
||||
{
|
||||
name: 'qwq:32b',
|
||||
label: 'QwQ 32B',
|
||||
parameterSize: '32B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/qwq-abliterated:32b',
|
||||
label: 'QwQ Uncensored 32B',
|
||||
parameterSize: '32B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'llama4',
|
||||
label: 'Llama4',
|
||||
descriptionEn: 'The latest collection of multimodal models from Meta.',
|
||||
descriptionPt: 'A coleção mais recente de modelos multimodais da Meta.',
|
||||
models: [
|
||||
{
|
||||
name: 'llama4:scout',
|
||||
label: 'Llama4 109B A17B',
|
||||
parameterSize: '109B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'deepseek',
|
||||
label: 'DeepSeek [ & Uncensored ]',
|
||||
descriptionEn: 'DeepSeek is a research model for reasoning tasks.',
|
||||
descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.',
|
||||
models: [
|
||||
{
|
||||
name: 'deepseek-r1:1.5b',
|
||||
label: 'DeepSeek 1.5B',
|
||||
parameterSize: '1.5B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'deepseek-r1:7b',
|
||||
label: 'DeepSeek 7B',
|
||||
parameterSize: '7B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'deepseek-r1:8b',
|
||||
label: 'DeepSeek 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/deepseek-r1-abliterated:1.5b',
|
||||
label: 'DeepSeek Uncensored 1.5B',
|
||||
parameterSize: '1.5B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/deepseek-r1-abliterated:7b',
|
||||
label: 'DeepSeek Uncensored 7B',
|
||||
parameterSize: '7B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/deepseek-r1-abliterated:8b',
|
||||
label: 'DeepSeek Uncensored 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/deepseek-r1-abliterated:14b',
|
||||
label: 'DeepSeek Uncensored 14B',
|
||||
parameterSize: '14B',
|
||||
thinking: true,
|
||||
uncensored: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'hermes3',
|
||||
label: 'Hermes3',
|
||||
descriptionEn: 'Hermes 3 is the latest version of the flagship Hermes series of LLMs by Nous Research.',
|
||||
descriptionPt: 'Hermes 3 é a versão mais recente da série Hermes de LLMs da Nous Research.',
|
||||
models: [
|
||||
{
|
||||
name: 'hermes3:3b',
|
||||
label: 'Hermes3 3B',
|
||||
parameterSize: '3B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'hermes3:8b',
|
||||
label: 'Hermes3 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'mistral',
|
||||
label: 'Mistral',
|
||||
descriptionEn: 'The 7B model released by Mistral AI, updated to version 0.3.',
|
||||
descriptionPt: 'O modelo 7B lançado pela Mistral AI, atualizado para a versão 0.3.',
|
||||
models: [
|
||||
{
|
||||
name: 'mistral:7b',
|
||||
label: 'Mistral 7B',
|
||||
parameterSize: '7B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'phi4 [ & Uncensored ]',
|
||||
label: 'Phi4',
|
||||
descriptionEn: 'Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft. ',
|
||||
descriptionPt: 'Phi-4 é um modelo de 14B de última geração, aberto pela Microsoft.',
|
||||
models: [
|
||||
{
|
||||
name: 'hf.co/unsloth/Phi-4-mini-reasoning-GGUF',
|
||||
label: 'Phi4 Mini Reasoning',
|
||||
parameterSize: '4B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'phi4:14b',
|
||||
label: 'Phi4 14B',
|
||||
parameterSize: '14B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'hf.co/unsloth/Phi-4-reasoning-plus-GGUF',
|
||||
label: 'Phi4 Reasoning Plus',
|
||||
parameterSize: '14B',
|
||||
thinking: true,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'huihui_ai/phi4-abliterated:14b',
|
||||
label: 'Phi4 Uncensored 14B',
|
||||
parameterSize: '14B',
|
||||
thinking: false,
|
||||
uncensored: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'phi3',
|
||||
label: 'Phi3',
|
||||
descriptionEn: 'Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft.',
|
||||
descriptionPt: 'Phi-3 é uma família de modelos leves de 3B (Mini) e 14B (Médio) de última geração, abertos pela Microsoft.',
|
||||
models: [
|
||||
{
|
||||
name: 'phi3:3.8b',
|
||||
label: 'Phi3 3.8B',
|
||||
parameterSize: '3.8B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'llama3',
|
||||
label: 'Llama4',
|
||||
descriptionEn: 'Llama 3, a lightweight model from Meta.',
|
||||
descriptionPt: 'Llama 3, um modelo leve da Meta.',
|
||||
models: [
|
||||
{
|
||||
name: 'llama3:8b',
|
||||
label: 'Llama3 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'llama3.1 [ Uncensored ]',
|
||||
label: 'Llama3.1',
|
||||
descriptionEn: 'Ablitered v3 llama-3.1 8b with uncensored prompt ',
|
||||
descriptionPt: 'Llama3.1 é um modelo aberto, leve e para dispositivos locais, com prompt não censurado.',
|
||||
models: [
|
||||
{
|
||||
name: 'mannix/llama3.1-8b-abliterated:latest',
|
||||
label: 'Llama3.1 8B',
|
||||
parameterSize: '8B',
|
||||
thinking: false,
|
||||
uncensored: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'llama3.2 [ & Uncensored ]',
|
||||
label: 'Llama3.2',
|
||||
descriptionEn: 'Llama3.2 is a family of open, lightweight models for general tasks.',
|
||||
descriptionPt: 'Llama3.2 é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
||||
models: [
|
||||
{
|
||||
name: 'llama3.2:1b',
|
||||
label: 'Llama3.2 1B',
|
||||
parameterSize: '1B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'llama3.2:3b',
|
||||
label: 'Llama3.2 3B',
|
||||
parameterSize: '3B',
|
||||
thinking: false,
|
||||
uncensored: false
|
||||
},
|
||||
{
|
||||
name: 'socialnetwooky/llama3.2-abliterated:3b_q8_0',
|
||||
label: 'Llama3.2 Uncensored 3B',
|
||||
parameterSize: '3B',
|
||||
thinking: false,
|
||||
uncensored: true
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
155
webui/components/account/model-picker.tsx
Executable file
155
webui/components/account/model-picker.tsx
Executable file
|
@ -0,0 +1,155 @@
|
|||
"use client"
|
||||
|
||||
/*
|
||||
Adapted from https://ui.shadcn.com/docs/components/combobox
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronsUpDownIcon, Cpu, Brain, ShieldOff } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { models } from "./ai"
|
||||
|
||||
interface ModelPickerProps {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ModelPicker({ value, onValueChange, disabled = false, className }: ModelPickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const currentModel = React.useMemo(() => {
|
||||
for (const category of models) {
|
||||
const model = category.models.find(m => m.name === value)
|
||||
if (model) {
|
||||
return {
|
||||
model,
|
||||
category: category.label,
|
||||
categoryDescription: category.descriptionEn
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [value])
|
||||
|
||||
const handleSelect = (modelName: string) => {
|
||||
onValueChange?.(modelName)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between h-auto p-4", className)}
|
||||
>
|
||||
<div className="flex items-start gap-3 text-left flex-1">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mt-0.5">
|
||||
<Cpu className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{currentModel ? (
|
||||
<>
|
||||
<div className="font-medium text-sm">{currentModel.model.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{currentModel.category}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1 break-words">
|
||||
{currentModel.categoryDescription}
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 mt-2 flex-wrap">
|
||||
<span className="px-2 py-0.5 bg-muted rounded text-xs font-mono">
|
||||
{currentModel.model.parameterSize}
|
||||
</span>
|
||||
{currentModel.model.thinking && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Brain />
|
||||
Thinking
|
||||
</span>
|
||||
)}
|
||||
{currentModel.model.uncensored && (
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded text-xs font-medium flex items-center gap-1">
|
||||
<ShieldOff />
|
||||
Uncensored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">Select a model...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList className="max-h-[300px]">
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
{models.map((category) => (
|
||||
<CommandGroup key={category.name} heading={category.label}>
|
||||
<div className="pb-2 ml-2 mb-1 text-xs text-muted-foreground/70">
|
||||
{category.descriptionEn}
|
||||
</div>
|
||||
{category.models.map((model) => (
|
||||
<CommandItem
|
||||
key={model.name}
|
||||
value={`${category.label} ${model.label} ${model.parameterSize}`}
|
||||
onSelect={() => handleSelect(model.name)}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
value === model.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{model.label}</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="px-2 py-0.5 bg-muted rounded text-xs font-mono">
|
||||
{model.parameterSize}
|
||||
</span>
|
||||
{model.thinking && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Brain />
|
||||
Thinking
|
||||
</span>
|
||||
)}
|
||||
{model.uncensored && (
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded text-xs font-medium flex items-center gap-1">
|
||||
<ShieldOff />
|
||||
Uncensored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
232
webui/components/app-sidebar.tsx
Executable file
232
webui/components/app-sidebar.tsx
Executable file
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Home,
|
||||
MessageSquare,
|
||||
Users,
|
||||
Sparkles,
|
||||
User,
|
||||
Trash2,
|
||||
LogOut
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { SiYoutube } from "react-icons/si"
|
||||
import { RiTelegram2Line } from "react-icons/ri"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface AccountItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
title: "Home",
|
||||
url: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
url: "/about",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
]
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "AI Commands",
|
||||
url: "/#ai-features",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
title: "Video Download",
|
||||
url: "/#youtube-features",
|
||||
icon: SiYoutube,
|
||||
},
|
||||
{
|
||||
title: "User Accounts & UI",
|
||||
url: "/#user-features",
|
||||
icon: Users,
|
||||
},
|
||||
]
|
||||
|
||||
export function AppSidebar() {
|
||||
const { isAuthenticated, loading, logout } = useAuth();
|
||||
const { setOpenMobile, isMobile } = useSidebar();
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
if (isMobile) {
|
||||
setOpenMobile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const accountItems: AccountItem[] = React.useMemo(() => {
|
||||
if (loading) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return [
|
||||
{
|
||||
title: "My Account",
|
||||
url: "/account",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: "Logout",
|
||||
url: "#",
|
||||
icon: LogOut,
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
title: "Delete Account",
|
||||
url: "/account/delete",
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
title: "Sign in with Telegram",
|
||||
url: "/login",
|
||||
icon: RiTelegram2Line,
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [isAuthenticated, loading]);
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<div className="flex flex-row justify-between gap-3">
|
||||
<Link href="/" className="flex flex-row gap-2" onClick={handleMenuItemClick}>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary/10 p-1">
|
||||
<Image
|
||||
src="/kowalski.svg"
|
||||
alt="Kowalski Logo"
|
||||
width={20}
|
||||
height={20}
|
||||
className="dark:invert -mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold text-lg">Kowalski</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Badge className="text-xs">Beta</Badge>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navigation.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url} onClick={handleMenuItemClick}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{!loading && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Account</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{accountItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
{item.title === "Logout" ? (
|
||||
<SidebarMenuButton
|
||||
onClick={() => {
|
||||
logout();
|
||||
handleMenuItemClick();
|
||||
}}
|
||||
className={item.danger ? "text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" : ""}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton asChild>
|
||||
<Link
|
||||
href={item.url}
|
||||
onClick={handleMenuItemClick}
|
||||
className={item.danger ? "text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" : ""}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Features</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{features.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url} onClick={handleMenuItemClick}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center justify-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
24
webui/components/footer.tsx
Executable file
24
webui/components/footer.tsx
Executable file
|
@ -0,0 +1,24 @@
|
|||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="py-12 px-6 border-t bg-background">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Image
|
||||
src="/kowalski.svg"
|
||||
alt="Kowalski"
|
||||
width={22}
|
||||
height={22}
|
||||
className="mr-2 dark:invert -mt-1"
|
||||
/>
|
||||
<span className="text-xl font-semibold">Kowalski</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Built with ❤️ by <Link href="https://git.p0ntus.com/ABOCN" className="underline hover:text-primary transition-all duration-300">ABOCN</Link> and contributors under open source licenses.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
29
webui/components/header-auth.tsx
Executable file
29
webui/components/header-auth.tsx
Executable file
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiTelegram2Line } from "react-icons/ri";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
export function HeaderAuth() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-8 h-8 animate-pulse bg-muted rounded-md" />
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/login">
|
||||
<RiTelegram2Line />
|
||||
Sign in with Telegram
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
21
webui/components/providers.tsx
Executable file
21
webui/components/providers.tsx
Executable file
|
@ -0,0 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
37
webui/components/theme-toggle.tsx
Executable file
37
webui/components/theme-toggle.tsx
Executable file
|
@ -0,0 +1,37 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
46
webui/components/ui/badge.tsx
Normal file
46
webui/components/ui/badge.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
59
webui/components/ui/button.tsx
Executable file
59
webui/components/ui/button.tsx
Executable file
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
184
webui/components/ui/command.tsx
Executable file
184
webui/components/ui/command.tsx
Executable file
|
@ -0,0 +1,184 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
143
webui/components/ui/dialog.tsx
Executable file
143
webui/components/ui/dialog.tsx
Executable file
|
@ -0,0 +1,143 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
21
webui/components/ui/input.tsx
Executable file
21
webui/components/ui/input.tsx
Executable file
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
48
webui/components/ui/popover.tsx
Executable file
48
webui/components/ui/popover.tsx
Executable file
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
28
webui/components/ui/separator.tsx
Executable file
28
webui/components/ui/separator.tsx
Executable file
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
139
webui/components/ui/sheet.tsx
Executable file
139
webui/components/ui/sheet.tsx
Executable file
|
@ -0,0 +1,139 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
726
webui/components/ui/sidebar.tsx
Executable file
726
webui/components/ui/sidebar.tsx
Executable file
|
@ -0,0 +1,726 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
13
webui/components/ui/skeleton.tsx
Executable file
13
webui/components/ui/skeleton.tsx
Executable file
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
66
webui/components/ui/tabs.sh.tsx
Executable file
66
webui/components/ui/tabs.sh.tsx
Executable file
|
@ -0,0 +1,66 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
127
webui/components/ui/tabs.tsx
Executable file
127
webui/components/ui/tabs.tsx
Executable file
|
@ -0,0 +1,127 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
} | null>(null)
|
||||
|
||||
interface TabsProps {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
|
||||
({ className, value, onValueChange, children, ...props }, ref) => {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div ref={ref} className={cn("", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Tabs.displayName = "Tabs"
|
||||
|
||||
interface TabsListProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsList.displayName = "TabsList"
|
||||
|
||||
interface TabsTriggerProps {
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||
({ className, children, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("TabsTrigger must be used within Tabs")
|
||||
}
|
||||
|
||||
const { value: currentValue, onValueChange } = context
|
||||
const isActive = currentValue === value
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/50 hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={() => onValueChange(value)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsTrigger.displayName = "TabsTrigger"
|
||||
|
||||
interface TabsContentProps {
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||
({ className, children, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("TabsContent must be used within Tabs")
|
||||
}
|
||||
|
||||
const { value: currentValue } = context
|
||||
|
||||
if (currentValue !== value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsContent.displayName = "TabsContent"
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
61
webui/components/ui/tooltip.tsx
Executable file
61
webui/components/ui/tooltip.tsx
Executable file
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
130
webui/contexts/auth-context.tsx
Executable file
130
webui/contexts/auth-context.tsx
Executable file
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface UserData {
|
||||
telegramId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
aiEnabled: boolean;
|
||||
showThinking: boolean;
|
||||
customAiModel: string;
|
||||
aiTemperature: number;
|
||||
aiRequests: number;
|
||||
aiCharacters: number;
|
||||
disabledCommands: string[];
|
||||
languageCode: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserData | null;
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = localStorage.getItem('kowalski-session');
|
||||
|
||||
if (!sessionToken) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/user/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
setUser(userData);
|
||||
} else {
|
||||
setUser(null);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('kowalski-session');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user data:', error);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const sessionToken = localStorage.getItem('kowalski-session');
|
||||
if (sessionToken) {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionToken}`
|
||||
}
|
||||
});
|
||||
}
|
||||
localStorage.removeItem('kowalski-session');
|
||||
}
|
||||
setUser(null);
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('kowalski-session');
|
||||
}
|
||||
setUser(null);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
await fetchUser();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
11
webui/drizzle.config.ts
Executable file
11
webui/drizzle.config.ts
Executable file
|
@ -0,0 +1,11 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './lib/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.databaseUrl!,
|
||||
},
|
||||
});
|
16
webui/eslint.config.mjs
Executable file
16
webui/eslint.config.mjs
Executable file
|
@ -0,0 +1,16 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
19
webui/hooks/use-mobile.ts
Executable file
19
webui/hooks/use-mobile.ts
Executable file
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
2
webui/lib/auth-constants.ts
Executable file
2
webui/lib/auth-constants.ts
Executable file
|
@ -0,0 +1,2 @@
|
|||
export const SESSION_COOKIE_NAME = "kowalski-session";
|
||||
export const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000;
|
135
webui/lib/auth-helpers.ts
Executable file
135
webui/lib/auth-helpers.ts
Executable file
|
@ -0,0 +1,135 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { validateSession } from './auth';
|
||||
import { SESSION_COOKIE_NAME } from './auth-constants';
|
||||
|
||||
export async function requireAuth(request: NextRequest) {
|
||||
const sessionToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionData = await validateSession(sessionToken);
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
throw NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
export async function validateJsonRequest(request: NextRequest) {
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
throw NextResponse.json({ error: "Invalid content type" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
return body;
|
||||
} catch {
|
||||
throw NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export function validateString(value: unknown, fieldName: string, minLength = 1, maxLength = 1000): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw NextResponse.json({ error: `${fieldName} must be a string` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (value.length < minLength || value.length > maxLength) {
|
||||
throw NextResponse.json({
|
||||
error: `${fieldName} must be between ${minLength} and ${maxLength} characters`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateArray(value: unknown, fieldName: string, maxLength = 100): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw NextResponse.json({ error: `${fieldName} must be an array` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (value.length > maxLength) {
|
||||
throw NextResponse.json({
|
||||
error: `${fieldName} cannot have more than ${maxLength} items`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateNumber(value: unknown, fieldName: string, min?: number, max?: number): number {
|
||||
const num = Number(value);
|
||||
|
||||
if (isNaN(num)) {
|
||||
throw NextResponse.json({ error: `${fieldName} must be a valid number` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (min !== undefined && num < min) {
|
||||
throw NextResponse.json({ error: `${fieldName} must be at least ${min}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (max !== undefined && num > max) {
|
||||
throw NextResponse.json({ error: `${fieldName} must be at most ${max}` }, { status: 400 });
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
export function handleApiError(error: unknown, operation: string) {
|
||||
console.error(`Error in ${operation}:`, error);
|
||||
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
error: "Internal server error"
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
|
||||
|
||||
export function rateLimit(identifier: string, maxAttempts = 5, windowMs = 15 * 60 * 1000) {
|
||||
const now = Date.now();
|
||||
const key = identifier;
|
||||
const record = rateLimitMap.get(key);
|
||||
|
||||
if (!record) {
|
||||
rateLimitMap.set(key, { count: 1, timestamp: now });
|
||||
return { allowed: true, remaining: maxAttempts - 1 };
|
||||
}
|
||||
|
||||
if (now - record.timestamp > windowMs) {
|
||||
rateLimitMap.set(key, { count: 1, timestamp: now });
|
||||
return { allowed: true, remaining: maxAttempts - 1 };
|
||||
}
|
||||
|
||||
record.count++;
|
||||
|
||||
if (record.count > maxAttempts) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
|
||||
return { allowed: true, remaining: maxAttempts - record.count };
|
||||
}
|
||||
|
||||
export function cleanupRateLimit() {
|
||||
const now = Date.now();
|
||||
const windowMs = 15 * 60 * 1000; // 15m
|
||||
|
||||
for (const [key, record] of rateLimitMap.entries()) {
|
||||
if (now - record.timestamp > windowMs) {
|
||||
rateLimitMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(cleanupRateLimit, 10 * 60 * 1000);
|
148
webui/lib/auth.ts
Executable file
148
webui/lib/auth.ts
Executable file
|
@ -0,0 +1,148 @@
|
|||
import { eq, and, gt, lt } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { sessionsTable, usersTable } from "./schema";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
export interface SessionData {
|
||||
id: string;
|
||||
userId: string;
|
||||
sessionToken: string;
|
||||
expiresAt: Date;
|
||||
user?: {
|
||||
telegramId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
aiEnabled: boolean;
|
||||
showThinking: boolean;
|
||||
customAiModel: string;
|
||||
aiTemperature: number;
|
||||
aiRequests: number;
|
||||
aiCharacters: number;
|
||||
disabledCommands: string[];
|
||||
languageCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
import { SESSION_COOKIE_NAME, SESSION_DURATION } from "./auth-constants";
|
||||
|
||||
export { SESSION_COOKIE_NAME };
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<SessionData> {
|
||||
const sessionId = generateSessionId();
|
||||
const sessionToken = generateSessionToken();
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||
|
||||
await db.delete(sessionsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionsTable.userId, userId),
|
||||
lt(sessionsTable.expiresAt, new Date())
|
||||
)
|
||||
);
|
||||
|
||||
const [session] = await db.insert(sessionsTable)
|
||||
.values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
sessionToken,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateSession(sessionToken: string): Promise<SessionData | null> {
|
||||
if (!sessionToken || typeof sessionToken !== 'string' || sessionToken.length < 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionWithUser = await db
|
||||
.select({
|
||||
session: sessionsTable,
|
||||
user: usersTable,
|
||||
})
|
||||
.from(sessionsTable)
|
||||
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.telegramId))
|
||||
.where(
|
||||
and(
|
||||
eq(sessionsTable.sessionToken, sessionToken),
|
||||
gt(sessionsTable.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (sessionWithUser.length === 0) {
|
||||
await cleanupExpiredSessions();
|
||||
return null;
|
||||
}
|
||||
|
||||
const { session, user } = sessionWithUser[0];
|
||||
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const timeUntilExpiry = session.expiresAt.getTime() - Date.now();
|
||||
|
||||
if (timeUntilExpiry < oneDay) {
|
||||
const newExpiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||
await db.update(sessionsTable)
|
||||
.set({ expiresAt: newExpiresAt })
|
||||
.where(eq(sessionsTable.id, session.id));
|
||||
|
||||
session.expiresAt = newExpiresAt;
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
sessionToken: session.sessionToken,
|
||||
expiresAt: session.expiresAt,
|
||||
user: {
|
||||
telegramId: user.telegramId,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
aiEnabled: user.aiEnabled,
|
||||
showThinking: user.showThinking,
|
||||
customAiModel: user.customAiModel,
|
||||
aiTemperature: user.aiTemperature,
|
||||
aiRequests: user.aiRequests,
|
||||
aiCharacters: user.aiCharacters,
|
||||
disabledCommands: user.disabledCommands || [],
|
||||
languageCode: user.languageCode,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error validating session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionToken: string): Promise<void> {
|
||||
await db.delete(sessionsTable)
|
||||
.where(eq(sessionsTable.sessionToken, sessionToken));
|
||||
}
|
||||
|
||||
export async function cleanupExpiredSessions(): Promise<void> {
|
||||
await db.delete(sessionsTable)
|
||||
.where(lt(sessionsTable.expiresAt, new Date()));
|
||||
}
|
||||
|
||||
export function getSessionCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
maxAge: SESSION_DURATION / 1000,
|
||||
path: "/",
|
||||
};
|
||||
}
|
13
webui/lib/db.ts
Executable file
13
webui/lib/db.ts
Executable file
|
@ -0,0 +1,13 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.databaseUrl,
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
});
|
52
webui/lib/schema.ts
Executable file
52
webui/lib/schema.ts
Executable file
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
integer,
|
||||
pgTable,
|
||||
varchar,
|
||||
timestamp,
|
||||
boolean,
|
||||
real,
|
||||
index
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const usersTable = pgTable("users", {
|
||||
telegramId: varchar({ length: 255 }).notNull().primaryKey(),
|
||||
username: varchar({ length: 255 }).notNull(),
|
||||
firstName: varchar({ length: 255 }).notNull(),
|
||||
lastName: varchar({ length: 255 }).notNull(),
|
||||
aiEnabled: boolean().notNull().default(false),
|
||||
showThinking: boolean().notNull().default(false),
|
||||
customAiModel: varchar({ length: 255 }).notNull().default("deepseek-r1:1.5b"),
|
||||
aiTemperature: real().notNull().default(0.9),
|
||||
aiRequests: integer().notNull().default(0),
|
||||
aiCharacters: integer().notNull().default(0),
|
||||
disabledCommands: varchar({ length: 255 }).array().notNull().default([]),
|
||||
languageCode: varchar({ length: 255 }).notNull(),
|
||||
aiTimeoutUntil: timestamp(),
|
||||
aiMaxExecutionTime: integer().default(0),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const twoFactorTable = pgTable("two_factor", {
|
||||
userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId).primaryKey(),
|
||||
currentCode: varchar({ length: 255 }).notNull(),
|
||||
codeExpiresAt: timestamp().notNull(),
|
||||
codeAttempts: integer().notNull().default(0),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index("idx_two_factor_user_id").on(table.userId),
|
||||
index("idx_two_factor_code_expires_at").on(table.codeExpiresAt),
|
||||
]);
|
||||
|
||||
export const sessionsTable = pgTable("sessions", {
|
||||
id: varchar({ length: 255 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId),
|
||||
sessionToken: varchar({ length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index("idx_sessions_user_id").on(table.userId),
|
||||
index("idx_sessions_expires_at").on(table.expiresAt),
|
||||
]);
|
6
webui/lib/utils.ts
Executable file
6
webui/lib/utils.ts
Executable file
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
47
webui/middleware.ts
Executable file
47
webui/middleware.ts
Executable file
|
@ -0,0 +1,47 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { SESSION_COOKIE_NAME } from '@/lib/auth-constants';
|
||||
|
||||
const protectedApiRoutes = [
|
||||
'/api/user/profile',
|
||||
'/api/user/settings',
|
||||
'/api/user/delete',
|
||||
];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
const sessionToken = bearerToken || cookieToken;
|
||||
|
||||
const isProtectedApiRoute = protectedApiRoutes.some(route =>
|
||||
pathname === route || pathname.startsWith(route + '/')
|
||||
);
|
||||
|
||||
if (isProtectedApiRoute && !sessionToken) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Authentication required' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === '/login' && sessionToken) {
|
||||
return NextResponse.redirect(new URL('/account', request.url));
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-XSS-Protection', '1; mode=block');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
};
|
7
webui/next.config.ts
Executable file
7
webui/next.config.ts
Executable file
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
44
webui/package.json
Executable file
44
webui/package.json
Executable file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "webui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"motion": "^12.23.0",
|
||||
"next": "15.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
5
webui/postcss.config.mjs
Executable file
5
webui/postcss.config.mjs
Executable file
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
1
webui/public/kowalski.svg
Executable file
1
webui/public/kowalski.svg
Executable file
|
@ -0,0 +1 @@
|
|||
<svg id="svg" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="400" viewBox="0, 0, 400,400"><g id="svgg"><path id="path0" d="M179.297 50.376 C 164.092 53.168,147.855 61.349,131.479 74.468 C 113.775 88.651,105.218 103.233,92.361 141.126 C 78.387 182.313,68.874 223.118,48.583 328.906 C 42.461 360.825,38.004 394.166,39.214 398.989 L 39.468 400.000 218.765 400.000 L 398.062 400.000 397.809 398.926 C 395.000 386.997,393.091 381.753,389.221 375.330 C 386.867 371.423,384.640 368.274,373.310 352.842 C 365.699 342.475,359.054 331.666,347.665 311.133 C 341.277 299.615,327.304 275.792,319.140 262.500 C 301.796 234.261,299.201 227.435,298.428 208.024 C 297.409 182.454,294.676 167.052,285.498 135.156 C 278.422 110.564,269.344 94.344,254.114 79.080 C 233.735 58.655,201.519 46.295,179.297 50.376 M248.799 106.489 C 261.588 113.493,267.969 126.130,269.712 147.904 C 270.757 160.959,271.922 164.811,277.307 173.024 C 287.186 188.091,288.511 195.505,285.231 217.383 C 282.919 232.807,283.079 236.616,286.314 243.164 C 288.475 247.539,298.449 263.364,300.698 265.988 C 306.079 272.264,307.804 275.534,311.451 286.369 C 313.507 292.477,314.275 295.779,316.038 306.092 C 318.955 323.145,323.794 340.998,328.706 352.832 C 329.053 353.668,328.477 353.717,327.031 352.973 C 317.514 348.079,306.139 347.859,297.011 352.392 L 293.046 354.362 290.008 353.902 C 285.368 353.198,278.329 351.147,269.938 348.053 C 240.075 337.042,227.498 340.095,217.498 360.781 C 216.210 363.445,215.063 365.625,214.949 365.625 C 214.835 365.625,212.348 363.747,209.422 361.451 C 206.496 359.155,197.773 352.358,190.039 346.345 C 168.528 329.625,163.860 325.786,148.421 312.120 C 144.652 308.784,138.722 303.594,135.241 300.586 C 125.704 292.343,125.397 290.429,130.287 269.695 C 134.458 252.013,134.120 248.138,127.335 235.860 C 118.910 220.615,116.802 212.186,118.504 200.543 C 119.671 192.555,120.387 190.606,124.348 184.630 C 130.549 175.276,130.610 174.884,127.001 167.653 C 120.735 155.103,119.360 142.129,123.311 132.841 C 126.621 125.061,135.901 110.371,138.603 108.638 C 149.303 101.772,171.655 109.150,195.910 127.554 C 209.712 138.026,217.301 140.791,222.032 137.070 C 223.212 136.141,226.543 129.441,229.196 122.656 C 235.502 106.533,240.841 102.130,248.799 106.489 M236.340 143.691 C 230.683 149.637,232.688 170.703,238.910 170.703 C 244.798 170.703,247.446 154.748,243.048 145.766 C 241.118 141.824,238.798 141.107,236.340 143.691 M166.625 145.801 C 161.260 151.182,162.200 169.876,167.956 172.260 C 171.312 173.650,174.196 169.334,174.921 161.837 C 176.037 150.290,171.292 141.119,166.625 145.801 M210.742 166.483 C 210.313 166.727,208.139 168.661,205.912 170.780 C 192.832 183.226,177.161 190.913,152.344 197.056 C 142.302 199.542,142.081 199.608,141.269 200.331 C 138.124 203.130,139.040 206.449,145.002 213.857 C 153.978 225.011,161.812 227.818,190.234 230.066 C 204.734 231.213,213.693 232.795,230.469 237.170 C 241.058 239.932,240.906 239.903,242.542 239.463 C 245.231 238.739,245.970 237.767,249.800 229.930 C 251.807 225.822,254.812 220.352,256.478 217.773 C 274.939 189.203,275.362 186.052,260.938 184.575 C 241.342 182.569,228.672 178.037,219.653 169.807 C 215.348 165.879,213.210 165.081,210.742 166.483 M216.988 180.273 C 224.271 189.549,229.777 201.830,235.700 222.012 C 237.035 226.558,237.043 226.636,236.205 226.410 C 217.682 221.409,205.456 219.237,189.844 218.172 C 174.731 217.142,167.844 216.033,162.791 213.815 C 156.379 211.002,149.455 205.078,152.577 205.078 C 154.767 205.078,173.978 199.068,180.753 196.263 C 191.616 191.766,201.332 185.333,208.970 177.578 L 211.886 174.618 213.210 175.883 C 213.938 176.579,215.638 178.555,216.988 180.273 M241.016 188.474 C 245.324 189.716,252.551 191.076,257.715 191.617 C 259.058 191.758,260.156 191.965,260.156 192.077 C 260.156 192.456,257.282 197.041,252.129 204.883 C 246.676 213.181,243.750 217.912,243.750 218.432 C 243.750 219.784,242.931 218.154,242.193 215.332 C 239.604 205.442,234.470 192.782,230.002 185.275 L 229.259 184.026 232.891 185.603 C 234.889 186.471,238.545 187.763,241.016 188.474 " stroke="none" fill="#000000" fill-rule="evenodd"></path></g></svg>
|
After Width: | Height: | Size: 4.1 KiB |
27
webui/tsconfig.json
Executable file
27
webui/tsconfig.json
Executable file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue