feat: add frontend app, improve static file serving with lazy loading and root redirect

This commit is contained in:
2026-07-20 21:03:48 +03:00
parent 799e939037
commit d1e96d35aa
27 changed files with 2053 additions and 14 deletions
+254
View File
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import '../api/models.dart';
import '../widgets/prompt_editor.dart';
import '../api/client.dart';
class PipelineEditorScreen extends StatefulWidget {
final Pipeline? pipeline;
const PipelineEditorScreen({super.key, this.pipeline});
@override
State<PipelineEditorScreen> createState() => _PipelineEditorScreenState();
}
class _PipelineEditorScreenState extends State<PipelineEditorScreen> {
late TextEditingController _idController;
late TextEditingController _nameController;
late TextEditingController _dirController;
late List<Prompt> _prompts;
bool _saving = false;
String? _error;
late APIClient _api;
@override
void initState() {
super.initState();
_api = APIClient(baseUrl: _parseBaseUrl());
if (widget.pipeline != null) {
_idController = TextEditingController(text: widget.pipeline!.id);
_nameController = TextEditingController(text: widget.pipeline!.name);
_dirController = TextEditingController(text: widget.pipeline!.workingDir);
_prompts = List.from(widget.pipeline!.prompts);
} else {
_idController = TextEditingController();
_nameController = TextEditingController();
_dirController = TextEditingController();
_prompts = [];
}
}
@override
void dispose() {
_idController.dispose();
_nameController.dispose();
_dirController.dispose();
super.dispose();
}
static String _parseBaseUrl() {
final uri = Uri.base;
return '${uri.scheme}://${uri.host}${uri.port != 80 && uri.port != 443 ? ':${uri.port}' : ''}';
}
void _addPrompt() {
setState(() {
_prompts.add(
Prompt(id: _generateId(), text: '', title: '', order: _prompts.length),
);
});
}
void _removePrompt(int index) {
setState(() {
_prompts.removeAt(index);
for (int i = index; i < _prompts.length; i++) {
_prompts[i] = _prompts[i].copyWith(order: i);
}
});
}
void _updatePrompt(int index, Prompt updated) {
setState(() {
_prompts[index] = updated;
});
}
static String _generateId() {
return DateTime.now().millisecondsSinceEpoch.toRadixString(36);
}
bool _validate() {
if (_idController.text.trim().isEmpty) {
setState(() => _error = 'ID is required');
return false;
}
if (_nameController.text.trim().isEmpty) {
setState(() => _error = 'Name is required');
return false;
}
return true;
}
Future<void> _save() async {
if (!_validate()) return;
setState(() {
_saving = true;
_error = null;
});
final sortedPrompts = List<Prompt>.from(_prompts)
..sort((a, b) => a.order.compareTo(b.order));
final pipeline = Pipeline(
id: _idController.text.trim(),
name: _nameController.text.trim(),
prompts: sortedPrompts,
workingDir: _dirController.text.trim(),
createdAt: widget.pipeline?.createdAt ?? '',
updatedAt: '',
);
try {
final saved = await _api.createPipeline(pipeline);
if (mounted) {
Navigator.pop(context, saved);
}
} catch (e) {
setState(() {
_error = e.toString();
_saving = false;
});
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.pipeline != null;
return Scaffold(
appBar: AppBar(
title: Text(isEdit ? 'Edit Pipeline' : 'Create Pipeline'),
actions: [
if (isEdit)
IconButton(
icon: const Icon(Icons.check, color: Colors.green),
onPressed: _saving ? null : _save,
tooltip: 'Save',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(child: Text(_error!)),
],
),
),
),
),
TextField(
controller: _idController,
decoration: const InputDecoration(
labelText: 'ID',
border: OutlineInputBorder(),
isDense: true,
),
enabled: !isEdit,
),
const SizedBox(height: 12),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: _dirController,
decoration: const InputDecoration(
labelText: 'Working Directory',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Prompts',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
FilledButton.icon(
onPressed: _addPrompt,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Step'),
),
],
),
const SizedBox(height: 12),
if (_prompts.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: Text(
'No prompts yet. Add a step to get started.',
style: TextStyle(color: Colors.grey),
),
),
),
for (int i = 0; i < _prompts.length; i++)
PromptEditor(
prompt: _prompts[i],
showDelete: _prompts.length > 1,
onChanged: (p) => _updatePrompt(i, p),
onDelete: () => _removePrompt(i),
),
if (_prompts.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: SizedBox(
width: double.infinity,
height: 48,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(
_saving
? 'Saving...'
: (isEdit ? 'Save Changes' : 'Create Pipeline'),
),
),
),
),
],
),
),
);
}
}