| import 'dart:async'; |
| |
| import 'package:jaspr/dom.dart'; |
| import 'package:jaspr/jaspr.dart'; |
| import 'package:web/web.dart' as web; |
| |
| import '../../../utils/provider.dart'; |
| import '../../../utils/styles.dart'; |
| import '../../preview/components/device_preview_button.dart'; |
| import '../../preview/components/share_button.dart'; |
| import '../../shared/components/icon_button.dart'; |
| import '../../shared/components/icons.dart'; |
| import '../../shared/components/material_icon.dart'; |
| import '../../shared/utils/date_format.dart'; |
| import '../view_models/panels_view_model.dart'; |
| import '../view_models/project_session_view_model.dart'; |
| |
| /// Header bar for the project page. |
| class ProjectHeader extends StatefulComponent { |
| const ProjectHeader({ |
| required this.onManageProjects, |
| super.key, |
| }); |
| |
| /// Called when the user clicks "Projects" to open the manage projects dialog. |
| final FutureOr<void> Function() onManageProjects; |
| |
| @override |
| State<ProjectHeader> createState() => _ProjectHeaderState(); |
| |
| @css |
| static List<StyleRule> get styles => _ProjectHeaderState.styles; |
| } |
| |
| class _ProjectHeaderState extends State<ProjectHeader> { |
| bool _isRenaming = false; |
| String _nameDraft = ''; |
| Timer? _refreshTimer; |
| |
| @override |
| void initState() { |
| super.initState(); |
| // Periodically refresh the "Last saved" relative timestamp. |
| _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { |
| setState(() {}); |
| }); |
| } |
| |
| @override |
| void dispose() { |
| _refreshTimer?.cancel(); |
| super.dispose(); |
| } |
| |
| /// Starts inline renaming from the currently persisted project name. |
| void _startRenaming(ProjectSessionViewModel projectSession) { |
| final currentProject = projectSession.session?.project; |
| if (currentProject == null) { |
| return; |
| } |
| setState(() { |
| _nameDraft = currentProject.name ?? ''; |
| _isRenaming = true; |
| }); |
| } |
| |
| /// Persists the inline rename draft and exits editing mode. |
| Future<void> _commitRename(ProjectSessionViewModel projectSession) async { |
| if (!_isRenaming) { |
| return; |
| } |
| setState(() { |
| _isRenaming = false; |
| }); |
| await projectSession.renameCurrentProject(_nameDraft); |
| } |
| |
| /// Handles keyboard completion/cancellation for the inline rename input. |
| void _handleRenameKeyDown(web.KeyboardEvent event, ProjectSessionViewModel projectSession) { |
| if (event.key == 'Enter') { |
| event.preventDefault(); |
| _commitRename(projectSession); |
| } else if (event.key == 'Escape') { |
| setState(() { |
| _isRenaming = false; |
| }); |
| } |
| } |
| |
| @override |
| Component build(BuildContext context) { |
| final panelsViewModel = context.watch<PanelsViewModel>(); |
| final projectSession = context.watch<ProjectSessionViewModel>(); |
| |
| final showEditor = panelsViewModel.showEditor; |
| final showPreview = panelsViewModel.showPreview; |
| final showAgent = panelsViewModel.showAgent; |
| |
| final currentProjectName = projectSession.session?.project.name ?? 'Untitled Project'; |
| |
| return header(classes: 'top-header', [ |
| div(classes: 'header-left', [ |
| const img(src: 'images/icon.png', classes: 'header-logo', width: 24, attributes: {'alt': 'VibePad logo'}), |
| const h1(classes: 'header-title', [.text('VibePad')]), |
| button( |
| classes: 'project-menu-trigger', |
| onClick: component.onManageProjects, |
| [const .text('Manage Projects')], |
| ), |
| ]), |
| div(classes: 'header-center', [ |
| if (_isRenaming) |
| input( |
| classes: 'project-name-input', |
| value: _nameDraft, |
| attributes: {'aria-label': 'Project name', 'autofocus': ''}, |
| events: { |
| 'input': (event) { |
| final target = event.target as web.HTMLInputElement; |
| setState(() { |
| _nameDraft = target.value; |
| }); |
| }, |
| 'keydown': (event) => _handleRenameKeyDown(event as web.KeyboardEvent, projectSession), |
| 'blur': (_) => _commitRename(projectSession), |
| }, |
| ) |
| else if (projectSession.session?.project.name == null) |
| const div(classes: 'project-title-skeleton', []) |
| else |
| button( |
| classes: 'project-name-button', |
| onClick: () => _startRenaming(projectSession), |
| [ |
| .text(currentProjectName), |
| span(classes: 'project-name-edit-icon', [ |
| materialIcon( |
| 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', |
| size: 12.0, |
| color: 'currentColor', |
| ), |
| ]), |
| ], |
| ), |
| if (projectSession.session?.project.updatedAt != null) |
| span(classes: 'project-last-saved', [ |
| .text('Last saved: ${formatRelativeDate(projectSession.session!.project.updatedAt)}'), |
| ]), |
| ]), |
| div(classes: 'header-right', [ |
| const DevicePreviewButton(), |
| const ShareButton(), |
| IconButton( |
| tooltip: 'Toggle Agent Panel', |
| onClick: (_) { |
| panelsViewModel.togglePanel(.agent); |
| }, |
| child: agentToggleIcon(active: showAgent), |
| ), |
| IconButton( |
| tooltip: 'Toggle Editor Panel', |
| onClick: (_) { |
| panelsViewModel.togglePanel(.editor); |
| }, |
| child: editorToggleIcon(active: showEditor), |
| ), |
| IconButton( |
| tooltip: 'Toggle Preview Panel', |
| onClick: (_) { |
| panelsViewModel.togglePanel(.preview); |
| }, |
| child: previewToggleIcon(active: showPreview), |
| ), |
| ]), |
| ]); |
| } |
| |
| static List<StyleRule> get styles => [ |
| css.keyframes('shimmer', { |
| '0%': const Styles(raw: {'background-position': '200% 0'}), |
| '100%': const Styles(raw: {'background-position': '-200% 0'}), |
| }), |
| css('.top-header').styles( |
| display: .flex, |
| position: const .relative(), |
| height: 48.px, |
| padding: .symmetric(horizontal: 16.px), |
| border: .only( |
| bottom: .solid(color: colorBorder, width: 1.px), |
| ), |
| justifyContent: .spaceBetween, |
| alignItems: .center, |
| flex: const .shrink(0), |
| backgroundColor: colorContainerLow, |
| ), |
| css('.header-title').styles( |
| margin: .zero, |
| color: colorOnSurface, |
| fontSize: 16.px, |
| fontWeight: .w600, |
| letterSpacing: 0.5.px, |
| ), |
| css('.header-left').styles( |
| display: .flex, |
| alignItems: .center, |
| gap: .all(8.px), |
| ), |
| css('.header-center').styles( |
| display: .flex, |
| position: .absolute(left: 50.percent, top: 50.percent), |
| transform: Transform.translate(x: (-50).percent, y: (-50).percent), |
| alignItems: .baseline, |
| gap: .all(10.px), |
| ), |
| css('.header-right').styles( |
| display: .flex, |
| alignItems: .center, |
| gap: .all(8.px), |
| ), |
| css('.project-menu-trigger').styles( |
| height: 28.px, |
| padding: .symmetric(horizontal: 8.px), |
| border: .none, |
| radius: .circular(4.px), |
| cursor: .pointer, |
| color: colorOnSurface, |
| fontSize: 13.px, |
| fontWeight: .w500, |
| backgroundColor: Colors.transparent, |
| ), |
| css('.project-menu-trigger:hover').styles( |
| backgroundColor: colorContainerHigh, |
| ), |
| css('.project-name-button').styles( |
| display: .flex, |
| maxWidth: 280.px, |
| padding: .symmetric(vertical: 4.px, horizontal: 10.px), |
| border: .none, |
| radius: .circular(4.px), |
| overflow: .hidden, |
| cursor: .pointer, |
| alignItems: .center, |
| gap: .all(6.px), |
| color: Colors.white, |
| fontSize: 16.px, |
| fontWeight: .w600, |
| letterSpacing: 0.5.px, |
| textOverflow: .ellipsis, |
| whiteSpace: .noWrap, |
| backgroundColor: Colors.transparent, |
| ), |
| css('.project-name-button:hover').styles( |
| backgroundColor: colorContainerHigh, |
| ), |
| css('.project-name-edit-icon').styles( |
| display: .flex, |
| opacity: 0.0, |
| transition: Transition('opacity', duration: 150.ms, curve: .ease), |
| alignItems: .center, |
| color: colorOnSurfaceVariant, |
| ), |
| css('.project-name-button:hover .project-name-edit-icon').styles( |
| opacity: 0.6, |
| ), |
| css('.project-name-input').styles( |
| width: 260.px, |
| height: 28.px, |
| padding: .symmetric(horizontal: 8.px), |
| border: .all(color: colorPrimary, width: 1.px), |
| radius: .circular(4.px), |
| outline: const Outline(style: .none), |
| color: Colors.white, |
| textAlign: .center, |
| fontSize: 16.px, |
| fontWeight: .w600, |
| letterSpacing: 0.5.px, |
| backgroundColor: colorContainer, |
| ), |
| css('.project-title-skeleton').styles( |
| width: 120.px, |
| height: 20.px, |
| radius: .circular(4.px), |
| raw: { |
| 'background': |
| 'linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.10) 50%, rgba(255,255,255,0.04) 75%)', |
| 'background-size': '200% 100%', |
| 'animation': 'shimmer 1.5s ease-in-out infinite', |
| }, |
| ), |
| css('.project-last-saved').styles( |
| opacity: 0.7, |
| color: colorOnSurfaceVariant, |
| fontSize: 11.px, |
| fontWeight: .w400, |
| whiteSpace: .noWrap, |
| ), |
| ]; |
| } |