update login page and operation interface

This commit is contained in:
Zhang Minghan 2023-09-04 17:03:06 +08:00
parent c4b84f038b
commit 36ea2ba04e
31 changed files with 2188 additions and 25 deletions

View File

@ -11,7 +11,14 @@
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toast": "^1.1.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.6",
"@reduxjs/toolkit": "^1.9.5",
"axios": "^1.5.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"localforage": "^1.10.0",
@ -19,7 +26,10 @@
"match-sorter": "^6.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"react-syntax-highlighter": "^15.5.0",
"sort-by": "^1.2.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7"

955
renio/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,19 +3,75 @@ import "./assets/navbar.less";
import ModeToggle, {ThemeProvider} from "./components/ThemeProvider.tsx";
import {Button} from "./components/ui/button.tsx";
import router from "./router.ts";
import I18nProvider from "./components/I18nProvider.tsx";
import ProjectLink from "./components/ProjectLink.tsx";
import {Menu} from "lucide-react";
import {Provider, useDispatch, useSelector} from "react-redux";
import {toggleMenu} from "./store/menu.ts";
import store from "./store/index.ts";
import {logout, selectAuthenticated, selectUsername, validateToken} from "./store/auth.ts";
import {useEffect} from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./components/ui/dropdown-menu.tsx";
import {Toaster} from "./components/ui/toaster.tsx";
import {login} from "./conf.ts";
function login() {
location.href = "https://deeptrain.lightxi.com/login?app=chatnio"
function Settings() {
const dispatch = useDispatch();
const username = useSelector(selectUsername);
return (
<div className={`avatar`}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={`ghost`} size={`icon`}>
<img src={`https://api.deeptrain.net/avatar/${username}`} alt="" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`}>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Quota</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button size={`sm`} className={`action-button`} onClick={() => dispatch(logout())}>
Logout
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function NavBar() {
const dispatch = useDispatch();
useEffect(() => {
validateToken(dispatch, localStorage.getItem("token") ?? "");
}, []);
const auth = useSelector(selectAuthenticated);
return (
<nav className={`navbar`}>
<div className={`items`}>
<Button size={`icon`} variant={`ghost`} onClick={() => dispatch(toggleMenu())}>
<Menu />
</Button>
<img className={`logo`} src="/favicon.ico" alt="" onClick={() => router.navigate('/')} />
<div className={`grow`} />
<ProjectLink />
<ModeToggle />
<Button size={`sm`} onClick={login}>Login</Button>
<I18nProvider />
{
auth ?
<Settings />
: <Button size={`sm`} onClick={login}>Login</Button>
}
</div>
</nav>
)
@ -23,11 +79,12 @@ function NavBar() {
function App() {
return (
<>
<Provider store={store}>
<NavBar />
<ThemeProvider />
<RouterProvider router={router} />
</>
<Toaster />
</Provider>
)
}

View File

@ -0,0 +1,6 @@
.auth {
width: 100%;
height: calc(100vh - 56px);
overflow: hidden;
background: hsl(var(--background-container));
}

View File

@ -23,8 +23,9 @@
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 240 4.8% 95.9%;
--accent-secondary: 240 5.9% 10%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
@ -32,6 +33,8 @@
--border: 42 24.2% 92.2%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--text: 0 0% 0%;
--text-secondary: 0 0% 20%;
--radius: 0.5rem;
@ -58,15 +61,18 @@
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 240 3.7% 15.9%;
--accent-secondary: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 0 0% 20%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--text: 0 0% 100%;
--text-secondary: 0 0% 80%;
--background-sidebar: rgba(0, 0, 0, 0.25);
}

View File

@ -11,15 +11,121 @@
.sidebar {
display: flex;
flex-direction: column;
width: 260px;
width: 0;
height: 100%;
padding: 0;
margin: 0;
background: var(--background-sidebar);
transition: 0.2s ease-in-out;
transition-property: width, background, box-shadow;
border-right: 0;
&.open {
width: 260px;
border-right: 1px solid hsl(var(--border));
}
.login-action {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
margin: auto;
width: max-content;
svg {
transform: translateY(1px);
}
}
@media (max-width: 768px) {
&.open {
width: max(30vw, 180px);
}
}
@media (max-width: 468px) {
// sidebar collapsed
&.open {
width: 100% !important;
}
&.open ~ .chat-container {
width: 0;
}
}
}
.chat-container {
display: flex;
flex-direction: column;
flex-grow: 1;
height: 100%;
background: hsl(var(--background-container));
transition: width 0.2s ease-in-out;
.chat-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 12px;
}
.tooltip {
user-select: none;
strong {
font-weight: 600 !important;
}
}
.chat-content {
flex-grow: 1;
width: 100%;
overflow: hidden;
padding: 6px;
}
.chat-input {
height: max-content;
width: 100%;
overflow: hidden;
padding: 6px 24px;
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
width: 100%;
gap: 4px;
height: min-content;
.input-box {
text-align: center;
color: hsl(var(--text));
}
.input-box::placeholder {
color: hsl(var(--text-secondary));
}
.send-button {
padding: 0 6px;
}
.send-button svg {
fill: hsl(var(--text));
}
}
.input-options {
width: max-content;
margin: 16px auto 2px;
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
height: min-content;
}
}
}

View File

@ -0,0 +1,68 @@
.loader-wrapper {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
vertical-align: center;
text-align: center;
top: 50%;
left: 50%;
gap: 20px;
transform: translate(-50%, -50%);
margin-top: -28px;
p {
text-align: center;
user-select: none;
&:after {
content: '.';
color: hsl(var(--text-secondary));
animation: dots 1s steps(5, end) infinite;
@keyframes dots {
0%, 20% {
color: rgba(0, 0, 0, 0);
text-shadow:
.25em 0 0 rgba(0, 0, 0, 0),
.5em 0 0 rgba(0, 0, 0, 0);
}
40% {
color: hsl(var(--text-secondary));
text-shadow:
.25em 0 0 rgba(0, 0, 0, 0),
.5em 0 0 rgba(0, 0, 0, 0);
}
60% {
text-shadow:
.25em 0 0 hsl(var(--text-secondary)),
.5em 0 0 rgba(0, 0, 0, 0);
}
80%, 100% {
text-shadow:
.25em 0 0 hsl(var(--text-secondary)),
.5em 0 0 hsl(var(--text-secondary));
}
}
}
}
.loader {
border: 4px solid hsl(var(--text));
border-left-color: transparent;
border-radius: 50%;
width: 46px;
height: 46px;
animation: SpinAnimation 1s linear infinite;
@keyframes SpinAnimation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
}

View File

@ -15,6 +15,10 @@ html, body {
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
:root {
@ -31,6 +35,29 @@ html, body {
-webkit-text-size-adjust: @-webkit-text-size-adjust;
}
* {
outline: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
.grow {
flex-grow: 1;
}
strong {
font-weight: bold;
}
.hover\:bg-accent[aria-pressed="false"] {
color: hsl(var(--text-secondary));
&:hover {
background: hsl(var(--accent-secondary));
}
}
.hover\:bg-accent[aria-pressed="true"] {
background: hsl(var(--accent));
color: hsl(var(--text));
}

View File

@ -17,7 +17,7 @@
flex-direction: row;
align-content: center;
vertical-align: center;
gap: 8px;
gap: 10px;
}
.logo {
@ -27,3 +27,32 @@
cursor: pointer;
}
}
.avatar {
outline: 0;
user-select: none;
img {
width: 40px;
height: 40px;
padding: 2px;
border-radius: var(--radius);
cursor: pointer;
}
}
div[data-radix-popper-content-wrapper=""] {
user-select: none;
div.relative {
cursor: pointer;
}
.action-button {
width: 100%;
cursor: pointer;
margin-top: 6px;
margin-bottom: 2px;
height: max-content !important;
}
}

View File

@ -0,0 +1,26 @@
import {Button} from "./ui/button.tsx";
import {Languages} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu.tsx";
function I18nProvider() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Languages className="absolute h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem>English</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default I18nProvider

View File

@ -0,0 +1,17 @@
import '../assets/loader.less'
type LoaderProps = {
className?: string,
prompt?: string,
}
function Loader({ className, prompt }: LoaderProps) {
return (
<div className={`loader-wrapper ${className}`}>
<div className={`loader`} />
<p>{prompt}</p>
</div>
)
}
export default Loader

View File

@ -0,0 +1,13 @@
import {Button} from "./ui/button.tsx";
function ProjectLink() {
return (
<Button variant="outline" size="icon" onClick={() => window.open("https://github.com/zmh-program/chatnio")}>
<svg viewBox="0 0 438.549 438.549" className="absolute h-[1.2rem] w-[1.2rem] rotate-0 scale-100">
<path fill="currentColor" d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"></path>
</svg>
</Button>
);
}
export default ProjectLink

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "./lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "./lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "./lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "./lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "./toast"
import { useToast } from "./use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "./lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "./toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

17
renio/src/conf.ts Normal file
View File

@ -0,0 +1,17 @@
import axios from "axios";
export const deploy: boolean = true;
export let rest_api: string = "http://localhost:8094";
export let ws_api: string = "ws://localhost:8094";
if (deploy) {
rest_api = "https://nioapi.fystart.cn";
ws_api = "wss://nioapi.fystart.cn";
}
export function login() {
location.href = "https://deeptrain.lightxi.com/login?app=chatnio"
}
axios.defaults.baseURL = rest_api;
axios.defaults.headers.post["Content-Type"] = "application/json";

View File

@ -1,8 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './conf.ts'
import './assets/main.less'
import '../app/globals.css'
import './assets/globals.less'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -1,6 +1,7 @@
import {createBrowserRouter} from "react-router-dom";
import Home from "./routes/Home.tsx";
import NotFound from "./routes/NotFound.tsx";
import Auth from "./routes/Auth.tsx";
const router = createBrowserRouter([
{
@ -8,7 +9,13 @@ const router = createBrowserRouter([
path: '/',
Component: Home,
ErrorBoundary: NotFound,
}
},
{
id: 'login',
path: '/login',
Component: Auth,
ErrorBoundary: NotFound,
},
]);
export default router;

72
renio/src/routes/Auth.tsx Normal file
View File

@ -0,0 +1,72 @@
import {useToast} from "../components/ui/use-toast.ts";
import {useLocation} from "react-router-dom";
import {ToastAction} from "../components/ui/toast.tsx";
import {login} from "../conf.ts";
import {useEffect} from "react";
import Loader from "../components/Loader.tsx";
import '../assets/auth.less';
import axios from "axios";
import {validateToken} from "../store/auth.ts";
import {useDispatch} from "react-redux";
import router from "../router.ts";
function Auth() {
const { toast } = useToast();
const dispatch = useDispatch();
const search = new URLSearchParams(useLocation().search);
const token = (search.get("token") || "").trim();
if (!token.length) {
toast({
title: "Invalid token",
description: "Please try again.",
action: (
<ToastAction altText={"Try again"} onClick={login}>Try again</ToastAction>
)
});
setTimeout(login, 2500);
}
useEffect(() => {
axios.post('/login', { token })
.then((res) => {
const data = res.data;
if (!data.status) {
toast({
title: "Invalid token",
description: "Login failed! Please check your token expiration and try again.",
action: (
<ToastAction altText={"Try again"} onClick={login}>Try again</ToastAction>
)
});
}
else validateToken(dispatch, data.token, () => {
toast({
title: "Login successful",
description: "You have been logged in successfully.",
});
router.navigate('/');
});
})
.catch((err) => {
console.debug(err);
toast({
title: "Server error",
description: "There was an error logging you in. Please try again.",
action: (
<ToastAction altText={"Try again"} onClick={login}>Try again</ToastAction>
)
});
});
}, []);
return (
<div className={`auth`}>
<Loader prompt={`Login`} />
</div>
);
}
export default Auth;

View File

@ -1,15 +1,90 @@
import "../assets/home.less";
import {Input} from "../components/ui/input.tsx";
import {Toggle} from "../components/ui/toggle.tsx";
import {Globe, LogIn} from "lucide-react";
import {Button} from "../components/ui/button.tsx";
import {Switch} from "../components/ui/switch.tsx";
import {Label} from "../components/ui/label.tsx";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../components/ui/tooltip.tsx";
import {useSelector} from "react-redux";
import type {RootState} from "../store";
import {selectAuthenticated} from "../store/auth.ts";
import {login} from "../conf.ts";
function SideBar() {
const open = useSelector((state: RootState) => state.menu.open);
const auth = useSelector(selectAuthenticated);
return (
<div className={`sidebar`}>
<div className={`sidebar ${open ? "open" : ""}`}>
{
auth ?
<div className={`sidebar-content`}></div>
: <Button className={`login-action`} variant={`default`} onClick={login}>
<LogIn className={`h-3 w-3 mr-2`} /> login
</Button>
}
</div>
)
}
function ChatWrapper() {
type ChatWrapperProps = {
onSend?: (message: string) => void
}
function ChatWrapper({ onSend }: ChatWrapperProps) {
function handleSend() {
const target = document.getElementById("input") as HTMLInputElement;
const message = target.value.trim();
if (message.length > 0) {
onSend?.(message);
target.value = "";
}
}
window.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
handleSend();
}
});
return (
<div className={`chat-container`}>
<div className={`chat-wrapper`}>
<div className={`chat-content`}>
</div>
<div className={`chat-input`}>
<div className={`input-wrapper`}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Toggle aria-label="Toggle chatgpt web feature" variant={`outline`}>
<Globe className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>
<p className={`tooltip`}>Enable ChatGPT <strong>web</strong> feature</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Input className={`input-box`} id={`input`} placeholder={`Write something...`} />
<Button size={`icon`} variant="outline" className={`send-button`} onClick={handleSend}>
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" data-v-f9a7276b=""><path d="m21.426 11.095-17-8A1 1 0 0 0 3.03 4.242l1.212 4.849L12 12l-7.758 2.909-1.212 4.849a.998.998 0 0 0 1.396 1.147l17-8a1 1 0 0 0 0-1.81z"></path></svg>
</Button>
</div>
<div className={`input-options`}>
<div className="flex items-center space-x-2">
<Switch id="enable-gpt4" />
<Label htmlFor="enable-gpt4">GPT-4</Label>
</div>
</div>
</div>
</div>
</div>
)
}
@ -18,7 +93,7 @@ function Home() {
return (
<div className={`main`}>
<SideBar />
<ChatWrapper />
<ChatWrapper onSend={console.log} />
</div>
)
}

60
renio/src/store/auth.ts Normal file
View File

@ -0,0 +1,60 @@
import {createSlice} from "@reduxjs/toolkit";
import axios from "axios";
export const authSlice = createSlice({
name: 'auth',
initialState: {
token: '',
authenticated: false,
username: '',
},
reducers: {
setToken: (state, action) => {
state.token = action.payload as string;
axios.defaults.headers.common['Authorization'] = state.token;
localStorage.setItem('token', state.token);
},
setAuthenticated: (state, action) => {
state.authenticated = action.payload as boolean;
},
setUsername: (state, action) => {
state.username = action.payload as string;
},
logout: (state) => {
state.token = '';
state.authenticated = false;
state.username = '';
axios.defaults.headers.common['Authorization'] = '';
localStorage.removeItem('token');
location.reload();
},
}
});
export function validateToken(dispatch: any, token: string, hook?: () => any) {
token = token.trim();
dispatch(setToken(token));
if (token.length === 0) {
dispatch(setAuthenticated(false));
dispatch(setUsername(''));
return;
} else axios.post('/state')
.then(res => {
dispatch(setAuthenticated(res.data.status));
dispatch(setUsername(res.data.user));
hook && hook();
})
.catch(err => {
// keep state
console.debug(err);
});
}
export const selectAuthenticated = (state: any) => state.auth.authenticated;
export const selectUsername = (state: any) => state.auth.username;
export const {setToken, setAuthenticated, setUsername, logout} = authSlice.actions;
export default authSlice.reducer;

42
renio/src/store/chat.ts Normal file
View File

@ -0,0 +1,42 @@
import {createSlice} from "@reduxjs/toolkit";
type Message = {
id: string;
text: string;
isBot: boolean;
}
type initialStateType = {
messages: Message[];
gpt4: boolean;
web: boolean;
}
const chatSlice = createSlice({
name: 'chat',
initialState: {
messages: [],
gpt4: false,
web: false,
} as initialStateType,
reducers: {
setMessages: (state, action) => {
state.messages = action.payload;
},
setGPT4: (state, action) => {
state.gpt4 = action.payload;
},
setWeb: (state, action) => {
state.web = action.payload;
},
addMessage: (state, action) => {
state.messages.push(action.payload as Message);
},
setMessage: (state, action) => {
state.messages[state.messages.length - 1] = action.payload as Message;
}
}
});
export const {setMessages, addMessage, setMessage, setGPT4, setWeb} = chatSlice.actions;
export default chatSlice.reducer;

16
renio/src/store/index.ts Normal file
View File

@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit'
import menuReducer from './menu'
import authReducer from './auth'
const store = configureStore({
reducer: {
menu: menuReducer,
auth: authReducer,
},
});
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export type {RootState, AppDispatch};
export default store;

25
renio/src/store/menu.ts Normal file
View File

@ -0,0 +1,25 @@
import {createSlice} from "@reduxjs/toolkit";
export const menuSlice = createSlice({
name: 'menu',
initialState: {
open: false,
},
reducers: {
toggleMenu: (state) => {
state.open = !state.open
},
closeMenu: (state) => {
state.open = false;
},
openMenu: (state) => {
state.open = true;
},
setMenu: (state, action) => {
state.open = action.payload as boolean;
}
},
})
export const {toggleMenu, closeMenu, openMenu, setMenu} = menuSlice.actions;
export default menuSlice.reducer;

13
renio/src/store/utils.ts Normal file
View File

@ -0,0 +1,13 @@
import {useDispatch, useSelector} from "react-redux";
export function dispatchWrapper(action: (state: any, payload?: any) => any) {
return (payload?: any) => {
const dispatch = useDispatch();
dispatch(action(payload));
};
}
export function getSelector(reducer: string, key: string) {
return useSelector((state: any) => state[reducer][key]);
}