mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 12:32:58 +00:00
first test
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { CourseManagement } from './components/CourseManagement';
|
||||
import { InstanceManagement } from './components/InstanceManagement';
|
||||
import './styles.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/dashboard/courses" component={CourseManagement} />
|
||||
<Route path="/dashboard/instances" component={InstanceManagement} />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,537 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
|
||||
interface Course {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
||||
duration: number;
|
||||
price: number;
|
||||
thumbnail: string;
|
||||
tags: string[];
|
||||
resources: CourseResource[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface CourseResource {
|
||||
id: number;
|
||||
course_id: number;
|
||||
title: string;
|
||||
type: 'youtube' | 'ztm' | 'github' | 'fireship' | 'link';
|
||||
url: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
order: number;
|
||||
is_required: boolean;
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
api_key: string;
|
||||
is_active: boolean;
|
||||
version: string;
|
||||
created_at: string;
|
||||
last_sync: string;
|
||||
admin_user_id: number;
|
||||
}
|
||||
|
||||
export const CourseManagement = () => {
|
||||
const [courses, setCourses] = createSignal<Course[]>([]);
|
||||
const [instances, setInstances] = createSignal<Instance[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [editingCourse, setEditingCourse] = createSignal<Course | null>(null);
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [resources, setResources] = createSignal<CourseResource[]>([]);
|
||||
const [tagInput, setTagInput] = createSignal('');
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = createSignal({
|
||||
title: '',
|
||||
category: '',
|
||||
difficulty: '' as 'beginner' | 'intermediate' | 'advanced' | '',
|
||||
duration: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const categories = [
|
||||
'programming',
|
||||
'design',
|
||||
'business',
|
||||
'marketing',
|
||||
'data-science',
|
||||
'web-development',
|
||||
'mobile-development',
|
||||
'devops',
|
||||
'other'
|
||||
];
|
||||
|
||||
const resourceTypes = [
|
||||
{ value: 'youtube', label: 'YouTube', color: '#ff0000' },
|
||||
{ value: 'ztm', label: 'ZTM', color: '#3b82f6' },
|
||||
{ value: 'github', label: 'GitHub', color: '#333' },
|
||||
{ value: 'fireship', label: 'Fireship', color: '#f59e0b' },
|
||||
{ value: 'link', label: 'Link', color: '#6b7280' }
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadCourses();
|
||||
await loadInstances();
|
||||
});
|
||||
|
||||
const loadCourses = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/courses');
|
||||
const data = await response.json();
|
||||
setCourses(data.courses || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading courses:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInstances = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/instances');
|
||||
const data = await response.json();
|
||||
setInstances(data.instances || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading instances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingCourse(null);
|
||||
setFormData({
|
||||
title: '',
|
||||
category: '',
|
||||
difficulty: '',
|
||||
duration: '',
|
||||
description: '',
|
||||
});
|
||||
setTags([]);
|
||||
setResources([]);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (course: Course) => {
|
||||
setEditingCourse(course);
|
||||
setFormData({
|
||||
title: course.title,
|
||||
category: course.category,
|
||||
difficulty: course.difficulty,
|
||||
duration: course.duration.toString(),
|
||||
description: course.description,
|
||||
});
|
||||
setTags(course.tags || []);
|
||||
setResources(course.resources || []);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingCourse(null);
|
||||
setTags([]);
|
||||
setResources([]);
|
||||
};
|
||||
|
||||
const addTag = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const value = tagInput().trim();
|
||||
if (value && !tags().includes(value)) {
|
||||
setTags([...tags(), value]);
|
||||
setTagInput('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags().filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const addResource = () => {
|
||||
setResources([...resources(), {
|
||||
id: Date.now(),
|
||||
course_id: editingCourse()?.id || 0,
|
||||
title: '',
|
||||
type: 'link',
|
||||
url: '',
|
||||
description: '',
|
||||
duration: 0,
|
||||
order: resources().length + 1,
|
||||
is_required: false
|
||||
}]);
|
||||
};
|
||||
|
||||
const updateResource = (index: number, field: keyof CourseResource, value: any) => {
|
||||
const updatedResources = [...resources()];
|
||||
updatedResources[index] = { ...updatedResources[index], [field]: value };
|
||||
setResources(updatedResources);
|
||||
};
|
||||
|
||||
const removeResource = (index: number) => {
|
||||
setResources(resources().filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const saveCourse = async () => {
|
||||
try {
|
||||
const courseData = {
|
||||
...formData(),
|
||||
duration: parseInt(formData().duration),
|
||||
tags: tags(),
|
||||
resources: resources()
|
||||
};
|
||||
|
||||
const url = editingCourse() ? `/api/v1/courses/${editingCourse()!.id}` : '/api/v1/courses';
|
||||
const method = editingCourse() ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(courseData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
await loadCourses();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Error: ' + (error.error || 'Failed to save course'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving course:', error);
|
||||
alert('Error: Failed to save course');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCourse = async (courseId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this course?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/courses/${courseId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadCourses();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Error: ' + (error.error || 'Failed to delete course'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting course:', error);
|
||||
alert('Error: Failed to delete course');
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'bg-green-100 text-green-800';
|
||||
case 'intermediate': return 'bg-orange-100 text-orange-800';
|
||||
case 'advanced': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<header class="bg-white/95 backdrop-blur-sm rounded-2xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
|
||||
T
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
|
||||
</div>
|
||||
<nav class="flex gap-2">
|
||||
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Dashboard</a>
|
||||
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Courses</a>
|
||||
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Instances</a>
|
||||
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="bg-white/95 backdrop-blur-sm rounded-2xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">Course Management</h2>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>+</span> Create New Course
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()} fallback={
|
||||
<Show when={courses().length > 0} fallback={
|
||||
<div class="text-center py-16 text-gray-500">
|
||||
<div class="text-6xl mb-4 opacity-50">📚</div>
|
||||
<div class="text-xl font-semibold mb-2">No courses yet</div>
|
||||
<p>Create your first learning course to get started!</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={courses()}>
|
||||
{(course) => (
|
||||
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden group">
|
||||
<div class="h-48 bg-gradient-to-r from-indigo-500 to-purple-600 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-white text-5xl font-bold">
|
||||
{course.title.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="absolute top-4 right-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-full text-sm font-semibold text-gray-900">
|
||||
FREE
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{course.title}</h3>
|
||||
<p class="text-gray-600 text-sm mb-4 line-clamp-2">{course.description}</p>
|
||||
<div class="flex justify-between items-center mb-4 text-sm text-gray-500">
|
||||
<span>{course.category}</span>
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium ${getDifficultyColor(course.difficulty)}`}>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
<span>{course.duration}h</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
onClick={() => window.open(`/api/v1/courses/${course.id}`, '_blank')}
|
||||
>
|
||||
👁️ View
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
onClick={() => openEditModal(course)}
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors text-sm"
|
||||
onClick={() => deleteCourse(course.id)}
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}>
|
||||
<div class="text-center py-8 text-gray-500">Loading courses...</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Modal */}
|
||||
<Show when={showModal()}>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-2xl font-semibold text-gray-900">
|
||||
{editingCourse() ? 'Edit Course' : 'Create New Course'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Course Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().title}
|
||||
onInput={(e) => setFormData({ ...formData(), title: e.currentTarget.value })}
|
||||
placeholder="Course Title"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Category *</label>
|
||||
<select
|
||||
value={formData().category}
|
||||
onChange={(e) => setFormData({ ...formData(), category: e.currentTarget.value })}
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
<For each={categories}>
|
||||
{(category) => <option value={category}>{category}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Difficulty *</label>
|
||||
<select
|
||||
value={formData().difficulty}
|
||||
onChange={(e) => setFormData({ ...formData(), difficulty: e.currentTarget.value as any })}
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
<option value="">Select Difficulty</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Duration (hours) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData().duration}
|
||||
onInput={(e) => setFormData({ ...formData(), duration: e.currentTarget.value })}
|
||||
min="1"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Description *</label>
|
||||
<textarea
|
||||
value={formData().description}
|
||||
onInput={(e) => setFormData({ ...formData(), description: e.currentTarget.value })}
|
||||
placeholder="Course description"
|
||||
rows={4}
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Tags (press Enter to add)</label>
|
||||
<div class="flex flex-wrap gap-2 p-3 border-2 border-gray-200 rounded-lg min-h-[50px] cursor-text" onClick={(e: MouseEvent) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const input = target.querySelector('input') as HTMLInputElement;
|
||||
input?.focus();
|
||||
}}>
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<span class="bg-indigo-500 text-white px-2 py-1 rounded-md text-sm flex items-center gap-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} class="font-bold">×</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput()}
|
||||
onInput={(e) => setTagInput(e.currentTarget.value)}
|
||||
onKeyDown={addTag}
|
||||
placeholder="Add tags..."
|
||||
class="border-none outline-none flex-1 min-w-[100px] p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h4 class="text-lg font-medium text-gray-900">Course Resources</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addResource}
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span>+</span> Add Resource
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={resources()}>
|
||||
{(resource, index) => (
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Resource Title"
|
||||
value={resource.title}
|
||||
onInput={(e) => updateResource(index(), 'title', e.currentTarget.value)}
|
||||
class="w-full p-2 border border-gray-200 rounded-md"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={resource.type}
|
||||
onChange={(e) => updateResource(index(), 'type', e.currentTarget.value)}
|
||||
class="p-2 border border-gray-200 rounded-md"
|
||||
>
|
||||
<For each={resourceTypes}>
|
||||
{(type) => <option value={type.value}>{type.label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="URL"
|
||||
value={resource.url}
|
||||
onInput={(e) => updateResource(index(), 'url', e.currentTarget.value)}
|
||||
class="flex-1 p-2 border border-gray-200 rounded-md"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Duration (min)"
|
||||
value={resource.duration}
|
||||
onInput={(e) => updateResource(index(), 'duration', parseInt(e.currentTarget.value) || 0)}
|
||||
class="w-24 p-2 border border-gray-200 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeResource(index())}
|
||||
class="px-3 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCourse}
|
||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Save Course
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
|
||||
interface DashboardStats {
|
||||
total_users: number;
|
||||
total_courses: number;
|
||||
total_instances: number;
|
||||
active_courses: number;
|
||||
total_progress: number;
|
||||
}
|
||||
|
||||
interface Course {
|
||||
id: number;
|
||||
title: string;
|
||||
category: string;
|
||||
difficulty: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
version: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_sync: string;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
export const Dashboard = () => {
|
||||
const [stats, setStats] = createSignal<DashboardStats>({
|
||||
total_users: 0,
|
||||
total_courses: 0,
|
||||
total_instances: 0,
|
||||
active_courses: 0,
|
||||
total_progress: 0
|
||||
});
|
||||
|
||||
const [courses, setCourses] = createSignal<Course[]>([]);
|
||||
const [instances, setInstances] = createSignal<Instance[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([
|
||||
loadStats(),
|
||||
loadCourses(),
|
||||
loadInstances()
|
||||
]);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/dashboard/stats');
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCourses = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/dashboard/courses');
|
||||
const data = await response.json();
|
||||
setCourses(data.courses || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading courses:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInstances = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/instances');
|
||||
const data = await response.json();
|
||||
setInstances(data.instances || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading instances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'bg-green-100 text-green-800';
|
||||
case 'intermediate': return 'bg-orange-100 text-orange-800';
|
||||
case 'advanced': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<header class="glass rounded-2xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
|
||||
T
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
|
||||
</div>
|
||||
<nav class="flex gap-2">
|
||||
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Dashboard</a>
|
||||
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Courses</a>
|
||||
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Instances</a>
|
||||
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
||||
👥
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_users}</div>
|
||||
<div class="text-gray-600 font-medium">Total Users</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-gradient-to-r from-green-500 to-green-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
||||
📚
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().active_courses}</div>
|
||||
<div class="text-gray-600 font-medium">Active Courses</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
||||
🖥️
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_instances}</div>
|
||||
<div class="text-gray-600 font-medium">Connected Instances</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-gradient-to-r from-orange-500 to-orange-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
||||
📈
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_progress}</div>
|
||||
<div class="text-gray-600 font-medium">Learning Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Recent Courses */}
|
||||
<div class="lg:col-span-2">
|
||||
<div class="glass rounded-2xl p-6 shadow-xl">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Recent Courses</h2>
|
||||
<a href="/dashboard/courses" class="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors">
|
||||
Manage Courses
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Show when={loading()} fallback={
|
||||
<Show when={courses().length > 0} fallback={
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<div class="text-5xl mb-4 opacity-50">📚</div>
|
||||
<div class="text-lg font-semibold mb-2">No courses yet</div>
|
||||
<p>Create your first course to get started!</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="space-y-4">
|
||||
<For each={courses().slice(0, 5)}>
|
||||
{(course) => (
|
||||
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<div class="w-12 h-12 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold">
|
||||
{course.title.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{course.title}</div>
|
||||
<div class="text-sm text-gray-600">{course.category} • {course.difficulty} • {course.duration}h</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
onClick={() => window.open(`/api/v1/courses/${course.id}`, '_blank')}
|
||||
title="View"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
onClick={() => window.location.href = `/dashboard/courses?edit=${course.id}`}
|
||||
title="Edit"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}>
|
||||
<div class="text-center py-8 text-gray-500">Loading courses...</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Instances */}
|
||||
<div>
|
||||
<div class="glass rounded-2xl p-6 shadow-xl">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Active Instances</h2>
|
||||
<a href="/dashboard/instances" class="text-indigo-600 hover:text-indigo-700 text-sm font-medium">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Show when={loading()} fallback={
|
||||
<Show when={instances().length > 0} fallback={
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<div class="text-5xl mb-4 opacity-50">🖥️</div>
|
||||
<div class="text-lg font-semibold mb-2">No instances</div>
|
||||
<p>Register your first instance to get started!</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="space-y-3">
|
||||
<For each={instances().slice(0, 3)}>
|
||||
{(instance) => (
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<div class={`w-2 h-2 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{instance.name}</div>
|
||||
<div class="text-sm text-gray-600">{instance.version}</div>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
onClick={() => window.open(`/api/v1/instances/${instance.id}`, '_blank')}
|
||||
title="View"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}>
|
||||
<div class="text-center py-8 text-gray-500">Loading instances...</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
|
||||
interface Instance {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
api_key: string;
|
||||
is_active: boolean;
|
||||
version: string;
|
||||
created_at: string;
|
||||
last_sync: string;
|
||||
admin_user_id: number;
|
||||
}
|
||||
|
||||
export const InstanceManagement = () => {
|
||||
const [instances, setInstances] = createSignal<Instance[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [editingInstance, setEditingInstance] = createSignal<Instance | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = createSignal({
|
||||
name: '',
|
||||
url: '',
|
||||
version: ''
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadInstances();
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const loadInstances = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/instances');
|
||||
const data = await response.json();
|
||||
setInstances(data.instances || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading instances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingInstance(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
version: ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (instance: Instance) => {
|
||||
setEditingInstance(instance);
|
||||
setFormData({
|
||||
name: instance.name,
|
||||
url: instance.url,
|
||||
version: instance.version || ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingInstance(null);
|
||||
};
|
||||
|
||||
const saveInstance = async () => {
|
||||
try {
|
||||
const url = editingInstance() ? `/api/v1/instances/${editingInstance()!.id}` : '/api/v1/instances';
|
||||
const method = editingInstance() ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(formData())
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
await loadInstances();
|
||||
|
||||
if (!editingInstance()) {
|
||||
const result = await response.json();
|
||||
if (result.api_key) {
|
||||
alert(`🎉 Instance registered successfully!\n\nAPI Key: ${result.api_key}\n\nSave this key securely - it will not be shown again.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Error: ' + (error.error || 'Failed to save instance'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving instance:', error);
|
||||
alert('Error: Failed to save instance');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInstance = async (instanceId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this instance? This action cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/instances/${instanceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadInstances();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Error: ' + (error.error || 'Failed to delete instance'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting instance:', error);
|
||||
alert('Error: Failed to delete instance');
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async (instance: Instance) => {
|
||||
try {
|
||||
const response = await fetch(`${instance.url}/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Connection successful! Instance is responding.');
|
||||
} else {
|
||||
alert('❌ Connection failed. Instance returned an error.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ Connection failed. Unable to reach the instance.');
|
||||
}
|
||||
};
|
||||
|
||||
const copyApiKey = (apiKey: string, event: MouseEvent) => {
|
||||
navigator.clipboard.writeText(apiKey).then(() => {
|
||||
// Show feedback (you could implement a toast here)
|
||||
const btn = event.target as HTMLButtonElement;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
(btn as HTMLButtonElement).style.background = '#10b981';
|
||||
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
(btn as HTMLButtonElement).style.background = '';
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<header class="glass rounded-2xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
|
||||
T
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
|
||||
</div>
|
||||
<nav class="flex gap-2">
|
||||
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Dashboard</a>
|
||||
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Courses</a>
|
||||
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Instances</a>
|
||||
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="glass rounded-2xl p-6 shadow-xl">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">Instance Management</h2>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>+</span> Register New Instance
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()} fallback={
|
||||
<Show when={instances().length > 0} fallback={
|
||||
<div class="text-center py-16 text-gray-500">
|
||||
<div class="text-6xl mb-4 opacity-50">🖥️</div>
|
||||
<div class="text-xl font-semibold mb-2">No instances registered</div>
|
||||
<p>Register your first Trackeep instance to get started!</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={instances()}>
|
||||
{(instance) => (
|
||||
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden relative">
|
||||
<div class={`absolute top-4 right-4 w-3 h-3 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'} ${instance.is_active ? 'animate-pulse' : ''}`}></div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">{instance.name}</h3>
|
||||
<a
|
||||
href={instance.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-indigo-600 hover:text-indigo-700 text-sm mb-2 block"
|
||||
>
|
||||
{instance.url}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div class={`w-2 h-2 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span>{instance.is_active ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Version</div>
|
||||
<div class="text-sm font-medium text-gray-900">{instance.version || 'Unknown'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Created</div>
|
||||
<div class="text-sm font-medium text-gray-900">{formatDate(instance.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Last Sync</div>
|
||||
<div class="text-sm font-medium text-gray-900">{formatDate(instance.last_sync)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Instance ID</div>
|
||||
<div class="text-sm font-medium text-gray-900">#{instance.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">API Key</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={instance.api_key}
|
||||
class="flex-1 text-xs font-mono bg-transparent border-none outline-none text-gray-600"
|
||||
/>
|
||||
<button
|
||||
onClick={(e: MouseEvent) => copyApiKey(instance.api_key, e)}
|
||||
class="px-2 py-1 bg-indigo-500 text-white text-xs rounded hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 pt-4 border-t border-gray-200">
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 100)}</div>
|
||||
<div class="text-xs text-gray-500">Users</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 50)}</div>
|
||||
<div class="text-xs text-gray-500">Courses</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 1000)}</div>
|
||||
<div class="text-xs text-gray-500">API Calls</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
class="flex-1 p-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-lg transition-colors text-sm"
|
||||
onClick={() => testConnection(instance)}
|
||||
title="Test Connection"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 p-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-lg transition-colors text-sm"
|
||||
onClick={() => openEditModal(instance)}
|
||||
title="Edit"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors text-sm"
|
||||
onClick={() => deleteInstance(instance.id)}
|
||||
title="Delete"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}>
|
||||
<div class="text-center py-8 text-gray-500">Loading instances...</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instance Modal */}
|
||||
<Show when={showModal()}>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-2xl p-8 max-w-md w-full">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-2xl font-semibold text-gray-900">
|
||||
{editingInstance() ? 'Edit Instance' : 'Register New Instance'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Instance Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().name}
|
||||
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
|
||||
placeholder="My Trackeep Instance"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Instance URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData().url}
|
||||
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
|
||||
placeholder="https://myapp.trackeep.com"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().version}
|
||||
onInput={(e) => setFormData({ ...formData(), version: e.currentTarget.value })}
|
||||
placeholder="1.0.0"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveInstance}
|
||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
{editingInstance() ? 'Update Instance' : 'Register Instance'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router } from '@solidjs/router';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (root) {
|
||||
render(() => (
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
), root);
|
||||
} else {
|
||||
console.error('Root element not found');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom styles for Trackeep-inspired UI */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Glassmorphism effects */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
Reference in New Issue
Block a user