@liorandb/studio 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +19 -0
- package/package.json +10 -0
- package/template/README.md +36 -0
- package/template/app/dashboard/page.tsx +240 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +73 -0
- package/template/app/layout.tsx +37 -0
- package/template/app/login/page.tsx +233 -0
- package/template/app/page.tsx +32 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +6765 -0
- package/template/package.json +31 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/dashboard/page.tsx +240 -0
- package/template/src/app/login/page.tsx +233 -0
- package/template/src/components/DocumentViewer.tsx +313 -0
- package/template/src/components/JsonViewer.tsx +93 -0
- package/template/src/components/Modal.tsx +192 -0
- package/template/src/components/Navbar.tsx +76 -0
- package/template/src/components/QueryEditor.tsx +189 -0
- package/template/src/components/Sidebar.tsx +196 -0
- package/template/src/components/Toast.tsx +91 -0
- package/template/src/lib/lioran.ts +252 -0
- package/template/src/lib/utils.ts +66 -0
- package/template/src/store/auth.ts +66 -0
- package/template/src/store/index.ts +125 -0
- package/template/src/types/index.ts +63 -0
- package/template/tsconfig.json +34 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Play, Copy } from 'lucide-react';
|
|
5
|
+
import { useAppStore } from '@/store';
|
|
6
|
+
import { LioranDBService } from '@/lib/lioran';
|
|
7
|
+
import { useToast } from './Toast';
|
|
8
|
+
import { formatJSON, copyToClipboard } from '@/lib/utils';
|
|
9
|
+
import { JsonViewer } from './JsonViewer';
|
|
10
|
+
|
|
11
|
+
interface QueryEditorProps {}
|
|
12
|
+
|
|
13
|
+
export function QueryEditor({}: QueryEditorProps) {
|
|
14
|
+
const { currentDatabase, selectedCollection, queryResults, isLoading } = useAppStore();
|
|
15
|
+
const { addToast } = useToast();
|
|
16
|
+
|
|
17
|
+
const [queryMode, setQueryMode] = useState<'find' | 'aggregate'>('find');
|
|
18
|
+
const [filter, setFilter] = useState('{}');
|
|
19
|
+
const [resultMode, setResultMode] = useState<'json' | 'count'>('json');
|
|
20
|
+
|
|
21
|
+
async function executeQuery() {
|
|
22
|
+
if (!currentDatabase || !selectedCollection) {
|
|
23
|
+
addToast('Please select a database and collection', 'warning');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
let filterObj: Record<string, any> = {};
|
|
29
|
+
try {
|
|
30
|
+
filterObj = JSON.parse(filter);
|
|
31
|
+
} catch {
|
|
32
|
+
addToast('Invalid JSON filter', 'error');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
useAppStore.setState({ isLoading: true });
|
|
37
|
+
|
|
38
|
+
const startTime = performance.now();
|
|
39
|
+
const { documents, count } = await LioranDBService.find(
|
|
40
|
+
currentDatabase,
|
|
41
|
+
selectedCollection,
|
|
42
|
+
filterObj,
|
|
43
|
+
100
|
|
44
|
+
);
|
|
45
|
+
const executionTime = Math.round(performance.now() - startTime);
|
|
46
|
+
|
|
47
|
+
useAppStore.setState({
|
|
48
|
+
queryResults: {
|
|
49
|
+
data: documents,
|
|
50
|
+
count,
|
|
51
|
+
executionTime,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
addToast(`Query executed in ${executionTime}ms`, 'success');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
addToast(`Query error: ${error}`, 'error');
|
|
58
|
+
} finally {
|
|
59
|
+
useAppStore.setState({ isLoading: false });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function copyResults() {
|
|
64
|
+
if (!queryResults) return;
|
|
65
|
+
try {
|
|
66
|
+
await copyToClipboard(formatJSON(queryResults.data));
|
|
67
|
+
addToast('Results copied to clipboard', 'success');
|
|
68
|
+
} catch {
|
|
69
|
+
addToast('Failed to copy', 'error');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex flex-col h-full gap-4 p-4">
|
|
75
|
+
{/* Query Editor */}
|
|
76
|
+
<div className="flex-1 flex flex-col gap-2">
|
|
77
|
+
<div className="flex items-center justify-between">
|
|
78
|
+
<h3 className="text-sm font-semibold text-slate-100">Query</h3>
|
|
79
|
+
<div className="flex gap-2">
|
|
80
|
+
<button
|
|
81
|
+
onClick={() => setQueryMode('find')}
|
|
82
|
+
className={`px-2 py-1 text-xs rounded transition ${
|
|
83
|
+
queryMode === 'find'
|
|
84
|
+
? 'bg-emerald-600 text-white'
|
|
85
|
+
: 'bg-slate-800 text-slate-400 hover:text-slate-200'
|
|
86
|
+
}`}
|
|
87
|
+
>
|
|
88
|
+
Find
|
|
89
|
+
</button>
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => setQueryMode('aggregate')}
|
|
92
|
+
className={`px-2 py-1 text-xs rounded transition ${
|
|
93
|
+
queryMode === 'aggregate'
|
|
94
|
+
? 'bg-emerald-600 text-white'
|
|
95
|
+
: 'bg-slate-800 text-slate-400 hover:text-slate-200'
|
|
96
|
+
}`}
|
|
97
|
+
disabled
|
|
98
|
+
>
|
|
99
|
+
Aggregate (Soon)
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<textarea
|
|
105
|
+
value={filter}
|
|
106
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
107
|
+
placeholder='{"field": "value"}'
|
|
108
|
+
className="flex-1 bg-slate-900 border border-slate-800 rounded font-mono text-sm text-slate-100 p-3 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 resize-none"
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<button
|
|
112
|
+
onClick={executeQuery}
|
|
113
|
+
disabled={isLoading || !currentDatabase || !selectedCollection}
|
|
114
|
+
className="flex items-center justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded py-2 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
|
115
|
+
>
|
|
116
|
+
<Play size={16} />
|
|
117
|
+
<span>Execute Query</span>
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Results */}
|
|
122
|
+
{queryResults && (
|
|
123
|
+
<div className="flex-1 flex flex-col gap-2 border-t border-slate-800 pt-4">
|
|
124
|
+
<div className="flex items-center justify-between">
|
|
125
|
+
<div className="flex items-center gap-3">
|
|
126
|
+
<h3 className="text-sm font-semibold text-slate-100">Results</h3>
|
|
127
|
+
<span className="text-xs text-slate-400">
|
|
128
|
+
{queryResults.count} documents • {queryResults.executionTime}ms
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="flex gap-2">
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => setResultMode('json')}
|
|
135
|
+
className={`px-2 py-1 text-xs rounded transition ${
|
|
136
|
+
resultMode === 'json'
|
|
137
|
+
? 'bg-emerald-600 text-white'
|
|
138
|
+
: 'bg-slate-800 text-slate-400 hover:text-slate-200'
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
JSON
|
|
142
|
+
</button>
|
|
143
|
+
<button
|
|
144
|
+
onClick={() => setResultMode('count')}
|
|
145
|
+
className={`px-2 py-1 text-xs rounded transition ${
|
|
146
|
+
resultMode === 'count'
|
|
147
|
+
? 'bg-emerald-600 text-white'
|
|
148
|
+
: 'bg-slate-800 text-slate-400 hover:text-slate-200'
|
|
149
|
+
}`}
|
|
150
|
+
>
|
|
151
|
+
Count
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
onClick={copyResults}
|
|
155
|
+
className="px-2 py-1 text-xs rounded bg-slate-800 text-slate-400 hover:text-slate-200 transition flex items-center gap-1"
|
|
156
|
+
>
|
|
157
|
+
<Copy size={12} />
|
|
158
|
+
Copy
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="flex-1 overflow-auto bg-slate-900 rounded border border-slate-800 p-3">
|
|
164
|
+
{resultMode === 'count' ? (
|
|
165
|
+
<div className="text-center text-slate-300">
|
|
166
|
+
<div className="text-4xl font-bold text-emerald-400 mb-2">
|
|
167
|
+
{queryResults.count}
|
|
168
|
+
</div>
|
|
169
|
+
<p className="text-slate-400">documents matched</p>
|
|
170
|
+
</div>
|
|
171
|
+
) : (
|
|
172
|
+
<div className="font-mono text-xs space-y-2">
|
|
173
|
+
{queryResults.data.length === 0 ? (
|
|
174
|
+
<p className="text-slate-400">No results</p>
|
|
175
|
+
) : (
|
|
176
|
+
queryResults.data.map((doc, idx) => (
|
|
177
|
+
<div key={idx} className="text-cyan-400">
|
|
178
|
+
<JsonViewer data={doc} collapsed={true} />
|
|
179
|
+
</div>
|
|
180
|
+
))
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { ChevronRight, ChevronDown, Plus, Trash2 } from 'lucide-react';
|
|
5
|
+
import { useAppStore } from '@/store';
|
|
6
|
+
import { LioranDBService } from '@/lib/lioran';
|
|
7
|
+
import { Database, Collection } from '@/types';
|
|
8
|
+
import { useToast } from './Toast';
|
|
9
|
+
|
|
10
|
+
interface SidebarProps {
|
|
11
|
+
onDatabaseSelect: (dbName: string) => void;
|
|
12
|
+
onCollectionSelect: (dbName: string, collectionName: string) => void;
|
|
13
|
+
onCreateDatabase: () => void;
|
|
14
|
+
onCreateCollection: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Sidebar({
|
|
18
|
+
onDatabaseSelect,
|
|
19
|
+
onCollectionSelect,
|
|
20
|
+
onCreateDatabase,
|
|
21
|
+
onCreateCollection,
|
|
22
|
+
}: SidebarProps) {
|
|
23
|
+
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
|
|
24
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
25
|
+
|
|
26
|
+
const { databases, currentDatabase, selectedCollection } = useAppStore();
|
|
27
|
+
const { addToast } = useToast();
|
|
28
|
+
|
|
29
|
+
const toggleDatabase = (dbName: string) => {
|
|
30
|
+
setExpandedDbs((prev) => {
|
|
31
|
+
const next = new Set(prev);
|
|
32
|
+
next.has(dbName) ? next.delete(dbName) : next.add(dbName);
|
|
33
|
+
return next;
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleDeleteDatabase = async (dbName: string) => {
|
|
38
|
+
if (!confirm(`Delete database "${dbName}"? This action cannot be undone.`)) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
setIsLoading(true);
|
|
42
|
+
await LioranDBService.dropDatabase(dbName);
|
|
43
|
+
const dbs = await LioranDBService.listDatabases();
|
|
44
|
+
useAppStore.setState({ databases: dbs });
|
|
45
|
+
if (currentDatabase === dbName) {
|
|
46
|
+
useAppStore.setState({ currentDatabase: null, selectedCollection: null });
|
|
47
|
+
}
|
|
48
|
+
addToast(`Database "${dbName}" deleted`, 'success');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
addToast(`Error deleting database: ${error}`, 'error');
|
|
51
|
+
} finally {
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleDeleteCollection = async (dbName: string, collectionName: string) => {
|
|
57
|
+
if (!confirm(`Delete collection "${collectionName}"? This action cannot be undone.`)) return;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
setIsLoading(true);
|
|
61
|
+
await LioranDBService.dropCollection(dbName, collectionName);
|
|
62
|
+
const collections = await LioranDBService.listCollections(dbName);
|
|
63
|
+
useAppStore.setState((state) => ({
|
|
64
|
+
collections: {
|
|
65
|
+
...state.collections,
|
|
66
|
+
[dbName]: collections,
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
if (selectedCollection === collectionName) {
|
|
70
|
+
useAppStore.setState({ selectedCollection: null });
|
|
71
|
+
}
|
|
72
|
+
addToast(`Collection "${collectionName}" deleted`, 'success');
|
|
73
|
+
} catch (error) {
|
|
74
|
+
addToast(`Error deleting collection: ${error}`, 'error');
|
|
75
|
+
} finally {
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="w-64 bg-slate-950 border-r border-slate-800 flex flex-col h-full">
|
|
82
|
+
{/* Header */}
|
|
83
|
+
<div className="p-4 border-b border-slate-800">
|
|
84
|
+
<div className="flex items-center justify-between">
|
|
85
|
+
<h2 className="text-lg font-semibold text-slate-100">Databases</h2>
|
|
86
|
+
<button
|
|
87
|
+
onClick={onCreateDatabase}
|
|
88
|
+
disabled={isLoading}
|
|
89
|
+
className="p-1.5 hover:bg-slate-800 rounded transition disabled:opacity-50"
|
|
90
|
+
title="Create database"
|
|
91
|
+
>
|
|
92
|
+
<Plus size={18} className="text-emerald-400" />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Database List */}
|
|
98
|
+
<div className="flex-1 overflow-y-auto">
|
|
99
|
+
{databases.length === 0 ? (
|
|
100
|
+
<div className="p-4 text-slate-400 text-sm text-center">
|
|
101
|
+
No databases. Create one to get started.
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="space-y-1 p-2">
|
|
105
|
+
{databases.map((db) => (
|
|
106
|
+
<div key={db.name}>
|
|
107
|
+
{/* Database Row */}
|
|
108
|
+
<div
|
|
109
|
+
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer transition ${
|
|
110
|
+
currentDatabase === db.name
|
|
111
|
+
? 'bg-emerald-900/30 text-emerald-400'
|
|
112
|
+
: 'text-slate-300 hover:bg-slate-800'
|
|
113
|
+
}`}
|
|
114
|
+
>
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => toggleDatabase(db.name)}
|
|
117
|
+
className="p-0.5 hover:bg-slate-700 rounded transition"
|
|
118
|
+
>
|
|
119
|
+
{expandedDbs.has(db.name) ? (
|
|
120
|
+
<ChevronDown size={16} />
|
|
121
|
+
) : (
|
|
122
|
+
<ChevronRight size={16} />
|
|
123
|
+
)}
|
|
124
|
+
</button>
|
|
125
|
+
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => onDatabaseSelect(db.name)}
|
|
128
|
+
className="flex-1 text-left truncate font-medium"
|
|
129
|
+
>
|
|
130
|
+
{db.name}
|
|
131
|
+
</button>
|
|
132
|
+
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => handleDeleteDatabase(db.name)}
|
|
135
|
+
disabled={isLoading}
|
|
136
|
+
className="p-0.5 hover:bg-red-900/50 hover:text-red-400 rounded transition disabled:opacity-50"
|
|
137
|
+
title="Delete database"
|
|
138
|
+
>
|
|
139
|
+
<Trash2 size={16} />
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Collections */}
|
|
144
|
+
{expandedDbs.has(db.name) && (
|
|
145
|
+
<div className="pl-6 space-y-1">
|
|
146
|
+
{(useAppStore.getState().collections[db.name] || []).map(
|
|
147
|
+
(col: Collection) => (
|
|
148
|
+
<div
|
|
149
|
+
key={col.name}
|
|
150
|
+
className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer transition ${
|
|
151
|
+
selectedCollection === col.name && currentDatabase === db.name
|
|
152
|
+
? 'bg-cyan-900/30 text-cyan-400'
|
|
153
|
+
: 'text-slate-400 hover:bg-slate-800'
|
|
154
|
+
}`}
|
|
155
|
+
>
|
|
156
|
+
<span className="w-4" />
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => onCollectionSelect(db.name, col.name)}
|
|
159
|
+
className="flex-1 text-left truncate text-sm"
|
|
160
|
+
>
|
|
161
|
+
{col.name}
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
onClick={() => handleDeleteCollection(db.name, col.name)}
|
|
165
|
+
disabled={isLoading}
|
|
166
|
+
className="p-0.5 hover:bg-red-900/50 hover:text-red-400 rounded transition disabled:opacity-50"
|
|
167
|
+
title="Delete collection"
|
|
168
|
+
>
|
|
169
|
+
<Trash2 size={14} />
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{/* Create Collection Button */}
|
|
176
|
+
<button
|
|
177
|
+
onClick={() => {
|
|
178
|
+
useAppStore.setState({ currentDatabase: db.name });
|
|
179
|
+
onCreateCollection();
|
|
180
|
+
}}
|
|
181
|
+
disabled={isLoading}
|
|
182
|
+
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-emerald-400 text-sm transition disabled:opacity-50 w-full rounded hover:bg-slate-800"
|
|
183
|
+
>
|
|
184
|
+
<Plus size={14} />
|
|
185
|
+
<span>New Collection</span>
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useState, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface Toast {
|
|
6
|
+
id: string;
|
|
7
|
+
message: string;
|
|
8
|
+
type: 'success' | 'error' | 'info' | 'warning';
|
|
9
|
+
duration?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ToastContextType {
|
|
13
|
+
toasts: Toast[];
|
|
14
|
+
addToast: (message: string, type: Toast['type'], duration?: number) => void;
|
|
15
|
+
removeToast: (id: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
|
19
|
+
|
|
20
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
21
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
22
|
+
|
|
23
|
+
const addToast = useCallback(
|
|
24
|
+
(message: string, type: Toast['type'], duration = 3000) => {
|
|
25
|
+
const id = Math.random().toString(36).substr(2, 9);
|
|
26
|
+
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
|
27
|
+
|
|
28
|
+
if (duration > 0) {
|
|
29
|
+
setTimeout(() => removeToast(id), duration);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
[]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const removeToast = useCallback((id: string) => {
|
|
36
|
+
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
|
41
|
+
{children}
|
|
42
|
+
<ToastContainer />
|
|
43
|
+
</ToastContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ToastContainer() {
|
|
48
|
+
const context = React.useContext(ToastContext);
|
|
49
|
+
if (!context) return null;
|
|
50
|
+
|
|
51
|
+
const { toasts, removeToast } = context;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
|
55
|
+
{toasts.map((toast) => (
|
|
56
|
+
<Toast key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Toast({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|
63
|
+
const bgColor = {
|
|
64
|
+
success: 'bg-emerald-600',
|
|
65
|
+
error: 'bg-red-600',
|
|
66
|
+
info: 'bg-blue-600',
|
|
67
|
+
warning: 'bg-amber-600',
|
|
68
|
+
}[toast.type];
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={`${bgColor} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-bottom-4 duration-300`}
|
|
73
|
+
>
|
|
74
|
+
<span className="flex-1">{toast.message}</span>
|
|
75
|
+
<button
|
|
76
|
+
onClick={onClose}
|
|
77
|
+
className="text-white hover:opacity-80 transition"
|
|
78
|
+
>
|
|
79
|
+
✕
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useToast() {
|
|
86
|
+
const context = React.useContext(ToastContext);
|
|
87
|
+
if (!context) {
|
|
88
|
+
throw new Error('useToast must be used within ToastProvider');
|
|
89
|
+
}
|
|
90
|
+
return context;
|
|
91
|
+
}
|