Skip to content

Commit d52e9b3

Browse files
committed
feat: polish management, viewer, and toast ux
1 parent 9f2dd7f commit d52e9b3

13 files changed

Lines changed: 614 additions & 268 deletions

File tree

frontend/public/locales/ru/minecraft-viewer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"connecting": "Подключение к боту...",
33
"error": "Ошибка",
4+
"errors": {
5+
"botNotRunning": "Бот не запущен"
6+
},
47
"settingsButton": "Настройки (Esc)",
58
"settings": {
69
"title": "Настройки",

frontend/public/locales/ru/nodes.json

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,17 +529,71 @@
529529

530530
"action:create_command": {
531531
"label": "Создать команду",
532-
"description": "Создаёт новую команду"
532+
"description": "Создаёт новую команду",
533+
"pins": {
534+
"exec": "Выполнить",
535+
"name": "Имя команды",
536+
"description": "Описание",
537+
"aliases": "Алиасы",
538+
"cooldown": "Кулдаун (сек)",
539+
"allowedChatTypes": "Типы чата",
540+
"permissionName": "Название права",
541+
"temporary": "Временная?",
542+
"commandId": "ID команды",
543+
"success": "Успешно?"
544+
},
545+
"placeholders": {
546+
"name": "моя-команда",
547+
"description": "Описание команды",
548+
"aliases": "[\"алиас1\", \"алиас2\"]",
549+
"cooldown": "0",
550+
"allowedChatTypes": "[\"chat\", \"private\"]",
551+
"permissionName": "модератор"
552+
},
553+
"options": {
554+
"temporary": {
555+
"false": "Постоянная",
556+
"true": "Временная"
557+
}
558+
}
533559
},
534560

535561
"action:update_command": {
536562
"label": "Обновить команду",
537-
"description": "Обновляет существующую команду"
563+
"description": "Обновляет существующую команду",
564+
"pins": {
565+
"exec": "Выполнить",
566+
"commandName": "Имя команды",
567+
"newName": "Новое имя",
568+
"description": "Описание",
569+
"aliases": "Алиасы",
570+
"cooldown": "Кулдаун (сек)",
571+
"allowedChatTypes": "Типы чата",
572+
"permissionName": "Название права",
573+
"success": "Успешно?"
574+
},
575+
"placeholders": {
576+
"commandName": "моя-команда",
577+
"newName": "новая-команда",
578+
"description": "Новое описание команды",
579+
"aliases": "[\"алиас1\", \"алиас2\"]",
580+
"cooldown": "0",
581+
"allowedChatTypes": "[\"chat\", \"private\"]",
582+
"permissionName": "модератор"
583+
}
538584
},
539585

540586
"action:delete_command": {
541587
"label": "Удалить команду",
542-
"description": "Удаляет команду"
588+
"description": "Удаляет команду",
589+
"pins": {
590+
"exec": "Выполнить",
591+
"commandName": "Имя команды",
592+
"success": "Успешно?"
593+
},
594+
"placeholders": {
595+
"commandName": "моя-команда"
596+
}
543597
},
544598

545599
"time:datetime_literal": {

frontend/src/components/InstalledPluginsView.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ function InstalledPluginCard({
292292
<IconComponent
293293
name={plugin.manifest?.icon}
294294
className={cn(
295-
'h-10 w-10 transform-gpu transition-transform duration-300 ease-out group-hover:scale-105',
295+
'h-10 w-10 transform-gpu transition-[filter,transform,color,opacity] duration-300 ease-out group-hover:scale-105',
296296
displayEnabled ? 'text-primary' : 'text-muted-foreground',
297297
isHovered && 'drop-shadow-glow'
298298
)}
@@ -432,7 +432,7 @@ function InstalledPluginCard({
432432
<IconComponent
433433
name={plugin.manifest?.icon}
434434
className={cn(
435-
'h-10 w-10 transform-gpu transition-transform duration-300 ease-out group-hover:scale-105',
435+
'h-10 w-10 transform-gpu transition-[filter,transform,color,opacity] duration-300 ease-out group-hover:scale-105',
436436
displayEnabled ? 'text-primary' : 'text-muted-foreground',
437437
isHovered && 'drop-shadow-glow'
438438
)}

frontend/src/components/PluginListItem.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,16 @@ export default function PluginListItem({ plugin, isInstalled, isInstalling, onIn
8989
>
9090
<div
9191
className={cn(
92-
'relative mt-1 shrink-0 transition-transform duration-200',
92+
'relative mt-1 shrink-0 transition-[filter,transform,opacity] duration-300 ease-out',
9393
isHovered && 'scale-105'
9494
)}
9595
>
9696
<IconComponent
9797
name={plugin.icon}
98-
className={cn('h-12 w-12 text-primary', isHovered && 'drop-shadow-glow')}
98+
className={cn(
99+
'h-12 w-12 text-primary transition-[filter,color,opacity] duration-300 ease-out',
100+
isHovered && 'drop-shadow-glow'
101+
)}
99102
/>
100103
</div>
101104

frontend/src/components/management/CommandsManager.jsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState, useEffect, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
3+
import { AnimatePresence, motion } from 'framer-motion';
34
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
45
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
56
import { Badge } from "@/components/ui/badge";
@@ -63,6 +64,10 @@ export default function CommandsManager({ commands = [], allPermissions = [], bo
6364
active: localCommands.filter(c => c.isEnabled).length
6465
};
6566
}, [localCommands]);
67+
const activeCountLabel = useMemo(
68+
() => t('commands.activeCount', { active: stats.active, total: stats.total }),
69+
[stats.active, stats.total, t]
70+
);
6671

6772
const handleOpenModal = (command) => {
6873
if (command.isVisual || (command.graphJson && command.graphJson !== 'null')) {
@@ -160,8 +165,19 @@ export default function CommandsManager({ commands = [], allPermissions = [], bo
160165
<div className="flex-1">
161166
<div className="flex items-baseline gap-3 mb-1">
162167
<CardTitle className="text-2xl font-bold tracking-tight">{t('commands.title')}</CardTitle>
163-
<span className="text-sm text-muted-foreground">
164-
{t('commands.activeCount', { active: stats.active, total: stats.total })}
168+
<span className="relative inline-flex min-h-[20px] items-center overflow-hidden text-sm text-muted-foreground">
169+
<AnimatePresence mode="wait" initial={false}>
170+
<motion.span
171+
key={activeCountLabel}
172+
initial={{ opacity: 0, y: 4 }}
173+
animate={{ opacity: 1, y: 0 }}
174+
exit={{ opacity: 0, y: -4 }}
175+
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
176+
className="inline-block"
177+
>
178+
{activeCountLabel}
179+
</motion.span>
180+
</AnimatePresence>
165181
</span>
166182
</div>
167183
<CardDescription>{t('commands.description')}</CardDescription>

frontend/src/components/ui/toast.jsx

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,66 @@ import { cn } from "@/lib/utils"
88

99
const ToastProvider = ToastPrimitives.Provider
1010

11+
const TOAST_TEXT_FADE_OUT_MS = 130
12+
const TOAST_TEXT_FADE_IN_MS = 220
13+
14+
function AnimatedToastText({ children, className }) {
15+
const [renderedChildren, setRenderedChildren] = React.useState(children)
16+
const [phase, setPhase] = React.useState("")
17+
const isFirstRenderRef = React.useRef(true)
18+
const previousChildrenRef = React.useRef(children)
19+
const fadeOutTimeoutRef = React.useRef(null)
20+
const fadeInTimeoutRef = React.useRef(null)
21+
22+
React.useEffect(() => {
23+
return () => {
24+
if (fadeOutTimeoutRef.current) {
25+
window.clearTimeout(fadeOutTimeoutRef.current)
26+
}
27+
if (fadeInTimeoutRef.current) {
28+
window.clearTimeout(fadeInTimeoutRef.current)
29+
}
30+
}
31+
}, [])
32+
33+
React.useEffect(() => {
34+
if (isFirstRenderRef.current) {
35+
isFirstRenderRef.current = false
36+
previousChildrenRef.current = children
37+
setRenderedChildren(children)
38+
return
39+
}
40+
41+
if (Object.is(previousChildrenRef.current, children)) {
42+
return
43+
}
44+
45+
previousChildrenRef.current = children
46+
47+
if (fadeOutTimeoutRef.current) {
48+
window.clearTimeout(fadeOutTimeoutRef.current)
49+
}
50+
if (fadeInTimeoutRef.current) {
51+
window.clearTimeout(fadeInTimeoutRef.current)
52+
}
53+
54+
setPhase("toast-text-leave")
55+
fadeOutTimeoutRef.current = window.setTimeout(() => {
56+
setRenderedChildren(children)
57+
setPhase("toast-text-enter")
58+
fadeInTimeoutRef.current = window.setTimeout(() => {
59+
setPhase("")
60+
}, TOAST_TEXT_FADE_IN_MS)
61+
}, TOAST_TEXT_FADE_OUT_MS)
62+
}, [children])
63+
64+
return (
65+
<span className={cn("block toast-text-transition", phase, className)}>
66+
{renderedChildren}
67+
</span>
68+
)
69+
}
70+
1171
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
1272
<ToastPrimitives.Viewport
1373
ref={ref}
@@ -39,7 +99,7 @@ const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
3999
return (
40100
<ToastPrimitives.Root
41101
ref={ref}
42-
className={cn(toastVariants({ variant }), className)}
102+
className={cn(toastVariants({ variant }), "toast-text-rendering", className)}
43103
{...props} />
44104
);
45105
})
@@ -74,12 +134,17 @@ const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
74134
<ToastPrimitives.Title
75135
ref={ref}
76136
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
77-
{...props} />
137+
{...props}
138+
>
139+
<AnimatedToastText>{props.children}</AnimatedToastText>
140+
</ToastPrimitives.Title>
78141
))
79142
ToastTitle.displayName = ToastPrimitives.Title.displayName
80143

81144
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
82-
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
145+
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props}>
146+
<AnimatedToastText>{props.children}</AnimatedToastText>
147+
</ToastPrimitives.Description>
83148
))
84149
ToastDescription.displayName = ToastPrimitives.Description.displayName
85150

frontend/src/hooks/use-toast.js

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from "react"
22

33
const TOAST_LIMIT = 1
44
const TOAST_REMOVE_DELAY = 1000000
5+
const DEFAULT_TOAST_DURATION = 4200
56

67
const actionTypes = {
78
ADD_TOAST: "ADD_TOAST",
@@ -18,6 +19,34 @@ function genId() {
1819
}
1920

2021
const toastTimeouts = new Map()
22+
const autoDismissTimeouts = new Map()
23+
24+
const clearAutoDismissTimeout = (toastId) => {
25+
if (!autoDismissTimeouts.has(toastId)) {
26+
return
27+
}
28+
29+
window.clearTimeout(autoDismissTimeouts.get(toastId))
30+
autoDismissTimeouts.delete(toastId)
31+
}
32+
33+
const scheduleAutoDismiss = (toastId, duration = DEFAULT_TOAST_DURATION) => {
34+
clearAutoDismissTimeout(toastId)
35+
36+
if (!Number.isFinite(duration) || duration <= 0) {
37+
return
38+
}
39+
40+
const timeout = window.setTimeout(() => {
41+
autoDismissTimeouts.delete(toastId)
42+
dispatch({
43+
type: "DISMISS_TOAST",
44+
toastId,
45+
})
46+
}, duration)
47+
48+
autoDismissTimeouts.set(toastId, timeout)
49+
}
2150

2251
const addToRemoveQueue = (toastId) => {
2352
if (toastTimeouts.has(toastId)) {
@@ -38,6 +67,15 @@ const addToRemoveQueue = (toastId) => {
3867
export const reducer = (state, action) => {
3968
switch (action.type) {
4069
case "ADD_TOAST":
70+
const existingToastIndex = state.toasts.findIndex((t) => t.id === action.toast.id)
71+
if (existingToastIndex !== -1) {
72+
const nextToasts = [...state.toasts]
73+
nextToasts.splice(existingToastIndex, 1)
74+
return {
75+
...state,
76+
toasts: [action.toast, ...nextToasts].slice(0, TOAST_LIMIT),
77+
};
78+
}
4179
return {
4280
...state,
4381
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
@@ -73,6 +111,11 @@ export const reducer = (state, action) => {
73111
};
74112
}
75113
case "REMOVE_TOAST":
114+
if (action.toastId !== undefined) {
115+
clearAutoDismissTimeout(action.toastId)
116+
} else {
117+
state.toasts.forEach((toast) => clearAutoDismissTimeout(toast.id))
118+
}
76119
if (action.toastId === undefined) {
77120
return {
78121
...state,
@@ -100,26 +143,45 @@ function dispatch(action) {
100143
function toast({
101144
...props
102145
}) {
103-
const id = genId()
146+
const id = props.id ?? genId()
147+
const duration = typeof props.duration === "number" ? props.duration : DEFAULT_TOAST_DURATION
148+
const buildToastPayload = (nextProps = {}) => ({
149+
...props,
150+
...nextProps,
151+
id,
152+
duration: typeof nextProps.duration === "number" ? nextProps.duration : duration,
153+
open: true,
154+
onOpenChange: (open) => {
155+
if (!open) dismiss()
156+
},
157+
})
158+
159+
const update = (nextProps) => {
160+
const nextDuration =
161+
typeof nextProps?.duration === "number"
162+
? nextProps.duration
163+
: memoryState.toasts.find((toast) => toast.id === id)?.duration ?? duration
104164

105-
const update = (props) =>
106165
dispatch({
107166
type: "UPDATE_TOAST",
108-
toast: { ...props, id },
167+
toast: buildToastPayload({ ...nextProps, duration: nextDuration }),
109168
})
110-
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
169+
scheduleAutoDismiss(id, nextDuration)
170+
}
171+
172+
const dismiss = () => {
173+
clearAutoDismissTimeout(id)
174+
dispatch({ type: "DISMISS_TOAST", toastId: id })
175+
}
176+
177+
const payload = buildToastPayload()
178+
const hasExistingToast = memoryState.toasts.some((toast) => toast.id === id)
111179

112180
dispatch({
113-
type: "ADD_TOAST",
114-
toast: {
115-
...props,
116-
id,
117-
open: true,
118-
onOpenChange: (open) => {
119-
if (!open) dismiss()
120-
},
121-
},
181+
type: hasExistingToast ? "UPDATE_TOAST" : "ADD_TOAST",
182+
toast: payload,
122183
})
184+
scheduleAutoDismiss(id, payload.duration)
123185

124186
return {
125187
id: id,

0 commit comments

Comments
 (0)