feat(onboarding): add minimax api key step and AI-powered editor scan
- Add apikey step in onboarding wizard (optional, with validation) - Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed - Add GET /api/editors endpoint - Editor step now has scan button to detect installed editors via backend - MiniMax API key is saved to provider config if provided 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
@@ -117,3 +117,8 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
editors := scanner.ScanEditors()
|
||||||
|
writeJSON(w, map[string]interface{}{"editors": editors})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.MuyueConfig
|
config *config.MuyueConfig
|
||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
|
agentRegistry *agent.Registry
|
||||||
|
agentToolsJSON json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -22,6 +26,10 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
}
|
}
|
||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
s.convStore = NewConversationStore()
|
s.convStore = NewConversationStore()
|
||||||
|
s.agentRegistry = agent.DefaultRegistry()
|
||||||
|
tools := s.agentRegistry.OpenAITools()
|
||||||
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
|
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -38,6 +46,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
||||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||||
|
s.mux.HandleFunc("/api/editors", s.handleEditors)
|
||||||
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
|
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
|
||||||
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
||||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolStatus struct {
|
type ToolStatus struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Latest string `yaml:"latest"`
|
Latest string `yaml:"latest"`
|
||||||
NeedsUpdate bool `yaml:"needs_update"`
|
NeedsUpdate bool `yaml:"needs_update"`
|
||||||
Category string `yaml:"category"`
|
Category string `yaml:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeStatus struct {
|
type RuntimeStatus struct {
|
||||||
@@ -30,15 +30,15 @@ type RuntimeStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
System platform.SystemInfo `yaml:"system"`
|
System platform.SystemInfo `yaml:"system"`
|
||||||
Tools []ToolStatus `yaml:"tools"`
|
Tools []ToolStatus `yaml:"tools"`
|
||||||
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
||||||
ShellSetup bool `yaml:"shell_setup"`
|
ShellSetup bool `yaml:"shell_setup"`
|
||||||
GitConfigured bool `yaml:"git_configured"`
|
GitConfigured bool `yaml:"git_configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheResult *ScanResult
|
cacheResult *ScanResult
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL = 5 * time.Minute
|
cacheTTL = 5 * time.Minute
|
||||||
@@ -193,6 +193,43 @@ func checkGitConfig() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var editorsList = []struct {
|
||||||
|
name string
|
||||||
|
cmd []string
|
||||||
|
version []string
|
||||||
|
}{
|
||||||
|
{"vim", []string{"vim"}, []string{"--version"}},
|
||||||
|
{"nvim", []string{"nvim"}, []string{"--version"}},
|
||||||
|
{"code", []string{"code"}, []string{"--version"}},
|
||||||
|
{"emacs", []string{"emacs"}, []string{"--version"}},
|
||||||
|
{"nano", []string{"nano"}, []string{"--version"}},
|
||||||
|
{"helix", []string{"hx"}, []string{"--version"}},
|
||||||
|
{"subl", []string{"subl"}, []string{"--version"}},
|
||||||
|
{"zed", []string{"zed"}, []string{"--version"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanEditors() []ToolStatus {
|
||||||
|
var results []ToolStatus
|
||||||
|
for _, e := range editorsList {
|
||||||
|
status := ToolStatus{Name: e.name}
|
||||||
|
path, err := exec.LookPath(e.name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.Installed = true
|
||||||
|
status.Path = path
|
||||||
|
if len(e.version) > 0 {
|
||||||
|
cmd := exec.Command(e.cmd[0], e.version...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
status.Version = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, status)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
||||||
|
|
||||||
func (s *ScanResult) Summary() string {
|
func (s *ScanResult) Summary() string {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const api = {
|
|||||||
getLSP: () => request('/lsp'),
|
getLSP: () => request('/lsp'),
|
||||||
getMCP: () => request('/mcp'),
|
getMCP: () => request('/mcp'),
|
||||||
getUpdates: () => request('/updates'),
|
getUpdates: () => request('/updates'),
|
||||||
|
getEditors: () => request('/editors'),
|
||||||
runScan: () => request('/scan', { method: 'POST' }),
|
runScan: () => request('/scan', { method: 'POST' }),
|
||||||
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
||||||
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
||||||
@@ -73,6 +74,8 @@ const api = {
|
|||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
} else if (data.thinking !== undefined || data.thinking_end) {
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.tool_call || data.tool_result) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react'
|
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n, LANGUAGES } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
import { getLayoutList } from '../i18n/keyboards'
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: 'welcome', title: 'welcome', field: null },
|
{ key: 'welcome', title: 'welcome' },
|
||||||
{ key: 'name', title: 'name', field: 'name' },
|
{ key: 'name', title: 'name' },
|
||||||
{ key: 'language', title: 'language', field: 'language' },
|
{ key: 'language', title: 'language' },
|
||||||
{ key: 'keyboard', title: 'keyboard', field: 'keyboard' },
|
{ key: 'keyboard', title: 'keyboard' },
|
||||||
{ key: 'editor', title: 'editor', field: 'editor' },
|
{ key: 'apikey', title: 'apikey' },
|
||||||
{ key: 'done', title: 'done', field: null },
|
{ key: 'editor', title: 'editor' },
|
||||||
|
{ key: 'done', title: 'done' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const EDITOR_SUGGESTIONS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
|
const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
|
||||||
|
|
||||||
export default function OnboardingWizard({ api, onComplete }) {
|
export default function OnboardingWizard({ api, onComplete }) {
|
||||||
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
||||||
@@ -21,11 +22,16 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
name: '',
|
name: '',
|
||||||
language: 'fr',
|
language: 'fr',
|
||||||
keyboard: 'azerty',
|
keyboard: 'azerty',
|
||||||
|
apikey: '',
|
||||||
editor: '',
|
editor: '',
|
||||||
})
|
})
|
||||||
|
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [requiredError, setRequiredError] = useState(false)
|
const [requiredError, setRequiredError] = useState(false)
|
||||||
|
const [validating, setValidating] = useState(false)
|
||||||
|
const [keyValid, setKeyValid] = useState(false)
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
|
||||||
const current = STEPS[step]
|
const current = STEPS[step]
|
||||||
const layouts = getLayoutList()
|
const layouts = getLayoutList()
|
||||||
@@ -44,6 +50,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
case 'name': return answers.name.trim().length > 0
|
case 'name': return answers.name.trim().length > 0
|
||||||
case 'language': return !!answers.language
|
case 'language': return !!answers.language
|
||||||
case 'keyboard': return !!answers.keyboard
|
case 'keyboard': return !!answers.keyboard
|
||||||
|
case 'apikey': return true
|
||||||
case 'editor': return true
|
case 'editor': return true
|
||||||
case 'done': return true
|
case 'done': return true
|
||||||
default: return true
|
default: return true
|
||||||
@@ -57,7 +64,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
if (e.key === 'Escape') { goPrev(); return }
|
if (e.key === 'Escape') { goPrev(); return }
|
||||||
if (e.key === 'Enter' && current.key !== 'done') { e.preventDefault(); goNext() }
|
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
@@ -69,19 +76,68 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
}
|
}
|
||||||
}, [step])
|
}, [step])
|
||||||
|
|
||||||
|
const handleValidateKey = async () => {
|
||||||
|
if (!answers.apikey.trim()) return
|
||||||
|
setValidating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({
|
||||||
|
name: 'minimax',
|
||||||
|
api_key: answers.apikey,
|
||||||
|
model: 'MiniMax-M2.7',
|
||||||
|
base_url: 'https://api.minimax.io/v1',
|
||||||
|
})
|
||||||
|
setKeyValid(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Clé invalide')
|
||||||
|
setKeyValid(false)
|
||||||
|
}
|
||||||
|
setValidating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScanEditors = async () => {
|
||||||
|
setScanning(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await api.getEditors()
|
||||||
|
const detected = (data.editors || []).map(e => e.name)
|
||||||
|
const merged = [...new Set([...detected, ...BASE_EDITORS])]
|
||||||
|
setEditorList(merged)
|
||||||
|
if (detected.length === 0) {
|
||||||
|
setError('Aucun éditeur détecté')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Erreur lors du scan')
|
||||||
|
}
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.saveProfile({
|
const profile = {
|
||||||
name: answers.name,
|
name: answers.name,
|
||||||
pseudo: answers.name.split(' ')[0] || 'user',
|
pseudo: answers.name.split(' ')[0] || 'user',
|
||||||
editor: answers.editor,
|
editor: answers.editor,
|
||||||
})
|
}
|
||||||
|
if (answers.apikey.trim()) {
|
||||||
|
profile.apikey = answers.apikey
|
||||||
|
}
|
||||||
|
await api.saveProfile(profile)
|
||||||
await api.savePreferences({
|
await api.savePreferences({
|
||||||
language: answers.language,
|
language: answers.language,
|
||||||
keyboard_layout: answers.keyboard,
|
keyboard_layout: answers.keyboard,
|
||||||
})
|
})
|
||||||
|
if (answers.apikey.trim()) {
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'minimax',
|
||||||
|
api_key: answers.apikey,
|
||||||
|
model: 'MiniMax-M2.7',
|
||||||
|
base_url: 'https://api.minimax.io/v1',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Erreur lors de la sauvegarde')
|
setError(err.message || 'Erreur lors de la sauvegarde')
|
||||||
@@ -129,7 +185,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
|
|
||||||
{current.key === 'language' && (
|
{current.key === 'language' && (
|
||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Quelle langue pr\u00e9f\u00e9rez-vous ?</div>
|
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
|
||||||
<div className="onboarding-chips">
|
<div className="onboarding-chips">
|
||||||
{LANGUAGES.map(lang => (
|
{LANGUAGES.map(lang => (
|
||||||
<div
|
<div
|
||||||
@@ -161,28 +217,78 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{current.key === 'apikey' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Clé API MiniMax</div>
|
||||||
|
<div className="onboarding-desc">
|
||||||
|
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="onboarding-input"
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||||
|
type="password"
|
||||||
|
value={answers.apikey}
|
||||||
|
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
||||||
|
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleValidateKey}
|
||||||
|
disabled={validating || !answers.apikey.trim()}
|
||||||
|
>
|
||||||
|
{validating ? 'Validation...' : 'Valider la clé'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sm ghost"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!answers.apikey.trim()}
|
||||||
|
>
|
||||||
|
Passer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{answers.apikey.trim() && !keyValid && !error && (
|
||||||
|
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{current.key === 'editor' && (
|
{current.key === 'editor' && (
|
||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Quel \u00e9diteur utilisez-vous ?</div>
|
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
||||||
<div className="onboarding-chips">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
{EDITOR_SUGGESTIONS.map(ed => (
|
<div className="onboarding-chips" style={{ flex: 1 }}>
|
||||||
<div
|
{editorList.map(ed => (
|
||||||
key={ed}
|
<div
|
||||||
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
key={ed}
|
||||||
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||||
>
|
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||||
{ed}
|
>
|
||||||
</div>
|
{ed}
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sm ghost"
|
||||||
|
onClick={handleScanEditors}
|
||||||
|
disabled={scanning}
|
||||||
|
title="Détecter les éditeurs installés"
|
||||||
|
style={{ marginLeft: 8, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="onboarding-input"
|
className="onboarding-input"
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
placeholder="Autre (vim, nvim, vscode...)"
|
placeholder="Autre éditeur..."
|
||||||
value={answers.editor}
|
value={answers.editor}
|
||||||
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
|
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{error && <div className="onboarding-required">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -282,6 +388,19 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
.onboarding-required {
|
.onboarding-required {
|
||||||
font-size: 12px; color: var(--error); margin-top: 4px;
|
font-size: 12px; color: var(--error); margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
.onboarding-valid {
|
||||||
|
font-size: 12px; color: var(--success); margin-top: 4px;
|
||||||
|
}
|
||||||
|
.onboarding-hint {
|
||||||
|
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
|
||||||
|
}
|
||||||
|
.spin-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user