blob: f86cae8d1d624a78453068814e2ebe10ebfeb21d [file]
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,
),
];
}