mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
feat: add JSON import/export functionality in settings view
- Introduced a new ImportExportDialog component for managing settings import and export via JSON. - Integrated JsonSyntaxEditor for editing JSON settings with syntax highlighting. - Updated SettingsView to include the import/export dialog and associated state management. - Enhanced SettingsHeader with an import/export button for easy access. These changes aim to improve user experience by allowing seamless transfer of settings between installations.
This commit is contained in:
140
apps/ui/src/components/ui/json-syntax-editor.tsx
Normal file
140
apps/ui/src/components/ui/json-syntax-editor.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { StreamLanguage } from '@codemirror/language';
|
||||||
|
import { javascript } from '@codemirror/legacy-modes/mode/javascript';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface JsonSyntaxEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syntax highlighting using CSS variables for theme compatibility
|
||||||
|
const syntaxColors = HighlightStyle.define([
|
||||||
|
// Property names (keys)
|
||||||
|
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||||
|
|
||||||
|
// Strings (values)
|
||||||
|
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
|
||||||
|
|
||||||
|
// Booleans and null
|
||||||
|
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||||
|
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||||
|
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||||
|
|
||||||
|
// Brackets and punctuation
|
||||||
|
{ tag: t.bracket, color: 'var(--muted-foreground)' },
|
||||||
|
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
|
||||||
|
|
||||||
|
// Default text
|
||||||
|
{ tag: t.content, color: 'var(--foreground)' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Editor theme using CSS variables
|
||||||
|
const editorTheme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
height: '100%',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
padding: '0.75rem',
|
||||||
|
minHeight: '100%',
|
||||||
|
caretColor: 'var(--primary)',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: 'var(--primary)',
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||||
|
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: 'var(--accent)',
|
||||||
|
opacity: '0.3',
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
padding: '0 0.25rem',
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted-foreground)',
|
||||||
|
border: 'none',
|
||||||
|
paddingRight: '0.5rem',
|
||||||
|
},
|
||||||
|
'.cm-lineNumbers .cm-gutterElement': {
|
||||||
|
minWidth: '2.5rem',
|
||||||
|
textAlign: 'right',
|
||||||
|
paddingRight: '0.5rem',
|
||||||
|
},
|
||||||
|
'.cm-placeholder': {
|
||||||
|
color: 'var(--muted-foreground)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// JavaScript language in JSON mode
|
||||||
|
const jsonLanguage = StreamLanguage.define(javascript);
|
||||||
|
|
||||||
|
// Combine all extensions
|
||||||
|
const extensions: Extension[] = [jsonLanguage, syntaxHighlighting(syntaxColors), editorTheme];
|
||||||
|
|
||||||
|
export function JsonSyntaxEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
minHeight = '300px',
|
||||||
|
maxHeight,
|
||||||
|
readOnly = false,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: JsonSyntaxEditorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
|
||||||
|
style={{ minHeight }}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
extensions={extensions}
|
||||||
|
theme="none"
|
||||||
|
placeholder={placeholder}
|
||||||
|
height={maxHeight}
|
||||||
|
minHeight={minHeight}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className="[&_.cm-editor]:min-h-[inherit]"
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
foldGutter: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
highlightSelectionMatches: true,
|
||||||
|
autocompletion: false,
|
||||||
|
bracketMatching: true,
|
||||||
|
indentOnInput: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
import { EventHooksSection } from './settings-view/event-hooks';
|
import { EventHooksSection } from './settings-view/event-hooks';
|
||||||
|
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ export function SettingsView() {
|
|||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
|
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||||
|
|
||||||
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||||
const [showNavigation, setShowNavigation] = useState(() => {
|
const [showNavigation, setShowNavigation] = useState(() => {
|
||||||
@@ -239,6 +241,7 @@ export function SettingsView() {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
showNavigation={showNavigation}
|
showNavigation={showNavigation}
|
||||||
onToggleNavigation={() => setShowNavigation(!showNavigation)}
|
onToggleNavigation={() => setShowNavigation(!showNavigation)}
|
||||||
|
onImportExportClick={() => setShowImportExportDialog(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Area with Sidebar */}
|
{/* Content Area with Sidebar */}
|
||||||
@@ -269,6 +272,9 @@ export function SettingsView() {
|
|||||||
project={currentProject}
|
project={currentProject}
|
||||||
onConfirm={moveProjectToTrash}
|
onConfirm={moveProjectToTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Import/Export Settings Dialog */}
|
||||||
|
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Copy, Check, AlertCircle, Save } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { JsonSyntaxEditor } from '@/components/ui/json-syntax-editor';
|
||||||
|
import { apiGet, apiPut } from '@/lib/api-fetch';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { GlobalSettings } from '@automaker/types';
|
||||||
|
|
||||||
|
interface ImportExportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsResponse {
|
||||||
|
success: boolean;
|
||||||
|
settings: GlobalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogProps) {
|
||||||
|
const [jsonValue, setJsonValue] = useState('');
|
||||||
|
const [originalValue, setOriginalValue] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load current settings when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiGet<SettingsResponse>('/api/settings/global');
|
||||||
|
if (response.success) {
|
||||||
|
const formatted = JSON.stringify(response.settings, null, 2);
|
||||||
|
setJsonValue(formatted);
|
||||||
|
setOriginalValue(formatted);
|
||||||
|
setParseError(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load settings');
|
||||||
|
console.error('Failed to load settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate JSON on change
|
||||||
|
const handleJsonChange = (value: string) => {
|
||||||
|
setJsonValue(value);
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
setParseError(null);
|
||||||
|
} catch {
|
||||||
|
setParseError('Invalid JSON syntax');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(jsonValue);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success('Settings copied to clipboard');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (parseError) {
|
||||||
|
toast.error('Please fix JSON syntax errors before saving');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(jsonValue);
|
||||||
|
const response = await apiPut<SettingsResponse>('/api/settings/global', settings);
|
||||||
|
if (response.success) {
|
||||||
|
const formatted = JSON.stringify(response.settings, null, 2);
|
||||||
|
setJsonValue(formatted);
|
||||||
|
setOriginalValue(formatted);
|
||||||
|
toast.success('Settings saved successfully', {
|
||||||
|
description: 'Your changes have been applied. Some settings may require a refresh.',
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save settings');
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setJsonValue(originalValue);
|
||||||
|
setParseError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = jsonValue !== originalValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import / Export Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Copy your settings to transfer to another machine, or paste settings from another
|
||||||
|
installation.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-4 min-h-0 mt-4">
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={isLoading || !!parseError}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadSettings}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasChanges && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset} disabled={isSaving}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading || isSaving || !hasChanges || !!parseError}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{parseError && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{parseError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* JSON Editor */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Loading settings...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<JsonSyntaxEditor
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={handleJsonChange}
|
||||||
|
placeholder="Loading settings..."
|
||||||
|
minHeight="350px"
|
||||||
|
maxHeight="450px"
|
||||||
|
data-testid="settings-json-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
To import settings, paste the JSON content into the editor and click "Save Changes".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
|
import { Settings, PanelLeft, PanelLeftClose, FileJson } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ interface SettingsHeaderProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
showNavigation?: boolean;
|
showNavigation?: boolean;
|
||||||
onToggleNavigation?: () => void;
|
onToggleNavigation?: () => void;
|
||||||
|
onImportExportClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsHeader({
|
export function SettingsHeader({
|
||||||
@@ -14,6 +15,7 @@ export function SettingsHeader({
|
|||||||
description = 'Configure your API keys and preferences',
|
description = 'Configure your API keys and preferences',
|
||||||
showNavigation,
|
showNavigation,
|
||||||
onToggleNavigation,
|
onToggleNavigation,
|
||||||
|
onImportExportClick,
|
||||||
}: SettingsHeaderProps) {
|
}: SettingsHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -24,39 +26,48 @@ export function SettingsHeader({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
||||||
<div className="flex items-center gap-3 lg:gap-4">
|
<div className="flex items-center justify-between">
|
||||||
{/* Mobile menu toggle button - only visible on mobile */}
|
<div className="flex items-center gap-3 lg:gap-4">
|
||||||
{onToggleNavigation && (
|
{/* Mobile menu toggle button - only visible on mobile */}
|
||||||
<Button
|
{onToggleNavigation && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={onToggleNavigation}
|
size="sm"
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
|
onClick={onToggleNavigation}
|
||||||
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
|
||||||
>
|
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||||
{showNavigation ? (
|
>
|
||||||
<PanelLeftClose className="w-5 h-5" />
|
{showNavigation ? (
|
||||||
) : (
|
<PanelLeftClose className="w-5 h-5" />
|
||||||
<PanelLeft className="w-5 h-5" />
|
) : (
|
||||||
|
<PanelLeft className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
|
||||||
|
'bg-gradient-to-br from-brand-500 to-brand-600',
|
||||||
|
'shadow-lg shadow-brand-500/25',
|
||||||
|
'ring-1 ring-white/10'
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Import/Export button */}
|
||||||
|
{onImportExportClick && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
|
||||||
|
<FileJson className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Import / Export</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
|
|
||||||
'bg-gradient-to-br from-brand-500 to-brand-600',
|
|
||||||
'shadow-lg shadow-brand-500/25',
|
|
||||||
'ring-1 ring-white/10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user