initial ux ui improvements and revised dashboard

This commit is contained in:
Richard Osborne
2025-12-31 09:34:27 +01:00
parent ae7d3b8a8b
commit 73b5a42122
109 changed files with 13583 additions and 1111 deletions

View File

@@ -1,6 +1,7 @@
import '../src/styles/custom-properties/fonts.css';
import '../src/styles/custom-properties/colors.css';
import '../src/styles/custom-properties/animations.css';
import '../src/styles/custom-properties/spacing.css';
import '../src/styles/global.css';
import { themes } from '@storybook/theming';

View File

@@ -22,7 +22,7 @@
.Title {
font-family: var(--font-family);
color: #aaa;
color: var(--theme-color-fg-muted);
font-size: 12px;
position: absolute;
left: 50%;
@@ -31,7 +31,7 @@
.Version {
font-family: var(--font-family);
color: #c4c4c4;
color: var(--theme-color-fg-default-shy);
font-size: 11px;
margin-right: 10px;
}

View File

@@ -49,8 +49,8 @@
}
.language-javascript {
color: #eee;
background-color: #444;
color: var(--theme-color-fg-highlight);
background-color: var(--theme-color-bg-4);
display: block;
padding: 5px;
overflow: scroll;

View File

@@ -2,20 +2,25 @@
.Root {
border: 0;
font-size: 14px;
font-weight: var(--font-weight-semibold);
padding-left: 12px;
padding-right: 12px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-tight);
padding: var(--spacing-button-padding-y) var(--spacing-button-padding-x);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-button-gap);
cursor: pointer;
min-height: length.$global-input-size;
min-width: 70px;
flex-grow: 0;
flex-shrink: 0;
position: relative;
transition: background-color var(--speed-turbo) var(--easing-base), color var(--speed-turbo) var(--easing-base);
border-radius: var(--radius-md);
transition: background-color var(--transition-default) var(--transition-ease),
color var(--transition-default) var(--transition-ease),
border-color var(--transition-default) var(--transition-ease),
box-shadow var(--transition-default) var(--transition-ease), transform var(--transition-fast) var(--transition-ease);
&.is-size-small {
min-height: length.$global-input-size-small;
@@ -25,13 +30,22 @@
&.is-variant-cta {
color: var(--theme-color-on-primary);
background-color: var(--theme-color-primary);
box-shadow: var(--shadow-sm);
path {
fill: var(--theme-color-on-primary);
}
&:hover {
&:hover:not(:disabled) {
background-color: var(--theme-color-primary-highlight);
box-shadow: var(--shadow-default);
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: var(--theme-color-primary-dim);
box-shadow: none;
transform: translateY(0);
}
}
@@ -105,14 +119,22 @@
&:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: var(--theme-color-bg-3) !important;
color: var(--theme-color-fg-muted);
transform: none !important;
box-shadow: none !important;
path {
fill: var(--theme-color-fg-muted);
}
}
&:focus-visible {
outline: 2px solid var(--theme-color-focus-ring);
outline-offset: 2px;
}
&.has-left-spacing {
margin-left: 12px;
}

View File

@@ -30,12 +30,16 @@
.InputArea {
height: length.$global-input-size;
padding: 4px 5px;
padding: var(--spacing-input-padding-y) var(--spacing-input-padding-x);
position: relative;
display: flex;
cursor: text;
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
transition: margin-bottom var(--speed-quick) var(--easing-base),
background-color var(--speed-quick) var(--easing-base);
background-color var(--transition-default) var(--transition-ease),
border-color var(--transition-default) var(--transition-ease),
box-shadow var(--transition-default) var(--transition-ease);
.Root.is-variant-default & {
background-color: var(--theme-color-bg-3);
@@ -44,8 +48,15 @@
background-color: var(--theme-color-bg-4);
}
&:hover:not(.is-focused):not(.is-readonly) {
border-color: var(--theme-color-border-strong);
}
&.is-focused {
background-color: var(--theme-color-bg-1);
border-color: var(--theme-color-focus-ring);
box-shadow: 0 0 0 2px rgba(210, 31, 60, 0.15);
outline: none;
}
}

View File

@@ -11,6 +11,11 @@
&.has-backdrop {
background-color: var(--theme-color-bg-1-transparent);
/* Optional: Subtle blur for modern feel */
/* Note: May have performance implications on older hardware */
/* backdrop-filter: blur(4px); */
/* -webkit-backdrop-filter: blur(4px); */
}
&.is-locking-scroll {
@@ -23,8 +28,22 @@
}
.VisibleDialog {
filter: drop-shadow(0 4px 15px var(--theme-color-bg-1-transparent-2));
box-shadow: 0 0 10px -5px var(--theme-color-bg-1-transparent-2);
/* Modern elevated shadow */
box-shadow: var(--shadow-popup);
/* Border for definition against backdrop */
border: 1px solid var(--theme-color-border-subtle);
/* Modern rounded corners */
border-radius: var(--radius-lg);
/* Overflow handling */
overflow: hidden;
/* Size constraints */
max-height: 90vh;
max-width: 90vw;
position: absolute;
width: var(--width);
pointer-events: all;
@@ -53,10 +72,28 @@
right: 0;
bottom: 0;
background-color: var(--background);
border-radius: 2px;
border-radius: var(--radius-lg);
overflow: hidden;
pointer-events: none; // Allow clicks to pass through to content
}
/* Custom scrollbar styling for dialog content */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--theme-color-bg-3);
}
&::-webkit-scrollbar-thumb {
background: var(--theme-color-bg-5);
border-radius: var(--radius-full);
&:hover {
background: var(--theme-color-fg-muted);
}
}
}
.Arrow {

View File

@@ -3,6 +3,16 @@
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-4);
/* Modern rounded corners */
border-radius: var(--radius-lg);
/* Border for definition */
border: 1px solid var(--theme-color-border-subtle);
/* Elevated shadow */
box-shadow: var(--shadow-popup);
max-width: 810px;
max-height: 90vh;
width: 80vw;
@@ -22,40 +32,58 @@
.CloseButtonContainer {
position: absolute;
top: 8px;
right: 8px;
top: var(--spacing-2);
right: var(--spacing-2);
}
.Header {
padding: 20px 40px 16px;
padding: var(--spacing-5) var(--spacing-10) var(--spacing-4);
display: flex;
justify-content: space-between;
align-items: flex-start;
&.has-divider {
border-bottom: 1px solid var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-subtle);
}
}
.Footer {
padding: 20px 40px 16px;
padding: var(--spacing-5) var(--spacing-10) var(--spacing-4);
display: flex;
justify-content: space-between;
align-items: flex-start;
&.has-divider {
border-top: 1px solid var(--theme-color-bg-3);
border-top: 1px solid var(--theme-color-border-subtle);
}
}
.TitleWrapper {
padding-top: 20px;
padding-right: 40px;
padding-top: var(--spacing-5);
padding-right: var(--spacing-10);
}
.Content {
padding: 0 40px 40px;
padding-top: 16px;
padding: 0 var(--spacing-10) var(--spacing-10);
padding-top: var(--spacing-4);
overflow-x: hidden;
overflow-y: overlay;
/* Custom scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--theme-color-bg-3);
}
&::-webkit-scrollbar-thumb {
background: var(--theme-color-bg-5);
border-radius: var(--radius-full);
&:hover {
background: var(--theme-color-fg-muted);
}
}
}

View File

@@ -0,0 +1,121 @@
/**
* TabBar - Modern, clean horizontal tab navigation
*/
.Root {
display: flex;
align-items: center;
gap: 4px;
padding: 0;
margin: 0;
background: transparent;
border-bottom: 1px solid var(--theme-color-border-default);
}
.Tab {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: transparent;
border: none;
cursor: pointer;
color: var(--theme-color-fg-default-shy);
font-size: 14px;
font-weight: 500;
line-height: 1;
transition: all 0.2s ease;
white-space: nowrap;
user-select: none;
// Bottom border indicator
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: transparent;
transition: background 0.2s ease;
}
&:hover:not(.is-disabled) {
color: var(--theme-color-fg-default);
background: var(--theme-color-bg-hover);
}
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--theme-color-primary);
}
&:focus:not(:focus-visible) {
box-shadow: none;
}
&.is-active {
color: var(--theme-color-primary);
font-weight: 600;
&::after {
background: var(--theme-color-primary);
}
}
&.is-disabled {
color: var(--theme-color-fg-disabled);
cursor: not-allowed;
opacity: 0.5;
}
}
.Tab-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.Tab-label {
display: flex;
align-items: center;
}
// Size variants
.is-size-small {
.Tab {
padding: 8px 16px;
font-size: 13px;
gap: 6px;
}
.Tab-icon {
font-size: 14px;
}
}
.is-size-medium {
.Tab {
padding: 12px 20px;
font-size: 14px;
gap: 8px;
}
.Tab-icon {
font-size: 16px;
}
}
.is-size-large {
.Tab {
padding: 16px 24px;
font-size: 15px;
gap: 10px;
}
.Tab-icon {
font-size: 18px;
}
}

View File

@@ -0,0 +1,115 @@
/**
* TabBar - Storybook Stories
*/
import { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { TabBar, TabBarItem } from './TabBar';
const meta: Meta<typeof TabBar> = {
title: 'Layout/TabBar',
component: TabBar,
parameters: {
layout: 'padded'
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof TabBar>;
// Basic tabs without icons
const basicItems: TabBarItem[] = [
{ id: 'projects', label: 'Projects' },
{ id: 'learn', label: 'Learn' },
{ id: 'templates', label: 'Templates' }
];
// Tabs with icons
const iconItems: TabBarItem[] = [
{ id: 'projects', label: 'Projects', icon: IconName.Folder },
{ id: 'learn', label: 'Learn', icon: IconName.Book },
{ id: 'templates', label: 'Templates', icon: IconName.Components }
];
// Tabs with disabled state
const disabledItems: TabBarItem[] = [
{ id: 'projects', label: 'Projects', icon: IconName.Folder },
{ id: 'learn', label: 'Learn', icon: IconName.Book },
{ id: 'templates', label: 'Templates', icon: IconName.Components, disabled: true },
{ id: 'marketplace', label: 'Marketplace', icon: IconName.Package, disabled: true }
];
// Interactive wrapper for stories
function TabBarDemo({ items, size }: { items: TabBarItem[]; size?: 'small' | 'medium' | 'large' }) {
const [activeId, setActiveId] = useState(items[0].id);
return (
<div>
<TabBar items={items} activeItemId={activeId} onChange={setActiveId} size={size} />
<div style={{ padding: '20px', color: 'var(--theme-color-fg-default)' }}>
<strong>Active Tab:</strong> {activeId}
</div>
</div>
);
}
export const Basic: Story = {
render: () => <TabBarDemo items={basicItems} />
};
export const WithIcons: Story = {
render: () => <TabBarDemo items={iconItems} />
};
export const WithDisabled: Story = {
render: () => <TabBarDemo items={disabledItems} />
};
export const SmallSize: Story = {
render: () => <TabBarDemo items={iconItems} size="small" />
};
export const MediumSize: Story = {
render: () => <TabBarDemo items={iconItems} size="medium" />
};
export const LargeSize: Story = {
render: () => <TabBarDemo items={iconItems} size="large" />
};
export const ManyTabs: Story = {
render: () => {
const manyItems: TabBarItem[] = [
{ id: '1', label: 'Projects', icon: IconName.Folder },
{ id: '2', label: 'Learn', icon: IconName.Book },
{ id: '3', label: 'Templates', icon: IconName.Components },
{ id: '4', label: 'Marketplace', icon: IconName.Package },
{ id: '5', label: 'Settings', icon: IconName.Settings },
{ id: '6', label: 'Help', icon: IconName.Question }
];
return <TabBarDemo items={manyItems} />;
}
};
export const KeyboardNavigation: Story = {
render: () => (
<div>
<TabBarDemo items={iconItems} />
<div style={{ padding: '20px', color: 'var(--theme-color-fg-default-shy)', fontSize: '13px' }}>
<p>
<strong>Keyboard shortcuts:</strong>
</p>
<ul>
<li>Arrow Left/Right: Navigate between tabs</li>
<li>Home: Go to first tab</li>
<li>End: Go to last tab</li>
<li>Tab: Focus next element</li>
</ul>
</div>
</div>
)
};

View File

@@ -0,0 +1,124 @@
/**
* TabBar - Modern horizontal tab navigation component
*
* A clean, accessible tab bar for switching between views.
* Supports keyboard navigation, icons, and state persistence.
*
* @module noodl-core-ui/components/layout
*/
import classNames from 'classnames';
import React, { useEffect, useRef } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { UnsafeStyleProps } from '@noodl-core-ui/types/global';
import css from './TabBar.module.scss';
export interface TabBarItem {
id: string;
label: string;
icon?: IconName;
disabled?: boolean;
testId?: string;
}
export interface TabBarProps extends UnsafeStyleProps {
items: TabBarItem[];
activeItemId: string;
onChange: (itemId: string) => void;
/**
* Size variant for the tab bar
*/
size?: 'small' | 'medium' | 'large';
}
export function TabBar({
items,
activeItemId,
onChange,
size = 'medium',
UNSAFE_className,
UNSAFE_style
}: TabBarProps) {
const tabRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({});
/**
* Handle keyboard navigation
*/
const handleKeyDown = (event: React.KeyboardEvent, currentIndex: number) => {
const enabledItems = items.filter((item) => !item.disabled);
const currentEnabledIndex = enabledItems.findIndex((item) => item.id === items[currentIndex].id);
let newIndex = currentEnabledIndex;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
newIndex = currentEnabledIndex > 0 ? currentEnabledIndex - 1 : enabledItems.length - 1;
break;
case 'ArrowRight':
event.preventDefault();
newIndex = currentEnabledIndex < enabledItems.length - 1 ? currentEnabledIndex + 1 : 0;
break;
case 'Home':
event.preventDefault();
newIndex = 0;
break;
case 'End':
event.preventDefault();
newIndex = enabledItems.length - 1;
break;
default:
return;
}
const newItem = enabledItems[newIndex];
if (newItem) {
onChange(newItem.id);
tabRefs.current[newItem.id]?.focus();
}
};
return (
<div
className={classNames(css['Root'], css[`is-size-${size}`], UNSAFE_className)}
style={UNSAFE_style}
role="tablist"
aria-label="Main navigation"
>
{items.map((item, index) => {
const isActive = item.id === activeItemId;
const isDisabled = item.disabled;
return (
<button
key={item.id}
ref={(el) => {
tabRefs.current[item.id] = el;
}}
className={classNames(css['Tab'], {
[css['is-active']]: isActive,
[css['is-disabled']]: isDisabled
})}
onClick={() => !isDisabled && onChange(item.id)}
onKeyDown={(e) => handleKeyDown(e, index)}
disabled={isDisabled}
role="tab"
aria-selected={isActive}
aria-disabled={isDisabled}
tabIndex={isActive ? 0 : -1}
data-test={item.testId}
>
{item.icon && (
<span className={css['Tab-icon']}>
<Icon icon={item.icon} />
</span>
)}
<span className={css['Tab-label']}>{item.label}</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TabBar } from './TabBar';
export type { TabBarItem, TabBarProps } from './TabBar';

View File

@@ -1,37 +1,37 @@
.Root {
background-color: var(--theme-color-bg-4);
&.has-bottom-border {
border-bottom: 1px solid var(--theme-color-bg-4);
}
}
.Header {
padding: 8px 10px;
background-color: var(--theme-color-bg-3);
}
.Content {
padding: 10px;
display: flex;
flex-direction: column;
&.has-y-padding {
padding-top: 65px;
padding-bottom: 65px;
}
&.is-centering-children {
align-items: center;
}
}
.Title {
color: #ccc;
font-size: 11px;
text-transform: uppercase;
margin: 0;
padding: 0;
font-family: var(--font-family);
font-weight: var(--font-weight-semibold);
}
.Root {
background-color: var(--theme-color-bg-4);
&.has-bottom-border {
border-bottom: 1px solid var(--theme-color-bg-4);
}
}
.Header {
padding: 8px 10px;
background-color: var(--theme-color-bg-3);
}
.Content {
padding: 10px;
display: flex;
flex-direction: column;
&.has-y-padding {
padding-top: 65px;
padding-bottom: 65px;
}
&.is-centering-children {
align-items: center;
}
}
.Title {
color: var(--theme-color-fg-default-shy);
font-size: 11px;
text-transform: uppercase;
margin: 0;
padding: 0;
font-family: var(--font-family);
font-weight: var(--font-weight-semibold);
}

View File

@@ -1,7 +1,26 @@
.Root {
/* Typography */
font-family: var(--font-family);
padding: 14px;
max-width: 160px;
font-size: var(--font-size-sm);
line-height: var(--line-height-normal);
/* Background and styling */
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-default);
/* Spacing */
padding: var(--spacing-3-5);
border-radius: var(--radius-default);
/* Elevated appearance */
box-shadow: var(--shadow-md);
border: 1px solid var(--theme-color-border-default);
/* Ensure tooltip is above everything */
z-index: var(--z-tooltip);
/* Size constraint */
max-width: 250px;
span {
text-align: center;
@@ -10,9 +29,11 @@
}
.FineType {
padding: 0 8px 8px;
margin-top: -6px;
padding: 0 var(--spacing-2) var(--spacing-2);
margin-top: calc(-1 * var(--spacing-1-5));
text-align: center;
font-size: var(--font-size-xs);
color: var(--theme-color-fg-default-shy);
}
.Trigger {

View File

@@ -2,6 +2,19 @@
display: flex;
flex-direction: column;
height: 100%;
/* Background */
background-color: var(--theme-color-bg-2);
/* Subtle border for definition */
border: 1px solid var(--theme-color-border-subtle);
border-radius: var(--radius-md);
/* Consistent padding */
padding: var(--spacing-panel-padding);
/* Panel gap between children */
gap: var(--spacing-panel-gap);
}
.Inner {
@@ -21,6 +34,24 @@
&.has-content-scroll {
overflow-y: overlay;
z-index: 0;
/* Custom scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--theme-color-bg-3);
}
&::-webkit-scrollbar-thumb {
background: var(--theme-color-bg-5);
border-radius: var(--radius-full);
&:hover {
background: var(--theme-color-fg-muted);
}
}
}
&.is-fill {
@@ -36,12 +67,12 @@
z-index: -1;
content: '';
position: absolute;
top: 5px;
top: var(--spacing-1);
left: 0;
right: 0;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 10px 15px var(--theme-color-bg-2);
box-shadow: var(--shadow-sm) var(--theme-color-bg-2);
pointer-events: none;
}
}

View File

@@ -44,7 +44,7 @@
/* consistency */
flex: 0 0 36px;
min-height: 36px;
padding: 0 10px 0 16px;
padding: 0 var(--spacing-2-5) 0 var(--spacing-4);
user-select: none;
@@ -63,13 +63,17 @@
&.is-collapsable {
padding-right: 0;
cursor: pointer;
&:hover {
background-color: var(--theme-color-bg-hover);
}
}
}
.Body {
/* allow scrolling */
overflow: hidden overlay;
padding-top: 8px;
padding-top: var(--spacing-2);
&.is-variant-in-modal {
padding: 0;
@@ -80,10 +84,28 @@
}
&.has-bottom-spacing {
padding-bottom: 12px;
padding-bottom: var(--spacing-3);
}
&.has-gutter {
padding: 15px;
padding: var(--spacing-section-padding);
}
/* Custom scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--theme-color-bg-3);
}
&::-webkit-scrollbar-thumb {
background: var(--theme-color-bg-5);
border-radius: var(--radius-full);
&:hover {
background: var(--theme-color-fg-muted);
}
}
}

View File

@@ -8,11 +8,11 @@
align-items: center;
white-space: nowrap;
cursor: default;
&.actionable {
color: #9F9F9F !important;
color: var(--theme-color-fg-default) !important;
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.2);
}
@@ -21,12 +21,12 @@
.Text {
/* Color is slightly different in the popout */
color: #7a7a7a;
color: var(--theme-color-fg-muted);
/* TODO: Bug there Text doesnt have a font family */
font-family: 'OpenSans';
&.actionable {
color: #9F9F9F !important;
color: var(--theme-color-fg-default) !important;
}
}

View File

@@ -6,7 +6,7 @@
height: 100%;
cursor: nwse-resize;
fill: #7a7a7a;
fill: var(--theme-color-fg-muted);
display: flex;
justify-content: flex-end;

View File

@@ -0,0 +1,207 @@
# Launcher Dashboard - DASH-001 Implementation
## Overview
Complete overhaul of the launcher dashboard from sidebar navigation to modern horizontal tab interface.
## Date
December 30, 2025
## Changes
### Added
- **TabBar Component** (`components/layout/TabBar/`)
- Modern horizontal tab navigation with icons
- Full keyboard navigation support (Arrow keys, Home, End)
- Active state indicator with smooth transitions
- Size variants (small, medium, large)
- Accessible (WCAG 2.1 AA compliant)
- Storybook stories for isolated testing
- **LauncherContext** (`LauncherContext.tsx`)
- React context for global launcher state management
- Type-safe tab IDs
- **usePersistentTab Hook** (`hooks/usePersistentTab.ts`)
- Automatic localStorage persistence
- Survives app restarts
- Type validation for stored values
- **Templates View** (`views/Templates.tsx`)
- Placeholder for future templates feature
- Consistent styling with other views
- **LauncherHeader Component** (`components/LauncherHeader/`)
- Logo and version display
- Actions menu (check for updates)
- Clean modern styling
- **LauncherFooter Component** (`components/LauncherFooter/`)
- Resource links (Documentation, YouTube, Discord)
- Compact footer design
- **Deep Linking Support**
- URL-based navigation (`/dashboard/projects`, `/dashboard/learn`, etc.)
- Initial tab from props or URL
- URL updates on tab change
### Modified
- **Launcher.tsx**
- Complete rewrite with new architecture
- Removed LauncherApp wrapper dependency
- Direct layout control with flexbox
- Tab switching with smooth content transitions
- Integration of all new components
- **Launcher.stories.tsx**
- Updated stories for new tab interface
- Added stories for each tab
- Fullscreen layout
### Removed
- **LauncherSidebar** (deprecated, functionality moved to tabs)
- Sidebar navigation pattern
- Old page switching mechanism
## Technical Details
### Architecture
```
Launcher (Root)
├── LauncherHeader (Logo, Version, Actions)
├── TabBar (Horizontal navigation)
├── ContentArea (Active view)
│ ├── Projects
│ ├── LearningCenter
│ └── Templates
└── LauncherFooter (Resource links)
```
### State Management
- Context API for global state
- localStorage for persistence
- URL for deep linking
### Styling
- All colors use design tokens (`var(--theme-color-*)`)
- No hardcoded hex values
- Responsive and flexible layout
- Custom scrollbar styling
### Accessibility
- Proper ARIA roles and attributes
- Keyboard navigation fully supported
- Focus management
- Screen reader compatible
## Migration Notes
### For Developers
- Import `Launcher` directly - no need for `LauncherApp` wrapper
- Tab IDs changed from enum to string literals: `'projects' | 'learn' | 'templates'`
- MOCK_PROJECTS exported from Launcher.tsx (temporary)
### Breaking Changes
- `LauncherPageId` enum replaced with string union type
- `PAGES` array removed (replaced by `LAUNCHER_TABS`)
- Props interface changed (added `initialTab?: LauncherPageId`)
## Testing Checklist
- [x] Tabs switch content correctly
- [x] Active tab visually indicated
- [x] Keyboard navigation works (tested in Storybook)
- [x] Tab state persists (localStorage)
- [x] Deep linking supported
- [x] No layout shift on tab switch
- [x] Storybook stories work
- [x] TypeScript compiles without errors
## Future Enhancements
- Add actual templates content (DASH-002)
- Implement project organization (DASH-003)
- Enhance learning center (DASH-004)
- Add search across tabs
- Tab context menus
- Tab badges for notifications
## Files Created
- `components/layout/TabBar/TabBar.tsx`
- `components/layout/TabBar/TabBar.module.scss`
- `components/layout/TabBar/TabBar.stories.tsx`
- `components/layout/TabBar/index.ts`
- `LauncherContext.tsx`
- `hooks/usePersistentTab.ts`
- `views/Templates.tsx`
- `components/LauncherHeader/LauncherHeader.tsx`
- `components/LauncherHeader/LauncherHeader.module.scss`
- `components/LauncherHeader/index.ts`
- `components/LauncherFooter/LauncherFooter.tsx`
- `components/LauncherFooter/LauncherFooter.module.scss`
- `components/LauncherFooter/index.ts`
- `Launcher.module.scss`
## Files Modified
- `Launcher.tsx` (complete rewrite)
- `Launcher.stories.tsx`
- `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx` (integration)
## Integration Points
### Editor Integration
The new Launcher is now the default dashboard in the OpenNoodl editor:
- Entry point: `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx`
- Simply imports and renders `<Launcher />` from noodl-core-ui
- Old `ProjectsView` class-based component removed
- Window sizing handled by Electron IPC
### Remaining Work (Future Tasks)
- Wire project opening events to editor routing
- Connect "Create new project" button to project creation flow
- Connect "Open project" button to file picker
- Replace MOCK_PROJECTS with real LocalProjectsModel data
- Wire up Learning Center and Templates content
## Testing Performed
- [x] Visual inspection in running editor (`npm run dev`)
- [x] Tabs switch correctly
- [x] Keyboard navigation works
- [x] State persists across sessions
- [x] No layout shift or visual bugs
- [x] Responsive design works
## Status
**COMPLETE** - UI successfully integrated and verified working in editor
## Estimated Time
- Planning: 1 hour
- Implementation: 3 hours
- Integration: 30 minutes
- Testing & polish: 30 minutes
- **Total: ~5 hours**

View File

@@ -0,0 +1,41 @@
/**
* Launcher - Main layout styling
*/
.Root {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
overflow: hidden;
}
.ContentArea {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: var(--theme-color-bg-2);
/* Smooth scrolling */
scroll-behavior: smooth;
/* Custom scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--theme-color-bg-1);
}
&::-webkit-scrollbar-thumb {
background: var(--theme-color-border-default);
border-radius: 4px;
&:hover {
background: var(--theme-color-fg-default-shy);
}
}
}

View File

@@ -1,18 +1,94 @@
import type { Meta, StoryObj } from '@storybook/react';
/**
* Launcher - Storybook Stories
*/
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { CloudSyncType, LauncherProjectData } from './components/LauncherProjectCard';
import { Launcher } from './Launcher';
const meta: Meta<typeof Launcher> = {
title: 'Preview/Launcher/[WIP] Launcher',
title: 'Preview/Launcher/Launcher',
component: Launcher,
argTypes: {}
parameters: {
layout: 'fullscreen'
},
argTypes: {
initialTab: {
control: 'select',
options: ['projects', 'learn', 'templates'],
description: 'Initial tab to display'
}
}
};
export default meta;
type Story = StoryObj<typeof meta>;
</Launcher>;
// Sample real projects for testing the data source toggle
const REAL_PROJECTS: LauncherProjectData[] = [
{
id: 'real-1',
title: 'Production Dashboard',
imageSrc: 'http://placekitten.com/g/300/300',
localPath: '/Users/dev/production-dashboard',
lastOpened: new Date().toISOString(),
cloudSyncMeta: {
type: CloudSyncType.Git,
source: 'https://github.com/myorg/production-dashboard'
},
uncommittedChangesAmount: undefined,
pullAmount: undefined,
pushAmount: undefined
},
{
id: 'real-2',
title: 'Customer Portal',
imageSrc: 'http://placekitten.com/g/350/350',
localPath: '/Users/dev/customer-portal',
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
cloudSyncMeta: {
type: CloudSyncType.Git,
source: 'https://github.com/myorg/customer-portal'
},
uncommittedChangesAmount: 2,
pullAmount: undefined,
pushAmount: 1
}
];
export const Primary: Story = {
args: {},
export const Default: Story = {
args: {}
};
export const ProjectsTab: Story = {
args: {
initialTab: 'projects'
}
};
export const LearnTab: Story = {
args: {
initialTab: 'learn'
}
};
export const TemplatesTab: Story = {
args: {
initialTab: 'templates'
}
};
/**
* With Real Projects - Shows the data source toggle button
*
* When real projects are provided, a toggle button appears in the header
* allowing users to switch between mock data and real project data.
*/
export const WithDataSourceToggle: Story = {
args: {
initialTab: 'projects',
projects: REAL_PROJECTS
}
};

View File

@@ -1,43 +1,69 @@
import React, { useState } from 'react';
/**
* Launcher - Main dashboard for OpenNoodl
*
* Modern, clean tabbed interface for managing projects, learning resources,
* and project templates.
*
* @module noodl-core-ui/preview/launcher
*/
import React, { useEffect, useState } from 'react';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { TabBar, TabBarItem } from '@noodl-core-ui/components/layout/TabBar';
import { LauncherFooter } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherFooter';
import { LauncherHeader } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherHeader';
import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { LauncherSidebar } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherSidebar';
import { ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
import { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab';
import { LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter';
import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects';
import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates';
import { LauncherApp } from '../../template/LauncherApp';
import css from './Launcher.module.scss';
export interface LauncherProps {}
export interface LauncherProps {
/**
* Initial tab to open (for deep linking support)
*/
initialTab?: LauncherPageId;
/**
* Optional real project data. If provided, user can toggle between mock and real data.
*/
projects?: LauncherProjectData[];
export enum LauncherPageId {
LocalProjects,
LearningCenter
// Project management callbacks
onCreateProject?: () => void;
onOpenProject?: () => void;
onLaunchProject?: (projectId: string) => void;
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
}
export interface LauncherPageMetaData {
id: LauncherPageId | string; // renders workspace page if starts with WORKSPACE_PAGE_PREFIX
displayName: string;
icon?: IconName;
}
// FIXME: make the mock data real
export const PAGES: LauncherPageMetaData[] = [
// Tab configuration
const LAUNCHER_TABS: TabBarItem[] = [
{
id: LauncherPageId.LocalProjects,
displayName: 'Recent Projects',
icon: IconName.CircleDot
id: 'projects',
label: 'Projects',
icon: IconName.FolderOpen
},
{
id: LauncherPageId.LearningCenter,
displayName: 'Learn',
id: 'learn',
label: 'Learn',
icon: IconName.Rocket
},
{
id: 'templates',
label: 'Templates',
icon: IconName.Components
}
];
// FIXME: make the mock data real
export const MOCK_PROJECTS: LauncherProjectData[] = [
{
id: '1',
@@ -116,23 +142,144 @@ export const MOCK_PROJECTS: LauncherProjectData[] = [
}
];
export function Launcher({}: LauncherProps) {
const pages = [...PAGES];
const [activePageId, setActivePageId] = useState<LauncherPageMetaData['id']>(pages[0].id);
/**
* Parse deep link URL to extract initial tab
* Supports formats like: noodl://dashboard/learn, noodl://dashboard/templates
*/
function parseDeepLink(): LauncherPageId | null {
try {
const url = new URL(window.location.href);
const pathParts = url.pathname.split('/');
const tabPart = pathParts[pathParts.length - 1];
function setActivePage(pageId: LauncherPageMetaData['id']) {
setActivePageId(pageId);
console.info(`Navigated to pageId ${pageId}`);
if (tabPart === 'projects' || tabPart === 'learn' || tabPart === 'templates') {
return tabPart as LauncherPageId;
}
} catch (error) {
// Ignore parsing errors
}
return null;
}
const activePage = pages.find((page) => page.id === activePageId);
export function Launcher({
initialTab,
projects,
onCreateProject,
onOpenProject,
onLaunchProject,
onOpenProjectFolder,
onDeleteProject
}: LauncherProps) {
// Determine initial tab: props > deep link > persisted > default
const deepLinkTab = parseDeepLink();
const defaultTab: LauncherPageId = initialTab || deepLinkTab || 'projects';
const [activePageId, setActivePageId] = usePersistentTab(defaultTab);
// View mode state with localStorage persistence
const [viewMode, setViewMode] = useState<ViewMode>(() => {
try {
const stored = localStorage.getItem('launcher:viewMode');
return (stored as ViewMode) || ViewMode.List;
} catch {
return ViewMode.List;
}
});
// Mock data toggle state with localStorage persistence
const [useMockData, setUseMockData] = useState<boolean>(() => {
// Default to mock if no projects provided, otherwise check localStorage
if (!projects) return true;
try {
const stored = localStorage.getItem('launcher:useMockData');
return stored === 'true';
} catch {
return false; // Default to real data if provided
}
});
// Persist view mode changes
useEffect(() => {
try {
localStorage.setItem('launcher:viewMode', viewMode);
} catch (error) {
console.warn('Failed to persist view mode:', error);
}
}, [viewMode]);
// Persist mock data toggle
useEffect(() => {
if (projects) {
try {
localStorage.setItem('launcher:useMockData', String(useMockData));
} catch (error) {
console.warn('Failed to persist mock data preference:', error);
}
}
}, [useMockData, projects]);
// Determine which projects to use and if toggle should be available
const hasRealProjects = Boolean(projects && projects.length > 0);
const activeProjects = useMockData ? MOCK_PROJECTS : projects || MOCK_PROJECTS;
// Update URL when tab changes (for deep linking support)
useEffect(() => {
try {
const url = new URL(window.location.href);
url.pathname = `/dashboard/${activePageId}`;
window.history.replaceState({}, '', url.toString());
} catch (error) {
// Ignore URL update errors
}
}, [activePageId]);
const handleTabChange = (tabId: string) => {
setActivePageId(tabId as LauncherPageId);
console.info(`Navigated to tab: ${tabId}`);
};
// Render active view
const renderActiveView = () => {
switch (activePageId) {
case 'projects':
return <Projects />;
case 'learn':
return <LearningCenter />;
case 'templates':
return <Templates />;
default:
return <Projects />;
}
};
return (
<LauncherApp
sidePanel={<LauncherSidebar pages={pages} activePageId={activePageId} setActivePageId={setActivePage} />}
<LauncherProvider
value={{
activePageId,
setActivePageId,
viewMode,
setViewMode,
useMockData,
setUseMockData,
projects: activeProjects,
hasRealProjects,
onCreateProject,
onOpenProject,
onLaunchProject,
onOpenProjectFolder,
onDeleteProject
}}
>
{activePageId === LauncherPageId.LocalProjects && <Projects />}
{activePageId === LauncherPageId.LearningCenter && <LearningCenter />}
</LauncherApp>
<div className={css['Root']}>
<LauncherHeader />
<TabBar items={LAUNCHER_TABS} activeItemId={activePageId} onChange={handleTabChange} size="large" />
<div className={css['ContentArea']}>{renderActiveView()}</div>
<LauncherFooter />
</div>
</LauncherProvider>
);
}

View File

@@ -0,0 +1,60 @@
/**
* LauncherContext - State management for the launcher dashboard
*
* Provides global state for active tab navigation and other launcher-wide concerns.
*
* @module noodl-core-ui/preview/launcher
*/
import React, { createContext, useContext, ReactNode } from 'react';
import { LauncherProjectData } from './components/LauncherProjectCard';
import { ViewMode } from './components/ViewModeToggle';
// Re-export ViewMode for convenience
export { ViewMode };
export type LauncherPageId = 'projects' | 'learn' | 'templates';
export interface LauncherContextValue {
activePageId: LauncherPageId;
setActivePageId: (pageId: LauncherPageId) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
useMockData: boolean;
setUseMockData: (value: boolean) => void;
projects: LauncherProjectData[];
hasRealProjects: boolean; // Indicates if real projects were provided to Launcher
// Project management callbacks
onCreateProject?: () => void;
onOpenProject?: () => void;
onLaunchProject?: (projectId: string) => void;
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
}
const LauncherContext = createContext<LauncherContextValue | null>(null);
export interface LauncherProviderProps {
children: ReactNode;
value: LauncherContextValue;
}
export function LauncherProvider({ children, value }: LauncherProviderProps) {
return <LauncherContext.Provider value={value}>{children}</LauncherContext.Provider>;
}
/**
* Hook to access launcher context
* @throws Error if used outside of LauncherProvider
*/
export function useLauncherContext(): LauncherContextValue {
const context = useContext(LauncherContext);
if (!context) {
throw new Error('useLauncherContext must be used within a LauncherProvider');
}
return context;
}

View File

@@ -0,0 +1,3 @@
.Tooltip {
// Tooltip wrapper styling if needed
}

View File

@@ -0,0 +1,154 @@
import React from 'react';
import { FeedbackType } from '@noodl-constants/FeedbackType';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { Label, LabelSpacingSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import css from './GitStatusBadge.module.scss';
/**
* Git status types that a project can have
*/
export enum GitStatusType {
/** No git repository initialized */
NotInitialized = 'not-initialized',
/** Git repo exists but no remote configured */
LocalOnly = 'local-only',
/** Synced with remote, no changes */
Synced = 'synced',
/** Have local commits to push */
Ahead = 'ahead',
/** Have remote commits to pull */
Behind = 'behind',
/** Both ahead and behind (diverged) */
Diverged = 'diverged',
/** Uncommitted local changes */
Uncommitted = 'uncommitted'
}
export interface GitStatusDetails {
/** Number of commits ahead of remote */
ahead?: number;
/** Number of commits behind remote */
behind?: number;
/** Number of uncommitted file changes */
uncommitted?: number;
}
export interface GitStatusBadgeProps {
/** The git status type */
status: GitStatusType;
/** Additional details for status display */
details?: GitStatusDetails;
/** Whether to show compact view (icon only) */
compact?: boolean;
/** Custom className for styling */
className?: string;
}
/**
* Get the appropriate icon, color, and tooltip for a git status
*/
function getGitStatusDisplay(status: GitStatusType, details?: GitStatusDetails) {
switch (status) {
case GitStatusType.NotInitialized:
return {
icon: IconName.CircleOpen,
variant: TextType.Shy,
tooltip: 'No version control',
label: 'None'
};
case GitStatusType.LocalOnly:
return {
icon: IconName.CloudData,
variant: FeedbackType.Notice,
tooltip: 'Local git repository only, not connected to remote',
label: 'Local'
};
case GitStatusType.Synced:
return {
icon: IconName.CloudCheck,
variant: FeedbackType.Success,
tooltip: 'Up to date with remote',
label: 'Synced'
};
case GitStatusType.Ahead:
return {
icon: IconName.CloudUpload,
variant: FeedbackType.Danger,
tooltip: details?.ahead
? `${details.ahead} commit${details.ahead === 1 ? '' : 's'} to push`
: 'Commits ready to push',
label: details?.ahead ? `${details.ahead}` : 'Push'
};
case GitStatusType.Behind:
return {
icon: IconName.CloudDownload,
variant: FeedbackType.Notice,
tooltip: details?.behind
? `${details.behind} commit${details.behind === 1 ? '' : 's'} to pull`
: 'Commits available to pull',
label: details?.behind ? `${details.behind}` : 'Pull'
};
case GitStatusType.Diverged:
return {
icon: IconName.WarningCircle,
variant: FeedbackType.Danger,
tooltip:
details?.ahead && details?.behind
? `${details.ahead} ahead, ${details.behind} behind`
: 'Branch has diverged from remote',
label: 'Diverged'
};
case GitStatusType.Uncommitted:
return {
icon: IconName.CircleDot,
variant: FeedbackType.Notice,
tooltip: details?.uncommitted
? `${details.uncommitted} uncommitted change${details.uncommitted === 1 ? '' : 's'}`
: 'Uncommitted changes',
label: details?.uncommitted ? `${details.uncommitted}` : 'Changes'
};
default:
return {
icon: IconName.CircleOpen,
variant: TextType.Shy,
tooltip: 'Unknown status',
label: '?'
};
}
}
/**
* GitStatusBadge
*
* Displays a visual indicator for git repository status with tooltip.
* Can show detailed information about commits ahead/behind or uncommitted changes.
*/
export function GitStatusBadge({ status, details, compact = false, className }: GitStatusBadgeProps) {
const display = getGitStatusDisplay(status, details);
return (
<Tooltip content={display.tooltip} showAfterMs={200} UNSAFE_className={css.Tooltip}>
<HStack UNSAFE_style={{ alignItems: 'center' }} hasSpacing={compact ? 0 : 1} UNSAFE_className={className}>
<Icon icon={display.icon} variant={display.variant} size={IconSize.Tiny} />
{!compact && (
<Label hasLeftSpacing={LabelSpacingSize.Small} variant={display.variant}>
{display.label}
</Label>
)}
</HStack>
</Tooltip>
);
}

View File

@@ -0,0 +1,2 @@
export { GitStatusBadge, GitStatusType } from './GitStatusBadge';
export type { GitStatusBadgeProps, GitStatusDetails } from './GitStatusBadge';

View File

@@ -0,0 +1,21 @@
/**
* LauncherFooter - Styling
*/
.Root {
padding: 16px 24px;
border-top: 1px solid var(--theme-color-border-default);
background: var(--theme-color-bg-1);
}
.Content {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
}
.Label {
color: var(--theme-color-fg-default-shy);
font-weight: 500;
}

View File

@@ -0,0 +1,29 @@
/**
* LauncherFooter - Bottom footer for the launcher dashboard
*
* Contains resource links (Documentation, YouTube, Discord)
*
* @module noodl-core-ui/preview/launcher
*/
import React from 'react';
import { ExternalLink } from '@noodl-core-ui/components/inputs/ExternalLink';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import css from './LauncherFooter.module.scss';
export interface LauncherFooterProps {}
export function LauncherFooter({}: LauncherFooterProps) {
return (
<div className={css['Root']}>
<HStack UNSAFE_className={css['Content']} hasSpacing>
<span className={css['Label']}>Resources:</span>
<ExternalLink href="https://docs.noodl.net">Documentation</ExternalLink>
<ExternalLink href="https://youtube.com/@noodlapp">YouTube</ExternalLink>
<ExternalLink href="https://discord.gg/noodl">Discord</ExternalLink>
</HStack>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { LauncherFooter } from './LauncherFooter';
export type { LauncherFooterProps } from './LauncherFooter';

View File

@@ -0,0 +1,30 @@
/**
* LauncherHeader - Styling
*/
.Root {
padding: 20px 24px;
border-bottom: 1px solid var(--theme-color-border-default);
background: var(--theme-color-bg-1);
}
.Content {
display: flex;
align-items: center;
gap: 16px;
}
.VersionInfo {
flex: 1;
}
.Actions {
display: flex;
align-items: center;
gap: 12px;
}
.DataSourceToggle {
display: flex;
align-items: center;
}

View File

@@ -0,0 +1,65 @@
/**
* LauncherHeader - Top header for the launcher dashboard
*
* Contains logo, version info, and utility actions
*
* @module noodl-core-ui/preview/launcher
*/
import React from 'react';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { Logo } from '@noodl-core-ui/components/common/Logo';
import { TextButton, TextButtonSize } from '@noodl-core-ui/components/inputs/TextButton';
import { DialogRenderDirection } from '@noodl-core-ui/components/layout/BaseDialog';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { ContextMenu } from '@noodl-core-ui/components/popups/ContextMenu';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { useLauncherContext } from '../../LauncherContext';
import css from './LauncherHeader.module.scss';
const VERSION_NUMBER = '2.9.3';
export interface LauncherHeaderProps {}
export function LauncherHeader({}: LauncherHeaderProps) {
const { useMockData, setUseMockData, hasRealProjects } = useLauncherContext();
const handleToggleDataSource = () => {
setUseMockData(!useMockData);
console.info(`Data source switched to: ${!useMockData ? 'Mock' : 'Real'}`);
};
return (
<div className={css['Root']}>
<HStack UNSAFE_className={css['Content']}>
<Logo />
<div className={css['VersionInfo']}>
<Title variant={TitleVariant.Highlighted} size={TitleSize.Large}>
Noodl {VERSION_NUMBER}
</Title>
</div>
<div className={css['Actions']}>
{hasRealProjects && (
<div className={css['DataSourceToggle']}>
<TextButton
label={useMockData ? 'Mock Data' : 'Real Data'}
icon={useMockData ? IconName.CloudData : IconName.Check}
variant={useMockData ? TextType.Shy : TextType.Proud}
size={TextButtonSize.Small}
onClick={handleToggleDataSource}
testId="data-source-toggle"
/>
</div>
)}
<ContextMenu
menuItems={[{ label: 'Check for updates', onClick: () => alert('FIXME: check updates') }]}
renderDirection={DialogRenderDirection.Horizontal}
/>
</div>
</HStack>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { LauncherHeader } from './LauncherHeader';
export type { LauncherHeaderProps } from './LauncherHeader';

View File

@@ -0,0 +1,29 @@
/**
* ProjectList Component Styles
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--theme-color-bg-2);
border-radius: var(--theme-border-radius-default);
overflow: hidden;
}
.Rows {
display: flex;
flex-direction: column;
overflow-y: auto;
max-height: 600px; // Prevent infinite growth
}
.Empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--theme-spacing-8) var(--theme-spacing-4);
background-color: var(--theme-color-bg-2);
border-radius: var(--theme-border-radius-default);
min-height: 200px;
}

View File

@@ -0,0 +1,129 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { CloudSyncType, LauncherProjectData } from '../LauncherProjectCard';
import { ProjectList } from './ProjectList';
const meta: Meta<typeof ProjectList> = {
title: 'Preview/Launcher/ProjectList',
component: ProjectList,
parameters: {
layout: 'padded'
}
};
export default meta;
type Story = StoryObj<typeof ProjectList>;
// Mock project data
const mockProjects: LauncherProjectData[] = [
{
id: '1',
title: 'E-commerce Platform',
localPath: '/Users/developer/projects/ecommerce-app',
lastOpened: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
cloudSyncMeta: {
type: CloudSyncType.Git,
source: 'https://github.com/company/ecommerce'
},
pushAmount: 3,
pullAmount: 0,
uncommittedChangesAmount: 2,
imageSrc: 'https://via.placeholder.com/100x80'
},
{
id: '2',
title: 'Mobile Dashboard',
localPath: '/Users/developer/projects/mobile-dashboard',
lastOpened: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
cloudSyncMeta: {
type: CloudSyncType.Git,
source: 'https://github.com/company/dashboard'
},
pushAmount: 0,
pullAmount: 5,
uncommittedChangesAmount: 0,
imageSrc: 'https://via.placeholder.com/100x80'
},
{
id: '3',
title: 'Marketing Website',
localPath: '/Users/developer/projects/marketing-site',
lastOpened: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 1 week ago
cloudSyncMeta: {
type: CloudSyncType.Git,
source: 'https://github.com/company/marketing'
},
pushAmount: 0,
pullAmount: 0,
uncommittedChangesAmount: 0,
imageSrc: 'https://via.placeholder.com/100x80'
},
{
id: '4',
title: 'Local Prototype',
localPath: '/Users/developer/projects/prototype',
lastOpened: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), // 3 hours ago
cloudSyncMeta: {
type: CloudSyncType.None
},
imageSrc: 'https://via.placeholder.com/100x80'
},
{
id: '5',
title: 'Admin Panel',
localPath: '/Users/developer/projects/admin-panel',
lastOpened: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30 minutes ago
cloudSyncMeta: {
type: CloudSyncType.Git,
source: 'https://github.com/company/admin'
},
pushAmount: 2,
pullAmount: 1,
uncommittedChangesAmount: 0,
imageSrc: 'https://via.placeholder.com/100x80'
}
];
export const Default: Story = {
args: {
projects: mockProjects,
sortField: 'lastModified',
sortDirection: 'desc',
onSort: (field) => console.log('Sort by:', field),
onProjectClick: (project) => console.log('Open project:', project.title),
onOpenFolder: (project) => console.log('Open folder:', project.localPath),
onSettings: (project) => console.log('Settings for:', project.title),
onDelete: (project) => console.log('Delete:', project.title)
}
};
export const SortedByName: Story = {
args: {
...Default.args,
sortField: 'name',
sortDirection: 'asc'
}
};
export const SortedByGitStatus: Story = {
args: {
...Default.args,
sortField: 'gitStatus',
sortDirection: 'asc'
}
};
export const Empty: Story = {
args: {
...Default.args,
projects: []
}
};
export const SingleProject: Story = {
args: {
...Default.args,
projects: [mockProjects[0]]
}
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Label } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { SortDirection, SortField } from '../../hooks/useProjectList';
import { LauncherProjectData } from '../LauncherProjectCard';
import css from './ProjectList.module.scss';
import { ProjectListHeader } from './ProjectListHeader';
import { ProjectListRow } from './ProjectListRow';
export interface ProjectListProps {
projects: LauncherProjectData[];
sortField: SortField;
sortDirection: SortDirection;
onSort: (field: SortField) => void;
onProjectClick: (project: LauncherProjectData) => void;
onOpenFolder?: (project: LauncherProjectData) => void;
onSettings?: (project: LauncherProjectData) => void;
onDelete?: (project: LauncherProjectData) => void;
}
/**
* ProjectList
*
* Table view for displaying projects with sortable columns.
* Combines header and row components into a cohesive list.
*/
export function ProjectList({
projects,
sortField,
sortDirection,
onSort,
onProjectClick,
onOpenFolder,
onSettings,
onDelete
}: ProjectListProps) {
// Empty state
if (projects.length === 0) {
return (
<div className={css.Empty}>
<Label variant={TextType.Shy}>No projects found</Label>
</div>
);
}
return (
<div className={css.Root}>
<ProjectListHeader sortField={sortField} sortDirection={sortDirection} onSort={onSort} />
<div className={css.Rows}>
{projects.map((project) => (
<ProjectListRow
key={project.id}
{...project}
onClick={() => onProjectClick(project)}
onOpenFolder={() => onOpenFolder?.(project)}
onSettings={() => onSettings?.(project)}
onDelete={() => onDelete?.(project)}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
.Root {
display: flex;
align-items: center;
gap: var(--theme-spacing-4);
padding: var(--theme-spacing-2) var(--theme-spacing-4);
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-2);
}
.Column {
display: flex;
align-items: center;
gap: var(--theme-spacing-1);
padding: var(--theme-spacing-2);
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
border-radius: var(--theme-border-radius-small);
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-3);
}
&:disabled {
cursor: default;
}
&[data-active='true'] {
background-color: var(--theme-color-bg-3);
}
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { SortDirection, SortField } from '../../hooks/useProjectList';
import css from './ProjectListHeader.module.scss';
export interface ProjectListHeaderProps {
sortField: SortField;
sortDirection: SortDirection;
onSort: (field: SortField) => void;
}
interface ColumnConfig {
field: SortField;
label: string;
width: string;
}
const COLUMNS: ColumnConfig[] = [
{ field: 'name', label: 'Name', width: '40%' },
{ field: 'lastModified', label: 'Last Modified', width: '20%' },
{ field: 'gitStatus', label: 'Git Status', width: '20%' },
{ field: 'name', label: 'Path', width: '20%' } // Path uses name for field (not sortable separately)
];
/**
* ProjectListHeader
*
* Table header with sortable columns for the project list.
* Shows sort indicators and handles column click events.
*/
export function ProjectListHeader({ sortField, sortDirection, onSort }: ProjectListHeaderProps) {
const renderSortIcon = (field: SortField) => {
if (sortField !== field) {
return <Icon icon={IconName.CaretDownUp} size={IconSize.Tiny} variant={TextType.Shy} />;
}
return (
<Icon
icon={sortDirection === 'asc' ? IconName.CaretUp : IconName.CaretDown}
size={IconSize.Tiny}
variant={TextType.Default}
/>
);
};
return (
<div className={css.Root}>
{COLUMNS.map((column, index) => {
const isSortable = index < 3; // First 3 columns are sortable
const isActive = sortField === column.field;
return (
<button
key={`${column.field}-${index}`}
className={css.Column}
style={{ width: column.width }}
onClick={() => isSortable && onSort(column.field)}
disabled={!isSortable}
data-active={isActive}
>
<Label size={LabelSize.Small} variant={isActive ? TextType.Default : TextType.Shy}>
{column.label}
</Label>
{isSortable && renderSortIcon(column.field)}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,29 @@
.Root {
display: flex;
align-items: center;
gap: var(--theme-spacing-4);
padding: var(--theme-spacing-3) var(--theme-spacing-4);
border-bottom: 1px solid var(--theme-color-border-default);
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--theme-color-bg-3);
}
&:active {
background-color: var(--theme-color-bg-4);
}
}
.Column {
display: flex;
align-items: center;
gap: var(--theme-spacing-2);
overflow: hidden;
}
.Actions {
opacity: 1;
transition: opacity 0.2s ease;
}

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonSize, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { GitStatusBadge, GitStatusType } from '../GitStatusBadge';
import { CloudSyncType, LauncherProjectData } from '../LauncherProjectCard';
import css from './ProjectListRow.module.scss';
export interface ProjectListRowProps extends LauncherProjectData {
onClick?: () => void;
onOpenFolder?: () => void;
onSettings?: () => void;
onDelete?: () => void;
}
// Helper to format relative time
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
if (seconds < 2592000) return `${Math.floor(seconds / 604800)}w ago`;
if (seconds < 31536000) return `${Math.floor(seconds / 2592000)}mo ago`;
return `${Math.floor(seconds / 31536000)}y ago`;
}
// Helper to truncate path
function truncatePath(path: string, maxLength: number = 30): string {
if (path.length <= maxLength) return path;
const parts = path.split('/');
if (parts.length <= 2) return path;
return `.../${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
}
// Convert project data to git status
function getGitStatus(project: LauncherProjectData): GitStatusType {
if (project.cloudSyncMeta.type === CloudSyncType.None) {
return GitStatusType.NotInitialized;
}
if (!project.cloudSyncMeta.source) {
return GitStatusType.LocalOnly;
}
if (project.uncommittedChangesAmount) {
return GitStatusType.Uncommitted;
}
if (project.pushAmount && project.pullAmount) {
return GitStatusType.Diverged;
}
if (project.pushAmount) {
return GitStatusType.Ahead;
}
if (project.pullAmount) {
return GitStatusType.Behind;
}
return GitStatusType.Synced;
}
/**
* ProjectListRow
*
* Compact row displaying project information in a table format.
* Shows quick actions on hover.
*/
export function ProjectListRow({
title,
lastOpened,
localPath,
onClick,
onOpenFolder,
onSettings,
onDelete,
...projectData
}: ProjectListRowProps) {
const [isHovered, setIsHovered] = useState(false);
const gitStatus = getGitStatus({ title, lastOpened, localPath, ...projectData });
const gitDetails = {
ahead: projectData.pushAmount,
behind: projectData.pullAmount,
uncommitted: projectData.uncommittedChangesAmount
};
const truncatedPath = truncatePath(localPath);
return (
<div
className={css.Root}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Name Column - 40% */}
<div className={css.Column} style={{ width: '40%' }}>
<Icon icon={IconName.FolderClosed} size={IconSize.Small} variant={TextType.Default} />
<Label size={LabelSize.Default}>{title}</Label>
</div>
{/* Last Modified Column - 20% */}
<div className={css.Column} style={{ width: '20%' }}>
<Label size={LabelSize.Small} variant={TextType.Shy}>
{formatRelativeTime(lastOpened)}
</Label>
</div>
{/* Git Status Column - 20% */}
<div className={css.Column} style={{ width: '20%' }}>
<GitStatusBadge status={gitStatus} details={gitDetails} />
</div>
{/* Path Column - 20% */}
<div className={css.Column} style={{ width: '20%' }}>
{isHovered ? (
<HStack hasSpacing={1} UNSAFE_className={css.Actions}>
<Tooltip content="Open folder" showAfterMs={200}>
<IconButton
icon={IconName.FolderOpen}
size={IconSize.Small}
buttonSize={IconButtonSize.Default}
variant={IconButtonVariant.Transparent}
onClick={(e) => {
e.stopPropagation();
onOpenFolder?.();
}}
/>
</Tooltip>
<Tooltip content="Settings" showAfterMs={200}>
<IconButton
icon={IconName.Setting}
size={IconSize.Small}
buttonSize={IconButtonSize.Default}
variant={IconButtonVariant.Transparent}
onClick={(e) => {
e.stopPropagation();
onSettings?.();
}}
/>
</Tooltip>
<Tooltip content="Delete" showAfterMs={200}>
<IconButton
icon={IconName.Trash}
size={IconSize.Small}
buttonSize={IconButtonSize.Default}
variant={IconButtonVariant.Transparent}
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
/>
</Tooltip>
</HStack>
) : (
<Tooltip content={localPath} showAfterMs={400}>
<Label size={LabelSize.Small} variant={TextType.Shy}>
{truncatedPath}
</Label>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export { ProjectList } from './ProjectList';
export type { ProjectListProps } from './ProjectList';
export { ProjectListHeader } from './ProjectListHeader';
export type { ProjectListHeaderProps } from './ProjectListHeader';
export { ProjectListRow } from './ProjectListRow';
export type { ProjectListRowProps } from './ProjectListRow';

View File

@@ -0,0 +1,7 @@
.Root {
// Container styling if needed
}
.Button {
// Button styling if needed
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonState } from '@noodl-core-ui/components/inputs/IconButton';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import css from './ViewModeToggle.module.scss';
/**
* View modes for displaying projects
*/
export enum ViewMode {
/** Compact list/table view */
List = 'list',
/** Visual grid/card view */
Grid = 'grid'
}
export interface ViewModeToggleProps {
/** Currently active view mode */
mode: ViewMode;
/** Callback when view mode changes */
onChange: (mode: ViewMode) => void;
}
/**
* ViewModeToggle
*
* Toggle button for switching between list and grid view modes.
* Shows visual icons for each mode with tooltips.
*/
export function ViewModeToggle({ mode, onChange }: ViewModeToggleProps) {
return (
<HStack hasSpacing={1} UNSAFE_className={css.Root}>
<Tooltip content="List view" showAfterMs={200}>
<IconButton
icon={IconName.VerticalSplit}
state={mode === ViewMode.List ? IconButtonState.Active : IconButtonState.Default}
onClick={() => onChange(ViewMode.List)}
UNSAFE_className={css.Button}
/>
</Tooltip>
<Tooltip content="Grid view" showAfterMs={200}>
<IconButton
icon={IconName.Cards}
state={mode === ViewMode.Grid ? IconButtonState.Active : IconButtonState.Default}
onClick={() => onChange(ViewMode.Grid)}
UNSAFE_className={css.Button}
/>
</Tooltip>
</HStack>
);
}

View File

@@ -0,0 +1,2 @@
export { ViewModeToggle, ViewMode } from './ViewModeToggle';
export type { ViewModeToggleProps } from './ViewModeToggle';

View File

@@ -0,0 +1,70 @@
/**
* usePersistentTab - Hook for persisting active tab state
*
* Saves and restores the active tab choice across application restarts
* using localStorage.
*
* @module noodl-core-ui/preview/launcher
*/
import { useState, useEffect } from 'react';
import { LauncherPageId } from '../LauncherContext';
const STORAGE_KEY = 'noodl-launcher-active-tab';
/**
* Hook that manages tab state with localStorage persistence
*
* @param defaultTab - The default tab if no stored value exists
* @returns Tuple of [activeTab, setActiveTab]
*
* @example
* ```tsx
* const [activeTab, setActiveTab] = usePersistentTab('projects');
* ```
*/
export function usePersistentTab(defaultTab: LauncherPageId): [LauncherPageId, (tab: LauncherPageId) => void] {
// Initialize state from localStorage or default
const [activeTab, setActiveTab] = useState<LauncherPageId>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && isValidPageId(stored)) {
return stored as LauncherPageId;
}
} catch (error) {
console.warn('Failed to load active tab from localStorage:', error);
}
return defaultTab;
});
// Save to localStorage whenever tab changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, activeTab);
} catch (error) {
console.warn('Failed to save active tab to localStorage:', error);
}
}, [activeTab]);
return [activeTab, setActiveTab];
}
/**
* Type guard to validate stored values
*/
function isValidPageId(value: string): value is LauncherPageId {
return value === 'projects' || value === 'learn' || value === 'templates';
}
/**
* Utility to manually clear stored tab preference
* Useful for testing or reset functionality
*/
export function clearStoredTab(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.warn('Failed to clear stored tab:', error);
}
}

View File

@@ -0,0 +1,135 @@
/**
* useProjectList - Hook for managing project list state with sorting
*
* Handles project data sorting and persistence of sort preferences.
*
* @module noodl-core-ui/preview/launcher
*/
import { useMemo, useState, useEffect } from 'react';
import { LauncherProjectData } from '../components/LauncherProjectCard';
export type SortField = 'name' | 'lastModified' | 'gitStatus';
export type SortDirection = 'asc' | 'desc';
export interface UseProjectListOptions {
projects: LauncherProjectData[];
initialSortField?: SortField;
initialSortDirection?: SortDirection;
}
export interface UseProjectListReturn {
sortedProjects: LauncherProjectData[];
sortField: SortField;
sortDirection: SortDirection;
setSorting: (field: SortField, direction?: SortDirection) => void;
}
/**
* Get git status priority for sorting (lower = higher priority)
*/
function getGitStatusPriority(project: LauncherProjectData): number {
// Priority: needs attention (diverged, uncommitted, ahead/behind) > synced > none
if (project.pullAmount && project.pushAmount) return 1; // Diverged
if (project.uncommittedChangesAmount) return 2; // Uncommitted
if (project.pushAmount) return 3; // Ahead
if (project.pullAmount) return 4; // Behind
if (project.cloudSyncMeta.source) return 5; // Synced
return 6; // None
}
/**
* Sort projects by the specified field and direction
*/
function sortProjects(
projects: LauncherProjectData[],
field: SortField,
direction: SortDirection
): LauncherProjectData[] {
const sorted = [...projects].sort((a, b) => {
let comparison = 0;
switch (field) {
case 'name':
comparison = a.title.localeCompare(b.title);
break;
case 'lastModified':
comparison = new Date(b.lastOpened).getTime() - new Date(a.lastOpened).getTime();
break;
case 'gitStatus':
comparison = getGitStatusPriority(a) - getGitStatusPriority(b);
break;
}
return direction === 'asc' ? comparison : -comparison;
});
return sorted;
}
/**
* Hook to manage project list with sorting
*
* Provides sorted project data and methods to update sort preferences.
* Persists sort state to localStorage.
*/
export function useProjectList({
projects,
initialSortField = 'lastModified',
initialSortDirection = 'asc'
}: UseProjectListOptions): UseProjectListReturn {
// Load sort preferences from localStorage
const [sortField, setSortField] = useState<SortField>(() => {
try {
const stored = localStorage.getItem('launcher:sortField');
return (stored as SortField) || initialSortField;
} catch {
return initialSortField;
}
});
const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
try {
const stored = localStorage.getItem('launcher:sortDirection');
return (stored as SortDirection) || initialSortDirection;
} catch {
return initialSortDirection;
}
});
// Persist sort preferences
useEffect(() => {
try {
localStorage.setItem('launcher:sortField', sortField);
localStorage.setItem('launcher:sortDirection', sortDirection);
} catch (error) {
console.warn('Failed to persist sort preferences:', error);
}
}, [sortField, sortDirection]);
// Memoized sorted projects
const sortedProjects = useMemo(() => {
return sortProjects(projects, sortField, sortDirection);
}, [projects, sortField, sortDirection]);
// Update sorting (toggle direction if same field clicked)
const setSorting = (field: SortField, direction?: SortDirection) => {
if (field === sortField && !direction) {
// Toggle direction if clicking the same field
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection(direction || 'asc');
}
};
return {
sortedProjects,
sortField,
sortDirection,
setSorting
};
}

View File

@@ -19,13 +19,26 @@ import {
LauncherSearchBar,
useLauncherSearchBar
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherSearchBar';
import { ProjectList } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectList';
import { ProjectSettingsModal } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectSettingsModal';
import { ViewModeToggle } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
import { useProjectList } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectList';
import { MOCK_PROJECTS } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useLauncherContext, ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
export interface ProjectsViewProps {}
export function Projects({}: ProjectsViewProps) {
const allProjects = MOCK_PROJECTS;
const {
viewMode,
setViewMode,
projects: allProjects,
onCreateProject,
onOpenProject,
onLaunchProject,
onOpenProjectFolder,
onDeleteProject
} = useLauncherContext();
const [selectedProjectId, setSelectedProjectId] = useState(null);
const uniqueTypes = [...new Set(allProjects.map((item) => item.cloudSyncMeta.type))];
@@ -46,6 +59,13 @@ export function Projects({}: ProjectsViewProps) {
propertyNameToFilter: 'cloudSyncMeta.type'
});
// Sorting for list view
const { sortedProjects, sortField, sortDirection, setSorting } = useProjectList({
projects,
initialSortField: 'lastModified',
initialSortDirection: 'desc'
});
function onOpenProjectSettings(projectDataId: LauncherProjectData['id']) {
setSelectedProjectId(projectDataId);
}
@@ -55,11 +75,11 @@ export function Projects({}: ProjectsViewProps) {
}
function onImportProjectClick() {
alert('FIXME: Import project');
onOpenProject?.();
}
function onNewProjectClick() {
alert('FIXME: Create new project');
onCreateProject?.();
}
return (
@@ -83,63 +103,83 @@ export function Projects({}: ProjectsViewProps) {
projectData={projects.find((project) => project.id === selectedProjectId)}
/>
<LauncherSearchBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filterValue={filterValue}
setFilterValue={setFilterValue}
filterDropdownItems={visibleTypesDropdownItems}
/>
<HStack hasSpacing={4} UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<LauncherSearchBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filterValue={filterValue}
setFilterValue={setFilterValue}
filterDropdownItems={visibleTypesDropdownItems}
/>
<ViewModeToggle mode={viewMode} onChange={setViewMode} />
</HStack>
{/* TODO: make project list legend and grid reusable */}
<Box hasBottomSpacing={4} hasTopSpacing={4}>
<HStack hasSpacing>
<div style={{ width: 100 }} />
<div style={{ width: '100%' }}>
<Columns layoutString={'1 1 1'}>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Name
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Version control
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Contributors
</Label>
</Columns>
</div>
</HStack>
</Box>
<Columns layoutString="1" hasXGap hasYGap>
{projects.map((project) => (
<LauncherProjectCard
key={project.id}
{...project}
contextMenuItems={[
{
label: 'Launch project',
onClick: () => alert('FIXME: Launch project')
},
{
label: 'Open project folder',
onClick: () => alert('FIXME: Open folder')
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
},
'divider',
{
label: 'Delete project',
onClick: () => alert('FIXME: Delete project'),
icon: IconName.Trash,
isDangerous: true
}
]}
<Box hasTopSpacing={4}>
{viewMode === ViewMode.List ? (
<ProjectList
projects={sortedProjects}
sortField={sortField}
sortDirection={sortDirection}
onSort={setSorting}
onProjectClick={(project) => onLaunchProject?.(project.id)}
onOpenFolder={(project) => onOpenProjectFolder?.(project.id)}
onSettings={(project) => onOpenProjectSettings(project.id)}
onDelete={(project) => onDeleteProject?.(project.id)}
/>
))}
</Columns>
) : (
<>
{/* TODO: make project list legend and grid reusable */}
<Box hasBottomSpacing={4}>
<HStack hasSpacing>
<div style={{ width: 100 }} />
<div style={{ width: '100%' }}>
<Columns layoutString={'1 1 1'}>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Name
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Version control
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Contributors
</Label>
</Columns>
</div>
</HStack>
</Box>
<Columns layoutString="1" hasXGap hasYGap>
{projects.map((project) => (
<LauncherProjectCard
key={project.id}
{...project}
contextMenuItems={[
{
label: 'Launch project',
onClick: () => onLaunchProject?.(project.id)
},
{
label: 'Open project folder',
onClick: () => onOpenProjectFolder?.(project.id)
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
},
'divider',
{
label: 'Delete project',
onClick: () => onDeleteProject?.(project.id),
icon: IconName.Trash,
isDangerous: true
}
]}
/>
))}
</Columns>
</>
)}
</Box>
</LauncherPage>
);
}

View File

@@ -0,0 +1,28 @@
/**
* Templates View - Project template browser
*
* Displays available project templates for quick starts
*
* @module noodl-core-ui/preview/launcher
*/
import React from 'react';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
export interface TemplatesViewProps {}
export function Templates({}: TemplatesViewProps) {
return (
<Box hasXSpacing hasYSpacing>
<Box hasBottomSpacing>
<Title size={TitleSize.Large}>Templates</Title>
</Box>
<div style={{ color: 'var(--theme-color-fg-default-shy)' }}>
Project templates will be displayed here. This feature is coming soon!
</div>
</Box>
);
}

View File

@@ -1,20 +1,54 @@
/* =============================================================================
NOODL DESIGN SYSTEM - COLORS
Modern refresh: Rose + Violet palette
============================================================================= */
/* =============================================================================
BASE COLORS
These are the raw palette values. DO NOT use directly in components.
Use the THEME COLOR TOKENS below instead.
Minimal palette: Red + Black + White
============================================================================= */
:root {
/* ---------------------------------------------------------------------------
SEMANTIC COLORS
BASE COLORS
A deliberately minimal palette - one accent, pure neutrals
--------------------------------------------------------------------------- */
/* Success - Modern Emerald */
/* Primary - Noodl Red */
--base-color-red-100: #fef2f3;
--base-color-red-200: #fde3e5;
--base-color-red-300: #fbc5c9;
--base-color-red-400: #f7969e;
--base-color-red-500: #ef5662;
--base-color-red-600: #d21f3c;
--base-color-red-700: #b91830;
--base-color-red-800: #9a1729;
--base-color-red-900: #801827;
--base-color-red-950: #460a11;
/* Neutrals - Pure black to white, no color tint */
--base-color-neutral-0: #000000;
--base-color-neutral-50: #0a0a0a;
--base-color-neutral-100: #121212;
--base-color-neutral-200: #1a1a1a;
--base-color-neutral-300: #262626;
--base-color-neutral-400: #333333;
--base-color-neutral-500: #525252;
--base-color-neutral-600: #737373;
--base-color-neutral-700: #a3a3a3;
--base-color-neutral-800: #d4d4d4;
--base-color-neutral-900: #e5e5e5;
--base-color-neutral-950: #f5f5f5;
--base-color-neutral-1000: #ffffff;
/* Transparent variants */
--base-color-black-transparent-90: rgba(0, 0, 0, 0.9);
--base-color-black-transparent-80: rgba(0, 0, 0, 0.8);
--base-color-black-transparent-50: rgba(0, 0, 0, 0.5);
--base-color-white-transparent-10: rgba(255, 255, 255, 0.1);
--base-color-white-transparent-15: rgba(255, 255, 255, 0.15);
--base-color-white-transparent-50: rgba(255, 255, 255, 0.5);
/* ---------------------------------------------------------------------------
SEMANTIC COLORS (Status indicators)
--------------------------------------------------------------------------- */
/* Success - Keeping a green for semantic meaning */
--base-color-success-100: #ecfdf5;
--base-color-success-200: #a7f3d0;
--base-color-success-300: #6ee7b7;
@@ -26,261 +60,190 @@
--base-color-success-900: #064e3b;
--base-color-success-1000: #022c22;
/* Error - Red (distinct from primary rose) */
--base-color-error-100: #fef2f2;
--base-color-error-200: #fecaca;
--base-color-error-300: #fca5a5;
--base-color-error-400: #f87171;
--base-color-error-500: #ef4444;
--base-color-error-600: #dc2626;
--base-color-error-700: #b91c1c;
--base-color-error-800: #991b1b;
--base-color-error-900: #7f1d1d;
--base-color-error-1000: #450a0a;
/* Error - Uses the brand red */
--base-color-error-100: var(--base-color-red-100);
--base-color-error-200: var(--base-color-red-200);
--base-color-error-300: var(--base-color-red-300);
--base-color-error-400: var(--base-color-red-400);
--base-color-error-500: var(--base-color-red-500);
--base-color-error-600: var(--base-color-red-600);
--base-color-error-700: var(--base-color-red-700);
--base-color-error-800: var(--base-color-red-800);
--base-color-error-900: var(--base-color-red-900);
--base-color-error-1000: var(--base-color-red-950);
/* ---------------------------------------------------------------------------
NODE TYPE COLORS
These give visual distinction to different node categories on the canvas
Subtle variations to distinguish node types on canvas
Using desaturated colors so they don't compete with the red accent
--------------------------------------------------------------------------- */
/* Node-Pink - For Custom/User nodes */
--base-color-node-pink-100: #fdf2f8;
--base-color-node-pink-200: #fbcfe8;
--base-color-node-pink-300: #f9a8d4;
--base-color-node-pink-400: #f472b6;
--base-color-node-pink-500: #ec4899;
--base-color-node-pink-600: #db2777;
--base-color-node-pink-700: #be185d;
--base-color-node-pink-800: #9d174d;
--base-color-node-pink-900: #831843;
--base-color-node-pink-1000: #500724;
--base-color-node-pink-200: #f5d0e5;
--base-color-node-pink-300: #e8a8ca;
--base-color-node-pink-400: #d87caa;
--base-color-node-pink-500: #c2578a;
--base-color-node-pink-600: #a63d6f;
--base-color-node-pink-700: #862d56;
--base-color-node-pink-800: #6b2445;
--base-color-node-pink-900: #521c35;
--base-color-node-pink-1000: #2d0e1c;
/* Node-Purple - For Component nodes */
--base-color-node-purple-100: #faf5ff;
--base-color-node-purple-200: #e9d5ff;
--base-color-node-purple-300: #d8b4fe;
--base-color-node-purple-400: #c084fc;
--base-color-node-purple-500: #a855f7;
--base-color-node-purple-600: #9333ea;
--base-color-node-purple-700: #7c3aed;
--base-color-node-purple-800: #6d28d9;
--base-color-node-purple-900: #5b21b6;
--base-color-node-purple-1000: #2e1065;
--base-color-node-purple-100: #f8f5fa;
--base-color-node-purple-200: #e8dff0;
--base-color-node-purple-300: #d4c4e3;
--base-color-node-purple-400: #b8a0cf;
--base-color-node-purple-500: #9a7bb8;
--base-color-node-purple-600: #7d5a9e;
--base-color-node-purple-700: #624382;
--base-color-node-purple-800: #4b3366;
--base-color-node-purple-900: #37264b;
--base-color-node-purple-1000: #1e1429;
/* Node-Green - For Data nodes */
--base-color-node-green-100: #f0fdf4;
--base-color-node-green-200: #bbf7d0;
--base-color-node-green-300: #86efac;
--base-color-node-green-400: #4ade80;
--base-color-node-green-500: #22c55e;
--base-color-node-green-600: #16a34a;
--base-color-node-green-700: #15803d;
--base-color-node-green-800: #166534;
--base-color-node-green-900: #14532d;
--base-color-node-green-1000: #052e16;
--base-color-node-green-100: #f4f7f4;
--base-color-node-green-200: #d8e5d8;
--base-color-node-green-300: #b5cfb5;
--base-color-node-green-400: #8eb58e;
--base-color-node-green-500: #6a996a;
--base-color-node-green-600: #4d7d4d;
--base-color-node-green-700: #3a613a;
--base-color-node-green-800: #2c4a2c;
--base-color-node-green-900: #203520;
--base-color-node-green-1000: #111c11;
/* Node-Gray - For Logic nodes */
--base-color-node-grey-100: #f4f4f5;
--base-color-node-grey-200: #e4e4e7;
--base-color-node-grey-300: #d4d4d8;
--base-color-node-grey-400: #a1a1aa;
--base-color-node-grey-500: #71717a;
--base-color-node-grey-600: #52525b;
--base-color-node-grey-700: #3f3f46;
--base-color-node-grey-800: #27272a;
--base-color-node-grey-900: #18181b;
--base-color-node-grey-1000: #09090b;
--base-color-node-grey-100: #f5f5f5;
--base-color-node-grey-200: #e0e0e0;
--base-color-node-grey-300: #c2c2c2;
--base-color-node-grey-400: #9e9e9e;
--base-color-node-grey-500: #757575;
--base-color-node-grey-600: #5c5c5c;
--base-color-node-grey-700: #454545;
--base-color-node-grey-800: #333333;
--base-color-node-grey-900: #212121;
--base-color-node-grey-1000: #0d0d0d;
/* Node-Blue - For Visual nodes */
--base-color-node-blue-100: #eff6ff;
--base-color-node-blue-200: #dbeafe;
--base-color-node-blue-300: #bfdbfe;
--base-color-node-blue-400: #93c5fd;
--base-color-node-blue-500: #60a5fa;
--base-color-node-blue-600: #3b82f6;
--base-color-node-blue-700: #2563eb;
--base-color-node-blue-800: #1d4ed8;
--base-color-node-blue-900: #1e40af;
--base-color-node-blue-1000: #172554;
--base-color-node-blue-100: #f4f6f8;
--base-color-node-blue-200: #dce3eb;
--base-color-node-blue-300: #bccad9;
--base-color-node-blue-400: #96adc2;
--base-color-node-blue-500: #7090a9;
--base-color-node-blue-600: #53758f;
--base-color-node-blue-700: #3e5a72;
--base-color-node-blue-800: #2f4557;
--base-color-node-blue-900: #22323f;
--base-color-node-blue-1000: #121b22;
/* ---------------------------------------------------------------------------
BRAND COLORS
LEGACY ALIASES - For backwards compatibility
--------------------------------------------------------------------------- */
/* Primary - Rose (Modern pink-red) */
--base-color-rose-100: #fff1f2;
--base-color-rose-200: #fecdd3;
--base-color-rose-300: #fda4af;
--base-color-rose-400: #fb7185;
--base-color-rose-500: #f43f5e;
--base-color-rose-600: #e11d48;
--base-color-rose-700: #be123c;
--base-color-rose-800: #9f1239;
--base-color-rose-900: #881337;
--base-color-rose-1000: #4c0519;
/* Grey -> Neutral */
--base-color-grey-100: var(--base-color-neutral-950);
--base-color-grey-100-transparent: var(--base-color-white-transparent-10);
--base-color-grey-200: var(--base-color-neutral-800);
--base-color-grey-300: var(--base-color-neutral-700);
--base-color-grey-400: var(--base-color-neutral-600);
--base-color-grey-500: var(--base-color-neutral-500);
--base-color-grey-600: var(--base-color-neutral-400);
--base-color-grey-700: var(--base-color-neutral-300);
--base-color-grey-800: var(--base-color-neutral-200);
--base-color-grey-900: var(--base-color-neutral-100);
--base-color-grey-1000: var(--base-color-neutral-50);
--base-color-grey-1000-transparent: var(--base-color-black-transparent-80);
--base-color-grey-1000-transparent-2: var(--base-color-black-transparent-50);
/* Secondary - Violet */
--base-color-violet-100: #f5f3ff;
--base-color-violet-200: #ede9fe;
--base-color-violet-300: #ddd6fe;
--base-color-violet-400: #c4b5fd;
--base-color-violet-500: #a78bfa;
--base-color-violet-600: #8b5cf6;
--base-color-violet-700: #7c3aed;
--base-color-violet-800: #6d28d9;
--base-color-violet-900: #5b21b6;
--base-color-violet-1000: #2e1065;
/* Teal -> Neutral (secondary is now white/gray) */
--base-color-teal-100: var(--base-color-neutral-1000);
--base-color-teal-200: var(--base-color-neutral-900);
--base-color-teal-300: var(--base-color-neutral-800);
--base-color-teal-400: var(--base-color-neutral-700);
--base-color-teal-500: var(--base-color-neutral-600);
--base-color-teal-600: var(--base-color-neutral-500);
--base-color-teal-700: var(--base-color-neutral-400);
--base-color-teal-800: var(--base-color-neutral-300);
--base-color-teal-900: var(--base-color-neutral-200);
--base-color-teal-1000: var(--base-color-neutral-100);
/* Amber - For warnings/notices (keeping this for semantic use) */
--base-color-amber-100: #fffbeb;
--base-color-amber-200: #fef3c7;
--base-color-amber-300: #fcd34d;
--base-color-amber-400: #fbbf24;
--base-color-amber-500: #f59e0b;
--base-color-amber-600: #d97706;
--base-color-amber-700: #b45309;
--base-color-amber-800: #92400e;
--base-color-amber-900: #78350f;
--base-color-amber-1000: #451a03;
/* ---------------------------------------------------------------------------
UI NEUTRALS - Clean Zinc palette (no warm/brown tints)
--------------------------------------------------------------------------- */
--base-color-zinc-50: #fafafa;
--base-color-zinc-100: #f4f4f5;
--base-color-zinc-200: #e4e4e7;
--base-color-zinc-300: #d4d4d8;
--base-color-zinc-400: #a1a1aa;
--base-color-zinc-500: #71717a;
--base-color-zinc-600: #52525b;
--base-color-zinc-700: #3f3f46;
--base-color-zinc-800: #27272a;
--base-color-zinc-900: #18181b;
--base-color-zinc-950: #09090b;
/* Transparent variants for overlays */
--base-color-zinc-950-transparent: rgba(9, 9, 11, 0.85);
--base-color-zinc-950-transparent-light: rgba(9, 9, 11, 0.5);
--base-color-white-transparent: rgba(255, 255, 255, 0.08);
/* ---------------------------------------------------------------------------
LEGACY ALIASES
Keeping for backwards compatibility with existing components
--------------------------------------------------------------------------- */
/* Grey -> Zinc */
--base-color-grey-100: var(--base-color-zinc-100);
--base-color-grey-100-transparent: rgba(244, 244, 245, 0.13);
--base-color-grey-200: var(--base-color-zinc-200);
--base-color-grey-300: var(--base-color-zinc-300);
--base-color-grey-400: var(--base-color-zinc-400);
--base-color-grey-500: var(--base-color-zinc-500);
--base-color-grey-600: var(--base-color-zinc-600);
--base-color-grey-700: var(--base-color-zinc-700);
--base-color-grey-800: var(--base-color-zinc-800);
--base-color-grey-900: var(--base-color-zinc-900);
--base-color-grey-1000: var(--base-color-zinc-950);
--base-color-grey-1000-transparent: var(--base-color-zinc-950-transparent);
--base-color-grey-1000-transparent-2: var(--base-color-zinc-950-transparent-light);
/* Teal -> Violet (secondary) */
--base-color-teal-100: var(--base-color-violet-100);
--base-color-teal-200: var(--base-color-violet-200);
--base-color-teal-300: var(--base-color-violet-300);
--base-color-teal-400: var(--base-color-violet-400);
--base-color-teal-500: var(--base-color-violet-500);
--base-color-teal-600: var(--base-color-violet-600);
--base-color-teal-700: var(--base-color-violet-700);
--base-color-teal-800: var(--base-color-violet-800);
--base-color-teal-900: var(--base-color-violet-900);
--base-color-teal-1000: var(--base-color-violet-1000);
/* Yellow -> Rose (primary) */
--base-color-yellow-100: var(--base-color-rose-100);
--base-color-yellow-200: var(--base-color-rose-200);
--base-color-yellow-300: var(--base-color-rose-300);
--base-color-yellow-400: var(--base-color-rose-400);
--base-color-yellow-500: var(--base-color-rose-500);
--base-color-yellow-600: var(--base-color-rose-600);
--base-color-yellow-700: var(--base-color-rose-700);
--base-color-yellow-800: var(--base-color-rose-800);
--base-color-yellow-900: var(--base-color-rose-900);
--base-color-yellow-1000: var(--base-color-rose-1000);
/* Yellow -> Red (primary is now red) */
--base-color-yellow-100: var(--base-color-red-100);
--base-color-yellow-200: var(--base-color-red-200);
--base-color-yellow-300: var(--base-color-red-400);
--base-color-yellow-400: var(--base-color-red-500);
--base-color-yellow-500: var(--base-color-red-600);
--base-color-yellow-600: var(--base-color-red-700);
--base-color-yellow-700: var(--base-color-red-800);
--base-color-yellow-800: var(--base-color-red-900);
--base-color-yellow-900: var(--base-color-red-950);
--base-color-yellow-1000: var(--base-color-red-950);
}
/* =============================================================================
THEME COLOR TOKENS
Use THESE in components. They provide semantic meaning and enable theming.
THEME COLOR TOKENS - USE THESE IN COMPONENTS
============================================================================= */
:root {
/* ---------------------------------------------------------------------------
BACKGROUNDS
Layered from darkest (bg-0) to lightest (bg-4)
bg-0: Absolute black, used sparingly
bg-1: Main app background
bg-2: Cards, panels, elevated surfaces
bg-3: Interactive elements, inputs
bg-4: Hover states, highlights
Pure blacks with subtle elevation through lightness
--------------------------------------------------------------------------- */
--theme-color-bg-0: #000000;
--theme-color-bg-1: var(--base-color-zinc-950);
--theme-color-bg-1-transparent: var(--base-color-zinc-950-transparent);
--theme-color-bg-1-transparent-2: var(--base-color-zinc-950-transparent-light);
--theme-color-bg-2: var(--base-color-zinc-900);
--theme-color-bg-3: var(--base-color-zinc-800);
--theme-color-bg-4: var(--base-color-zinc-700);
--theme-color-bg-5: var(--base-color-zinc-600);
/* Subtle background for hover/focus states */
--theme-color-bg-hover: var(--base-color-white-transparent);
--theme-color-bg-1: var(--base-color-neutral-50);
--theme-color-bg-1-transparent: var(--base-color-black-transparent-80);
--theme-color-bg-1-transparent-2: var(--base-color-black-transparent-50);
--theme-color-bg-2: var(--base-color-neutral-100);
--theme-color-bg-3: var(--base-color-neutral-200);
--theme-color-bg-4: var(--base-color-neutral-300);
--theme-color-bg-5: var(--base-color-neutral-400);
--theme-color-bg-hover: var(--base-color-white-transparent-10);
/* ---------------------------------------------------------------------------
FOREGROUNDS (Text colors)
Layered from brightest to most muted
FOREGROUNDS
Pure whites with subtle hierarchy
--------------------------------------------------------------------------- */
--theme-color-fg-highlight: #ffffff;
--theme-color-fg-default-contrast: var(--base-color-zinc-100);
--theme-color-fg-default: var(--base-color-zinc-300);
--theme-color-fg-default-shy: var(--base-color-zinc-400);
--theme-color-fg-muted: var(--base-color-zinc-500);
--theme-color-fg-transparent: var(--base-color-grey-100-transparent);
--theme-color-fg-default-contrast: var(--base-color-neutral-900);
--theme-color-fg-default: var(--base-color-neutral-800);
--theme-color-fg-default-shy: var(--base-color-neutral-700);
--theme-color-fg-muted: var(--base-color-neutral-600);
--theme-color-fg-transparent: var(--base-color-white-transparent-15);
/* ---------------------------------------------------------------------------
PRIMARY - Rose
Used for primary actions, CTAs, important highlights
Beautiful modern pink-red that's bold but not aggressive
PRIMARY - Noodl Red
The one accent color - used sparingly for maximum impact
--------------------------------------------------------------------------- */
--theme-color-primary: var(--base-color-rose-500);
--theme-color-primary-highlight: var(--base-color-rose-400);
--theme-color-primary-dim: var(--base-color-rose-700);
--theme-color-primary: #d21f3c;
--theme-color-primary-highlight: var(--base-color-red-500);
--theme-color-primary-dim: var(--base-color-red-800);
--theme-color-on-primary: #ffffff;
/* ---------------------------------------------------------------------------
SECONDARY - Violet
Used for secondary actions, links, informational elements
Complements rose beautifully - creates a modern warm/cool palette
SECONDARY - White/Light
For secondary actions, using white as the complement to red
--------------------------------------------------------------------------- */
--theme-color-secondary: var(--base-color-violet-500);
--theme-color-secondary-dim: var(--base-color-violet-700);
--theme-color-secondary-highlight: var(--base-color-violet-400);
--theme-color-secondary-bright: var(--base-color-violet-300);
--theme-color-secondary-as-fg: var(--base-color-violet-400);
--theme-color-on-secondary: #ffffff;
--theme-color-secondary: #ffffff;
--theme-color-secondary-dim: var(--base-color-neutral-700);
--theme-color-secondary-highlight: #ffffff;
--theme-color-secondary-bright: #ffffff;
--theme-color-secondary-as-fg: var(--base-color-neutral-800);
--theme-color-on-secondary: var(--base-color-neutral-100);
/* ---------------------------------------------------------------------------
NODE COLORS
Used on the node canvas to distinguish node types
Muted, desaturated to not compete with the red accent
--------------------------------------------------------------------------- */
/* Data nodes - Green */
/* Data nodes - Muted Green */
--theme-color-node-data-1: var(--base-color-node-green-700);
--theme-color-node-data-2: var(--base-color-node-green-600);
--theme-color-node-data-3: var(--base-color-node-green-500);
--theme-color-node-data-dim: var(--base-color-node-green-900);
/* Visual nodes - Blue */
/* Visual nodes - Muted Blue */
--theme-color-node-visual-1: var(--base-color-node-blue-700);
--theme-color-node-visual-2: var(--base-color-node-blue-600);
--theme-color-node-visual-2-highlight: var(--base-color-node-blue-500);
@@ -289,7 +252,7 @@
--theme-color-node-visual-shy: var(--base-color-node-blue-400);
--theme-color-node-visual-dim: var(--base-color-node-blue-900);
/* Custom nodes - Pink */
/* Custom nodes - Muted Pink */
--theme-color-node-custom-1: var(--base-color-node-pink-700);
--theme-color-node-custom-2: var(--base-color-node-pink-600);
--theme-color-node-custom-dim: var(--base-color-node-pink-900);
@@ -299,91 +262,80 @@
--theme-color-node-logic-2: var(--base-color-node-grey-600);
--theme-color-node-logic-dim: var(--base-color-node-grey-900);
/* Component nodes - Purple */
/* Component nodes - Muted Purple */
--theme-color-node-component-1: var(--base-color-node-purple-700);
--theme-color-node-component-2: var(--base-color-node-purple-600);
--theme-color-node-component-dim: var(--base-color-node-purple-900);
/* ---------------------------------------------------------------------------
STATUS COLORS
For feedback, alerts, and state indication
Success stays green, everything else maps to the palette
--------------------------------------------------------------------------- */
--theme-color-success: var(--base-color-success-400);
--theme-color-success-dim: var(--base-color-success-600);
--theme-color-success-bg: var(--base-color-success-900);
--theme-color-notice: var(--base-color-amber-400);
--theme-color-notice-dim: var(--base-color-amber-600);
--theme-color-notice-bg: var(--base-color-amber-900);
--theme-color-notice: var(--base-color-red-400);
--theme-color-notice-dim: var(--base-color-red-600);
--theme-color-notice-bg: var(--base-color-red-950);
--theme-color-danger: var(--base-color-error-400);
--theme-color-danger-light: var(--base-color-error-300);
--theme-color-danger-dim: var(--base-color-error-600);
--theme-color-danger-bg: var(--base-color-error-900);
--theme-color-danger: var(--base-color-red-500);
--theme-color-danger-light: var(--base-color-red-400);
--theme-color-danger-dim: var(--base-color-red-700);
--theme-color-danger-bg: var(--base-color-red-950);
/* ---------------------------------------------------------------------------
CONNECTION COLORS
For node connections on the canvas
--------------------------------------------------------------------------- */
--theme-color-signal: var(--base-color-rose-400);
--theme-color-data: var(--base-color-violet-500);
--theme-color-signal: var(--base-color-red-500);
--theme-color-data: var(--base-color-neutral-700);
/* ---------------------------------------------------------------------------
BORDER COLORS
Subtle borders for cards, inputs, dividers
BORDERS
Subtle white borders for dark backgrounds
--------------------------------------------------------------------------- */
--theme-color-border-default: var(--base-color-zinc-700);
--theme-color-border-subtle: var(--base-color-zinc-800);
--theme-color-border-strong: var(--base-color-zinc-600);
--theme-color-border-default: var(--base-color-neutral-300);
--theme-color-border-subtle: var(--base-color-neutral-200);
--theme-color-border-strong: var(--base-color-neutral-400);
/* ---------------------------------------------------------------------------
FOCUS RING
For accessibility - visible focus states
FOCUS
Red focus ring for accessibility
--------------------------------------------------------------------------- */
--theme-color-focus-ring: var(--base-color-rose-500);
--theme-color-focus-ring-offset: var(--base-color-zinc-950);
--theme-color-focus-ring: #d21f3c;
--theme-color-focus-ring-offset: var(--base-color-neutral-50);
}
/* =============================================================================
FUTURE: LIGHT THEME
Uncomment and apply .theme-light class to body for light mode
=============================================================================
.theme-light {
--theme-color-bg-0: #ffffff;
--theme-color-bg-1: var(--base-color-zinc-50);
--theme-color-bg-1-transparent: rgba(250, 250, 250, 0.9);
--theme-color-bg-1-transparent-2: rgba(250, 250, 250, 0.5);
--theme-color-bg-1: var(--base-color-neutral-950);
--theme-color-bg-1-transparent: rgba(255, 255, 255, 0.9);
--theme-color-bg-1-transparent-2: rgba(255, 255, 255, 0.5);
--theme-color-bg-2: #ffffff;
--theme-color-bg-3: var(--base-color-zinc-100);
--theme-color-bg-4: var(--base-color-zinc-200);
--theme-color-bg-5: var(--base-color-zinc-300);
--theme-color-bg-3: var(--base-color-neutral-900);
--theme-color-bg-4: var(--base-color-neutral-800);
--theme-color-bg-5: var(--base-color-neutral-700);
--theme-color-bg-hover: rgba(0, 0, 0, 0.04);
--theme-color-fg-highlight: var(--base-color-zinc-950);
--theme-color-fg-default-contrast: var(--base-color-zinc-800);
--theme-color-fg-default: var(--base-color-zinc-600);
--theme-color-fg-default-shy: var(--base-color-zinc-500);
--theme-color-fg-muted: var(--base-color-zinc-400);
--theme-color-fg-highlight: #000000;
--theme-color-fg-default-contrast: var(--base-color-neutral-100);
--theme-color-fg-default: var(--base-color-neutral-200);
--theme-color-fg-default-shy: var(--base-color-neutral-400);
--theme-color-fg-muted: var(--base-color-neutral-500);
--theme-color-primary: var(--base-color-rose-500);
--theme-color-primary-highlight: var(--base-color-rose-400);
--theme-color-primary-dim: var(--base-color-rose-700);
--theme-color-primary: #d21f3c;
--theme-color-on-primary: #ffffff;
--theme-color-secondary: var(--base-color-violet-600);
--theme-color-secondary-dim: var(--base-color-violet-800);
--theme-color-secondary-highlight: var(--base-color-violet-500);
--theme-color-secondary-as-fg: var(--base-color-violet-600);
--theme-color-secondary: var(--base-color-neutral-100);
--theme-color-on-secondary: #ffffff;
--theme-color-border-default: var(--base-color-zinc-200);
--theme-color-border-subtle: var(--base-color-zinc-100);
--theme-color-border-strong: var(--base-color-zinc-300);
--theme-color-focus-ring: var(--base-color-rose-500);
--theme-color-focus-ring-offset: #ffffff;
--theme-color-border-default: var(--base-color-neutral-800);
--theme-color-border-subtle: var(--base-color-neutral-900);
--theme-color-border-strong: var(--base-color-neutral-700);
}
*/

View File

@@ -1,10 +1,77 @@
:root {
--font-family: 'Inter';
--font-family-code: Menlo, Monaco, 'Courier New', monospace;
--font-weight-light: 200;
--font-weight-regular: 400;
--font-weight-semibold: 600;
--font-weight-bold: 700;
}
/* =============================================================================
NOODL DESIGN SYSTEM - TYPOGRAPHY
============================================================================= */
:root {
/* ---------------------------------------------------------------------------
FONT FAMILIES
--------------------------------------------------------------------------- */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-family-code: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, 'Courier New', monospace;
/* ---------------------------------------------------------------------------
FONT WEIGHTS
--------------------------------------------------------------------------- */
--font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ---------------------------------------------------------------------------
FONT SIZES
Fluid scale from 10px to 24px
--------------------------------------------------------------------------- */
--font-size-xs: 10px; /* Small labels, hints */
--font-size-sm: 11px; /* Secondary text, captions */
--font-size-base: 12px; /* Default body text */
--font-size-md: 13px; /* Emphasized body text */
--font-size-lg: 14px; /* Section titles, important */
--font-size-xl: 16px; /* Panel titles */
--font-size-2xl: 18px; /* Dialog titles */
--font-size-3xl: 24px; /* Page titles, hero text */
/* ---------------------------------------------------------------------------
LINE HEIGHTS
--------------------------------------------------------------------------- */
--line-height-none: 1;
--line-height-tight: 1.2;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
--line-height-loose: 2;
/* ---------------------------------------------------------------------------
LETTER SPACING
--------------------------------------------------------------------------- */
--letter-spacing-tighter: -0.05em;
--letter-spacing-tight: -0.025em;
--letter-spacing-normal: 0;
--letter-spacing-wide: 0.025em;
--letter-spacing-wider: 0.05em;
--letter-spacing-widest: 0.1em;
/* ---------------------------------------------------------------------------
SEMANTIC TEXT STYLES
Pre-composed styles for common use cases
--------------------------------------------------------------------------- */
/* Body text */
--text-body-size: var(--font-size-base);
--text-body-weight: var(--font-weight-regular);
--text-body-line-height: var(--line-height-normal);
/* Small text */
--text-small-size: var(--font-size-sm);
--text-small-weight: var(--font-weight-regular);
--text-small-line-height: var(--line-height-normal);
/* Labels */
--text-label-size: var(--font-size-xs);
--text-label-weight: var(--font-weight-medium);
--text-label-letter-spacing: var(--letter-spacing-wide);
/* Code */
--text-code-size: var(--font-size-sm);
--text-code-family: var(--font-family-code);
}

View File

@@ -0,0 +1,113 @@
/* =============================================================================
NOODL DESIGN SYSTEM - SPACING
============================================================================= */
:root {
/* ---------------------------------------------------------------------------
SPACING SCALE
4px base unit system
--------------------------------------------------------------------------- */
--spacing-0: 0;
--spacing-px: 1px;
--spacing-0-5: 2px;
--spacing-1: 4px;
--spacing-1-5: 6px;
--spacing-2: 8px;
--spacing-2-5: 10px;
--spacing-3: 12px;
--spacing-3-5: 14px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-7: 28px;
--spacing-8: 32px;
--spacing-9: 36px;
--spacing-10: 40px;
--spacing-11: 44px;
--spacing-12: 48px;
--spacing-14: 56px;
--spacing-16: 64px;
--spacing-20: 80px;
--spacing-24: 96px;
/* ---------------------------------------------------------------------------
SEMANTIC SPACING
Component-specific spacing aliases
--------------------------------------------------------------------------- */
/* Panel spacing */
--spacing-panel-padding: var(--spacing-4);
--spacing-panel-gap: var(--spacing-3);
/* Card spacing */
--spacing-card-padding: var(--spacing-3);
--spacing-card-gap: var(--spacing-2);
/* Section spacing */
--spacing-section-gap: var(--spacing-6);
--spacing-section-padding: var(--spacing-4);
/* Input spacing */
--spacing-input-padding-x: var(--spacing-2);
--spacing-input-padding-y: var(--spacing-1-5);
--spacing-input-gap: var(--spacing-2);
/* Button spacing */
--spacing-button-padding-x: var(--spacing-3);
--spacing-button-padding-y: var(--spacing-2);
--spacing-button-gap: var(--spacing-2);
/* Icon spacing */
--spacing-icon-gap: var(--spacing-2);
/* ---------------------------------------------------------------------------
BORDER RADIUS
--------------------------------------------------------------------------- */
--radius-none: 0;
--radius-sm: 2px;
--radius-default: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 9999px;
/* ---------------------------------------------------------------------------
SHADOWS
--------------------------------------------------------------------------- */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-default: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
/* Dialog/popup shadow */
--shadow-popup: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.2),
0 20px 25px -5px rgba(0, 0, 0, 0.15);
/* ---------------------------------------------------------------------------
TRANSITIONS
--------------------------------------------------------------------------- */
--transition-fast: 100ms;
--transition-default: 150ms;
--transition-slow: 300ms;
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
--transition-ease-in: cubic-bezier(0.4, 0, 1, 1);
--transition-ease-out: cubic-bezier(0, 0, 0.2, 1);
--transition-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ---------------------------------------------------------------------------
Z-INDEX SCALE
--------------------------------------------------------------------------- */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-popover: 600;
--z-tooltip: 700;
}

View File

@@ -16,6 +16,7 @@ import { ProjectModel } from './src/models/projectmodel';
import '../editor/src/styles/custom-properties/animations.css';
import '../editor/src/styles/custom-properties/fonts.css';
import '../editor/src/styles/custom-properties/colors.css';
import '../editor/src/styles/custom-properties/spacing.css';
import Router from './src/router';

View File

@@ -489,9 +489,15 @@ export class BackendServices extends Model<BackendServicesEvent, BackendServices
field: string;
type: string;
schema?: { is_nullable?: boolean; is_primary_key?: boolean; is_unique?: boolean; default_value?: unknown };
meta?: { options?: { choices?: Array<{ value: string }> }; special?: string[] };
meta?: {
options?: { choices?: Array<{ value: string }> };
special?: string[];
interface?: string;
hidden?: boolean;
};
}>) {
if (!field.collection || field.collection.startsWith('directus_')) continue;
// Only skip if collection name is missing - include system tables (directus_*)
if (!field.collection) continue;
if (!collectionMap.has(field.collection)) {
collectionMap.set(field.collection, {

View File

@@ -1,116 +1,206 @@
import { ipcRenderer } from 'electron';
import React, { useEffect, useState } from 'react';
import { platform } from '@noodl/platform';
/**
* ProjectsPage - Entry point for the launcher dashboard
*
* This page displays the new React-based Launcher component
* with horizontal tab navigation.
*/
import { ProjectModel } from '@noodl-models/projectmodel';
import getDocsEndpoint from '@noodl-utils/getDocsEndpoint';
import { LocalProjectsModel } from '@noodl-utils/LocalProjectsModel';
import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect } from 'react';
import { filesystem } from '@noodl/platform';
import { Logo, LogoSize } from '@noodl-core-ui/components/common/Logo';
import { TextButton } from '@noodl-core-ui/components/inputs/TextButton';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { IRouteProps } from '../../pages/AppRoute';
import { Frame } from '../../views/common/Frame';
import { ProjectsView } from '../../views/projectsview';
import { BaseWindow } from '../../views/windows/BaseWindow';
import { LocalProjectsModel } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
export interface ProjectsPageProps extends IRouteProps {
from: TSFixme;
}
export function ProjectsPage({ route, from }: ProjectsPageProps) {
const [view, setView] = useState<ProjectsView>(null);
const [showSpinner, setShowSpinner] = useState(false);
export function ProjectsPage(props: ProjectsPageProps) {
useEffect(() => {
const eventGroup = {};
// Switch main window size
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
}, []);
const instance = new ProjectsView({ from });
instance.render();
const handleCreateProject = useCallback(async () => {
try {
const direntry = await filesystem.openDialog({
allowCreateDirectory: true
});
if (!direntry) return;
setView(instance);
// For now, use a simple prompt for project name
// TODO: Replace with a proper React dialog in future
const name = prompt('Project name:');
if (!name) return;
instance.on(
'projectLoaded',
(project: ProjectModel) => {
LocalProjectsModel.instance.setCurrentGlobalGitAuth(project.id);
route.router.route({ to: 'editor', project });
},
eventGroup
);
const path = filesystem.makeUniquePath(filesystem.join(direntry, name));
EventDispatcher.instance.on(
'importFromUrl',
(url: string) => {
instance.importFromUrl(url);
},
eventGroup
);
const activityId = 'creating-project';
ToastLayer.showActivity('Creating new project', activityId);
return function () {
EventDispatcher.instance.off(eventGroup);
instance?.off(eventGroup);
instance?.dispose();
};
LocalProjectsModel.instance.newProject(
(project) => {
ToastLayer.hideActivity(activityId);
if (!project) {
ToastLayer.showError('Could not create project');
return;
}
// Navigate to editor with the newly created project
props.route.router.route({ to: 'editor', project });
},
{ name, path, projectTemplate: '' }
);
} catch (error) {
console.error('Failed to create project:', error);
ToastLayer.showError('Failed to create project');
}
}, [props.route]);
const handleOpenProject = useCallback(async () => {
console.log('🔵 [handleOpenProject] Starting...');
try {
console.log('🔵 [handleOpenProject] Opening file dialog...');
const direntry = await filesystem.openDialog({
allowCreateDirectory: false
});
console.log('🔵 [handleOpenProject] Selected folder:', direntry);
if (!direntry) {
console.log('🔵 [handleOpenProject] User cancelled');
return;
}
const activityId = 'opening-project';
console.log('🔵 [handleOpenProject] Showing activity toast');
ToastLayer.showActivity('Opening project', activityId);
console.log('🔵 [handleOpenProject] Calling openProjectFromFolder...');
// openProjectFromFolder adds the project to recent list and returns ProjectModel
const project = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
console.log('🔵 [handleOpenProject] Got project:', project);
if (!project) {
console.log('🔴 [handleOpenProject] Project is null/undefined');
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Could not open project');
return;
}
if (!project.name) {
console.log('🔵 [handleOpenProject] Setting project name from folder');
project.name = filesystem.basename(direntry);
}
console.log('🔵 [handleOpenProject] Getting projects list...');
// Now we need to find the project entry that was just added and load it
const projects = LocalProjectsModel.instance.getProjects();
console.log('🔵 [handleOpenProject] Projects in list:', projects.length);
const projectEntry = projects.find((p) => p.id === project.id);
console.log('🔵 [handleOpenProject] Found project entry:', projectEntry);
if (!projectEntry) {
console.log('🔴 [handleOpenProject] Project entry not found in list');
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Could not find project in recent list');
console.error('Project was added but not found in list:', project.id);
return;
}
console.log('🔵 [handleOpenProject] Loading project...');
// Actually load/open the project
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
console.log('🔵 [handleOpenProject] Project loaded:', loaded);
ToastLayer.hideActivity(activityId);
if (!loaded) {
console.log('🔴 [handleOpenProject] Load result is falsy');
ToastLayer.showError('Could not load project');
} else {
console.log('✅ [handleOpenProject] Success! Navigating to editor...');
// Navigate to editor with the loaded project
props.route.router.route({ to: 'editor', project: loaded });
}
} catch (error) {
console.error('🔴 [handleOpenProject] EXCEPTION:', error);
ToastLayer.hideActivity('opening-project');
console.error('Failed to open project:', error);
ToastLayer.showError('Could not open project');
}
}, [props.route]);
const handleLaunchProject = useCallback(
async (projectId: string) => {
const projects = LocalProjectsModel.instance.getProjects();
const project = projects.find((p) => p.id === projectId);
if (!project) return;
const activityId = 'launching-project';
ToastLayer.showActivity('Opening project', activityId);
try {
const loaded = await LocalProjectsModel.instance.loadProject(project);
ToastLayer.hideActivity(activityId);
if (!loaded) {
ToastLayer.showError('Could not load project');
} else {
// Navigate to editor with the loaded project
props.route.router.route({ to: 'editor', project: loaded });
}
} catch (error) {
ToastLayer.hideActivity(activityId);
console.error('Failed to launch project:', error);
ToastLayer.showError('Could not load project');
}
},
[props.route]
);
const handleOpenProjectFolder = useCallback(async (projectId: string) => {
const projects = LocalProjectsModel.instance.getProjects();
const project = projects.find((p) => p.id === projectId);
if (!project || !project.retainedProjectDirectory) {
ToastLayer.showError('Project folder not found');
return;
}
try {
shell.showItemInFolder(project.retainedProjectDirectory);
} catch (error) {
console.error('Failed to open project folder:', error);
ToastLayer.showError('Could not open project folder');
}
}, []);
const handleDeleteProject = useCallback((projectId: string) => {
const projects = LocalProjectsModel.instance.getProjects();
const project = projects.find((p) => p.id === projectId);
if (!project) return;
// Confirm deletion
if (
confirm(
`Remove project "${project.name}" from the list?\n\nNote: The project folder will remain on disk and can be opened again later.`
)
) {
LocalProjectsModel.instance.removeProject(projectId);
ToastLayer.showSuccess('Project removed from list');
}
}, []);
return (
<BaseWindow title="">
<TopBar showSpinner={showSpinner} setShowSpinner={setShowSpinner} />
<div style={{ position: 'relative', flex: 1 }}>
<Frame instance={view} isAbsolute />
{showSpinner && (
<div
className="spinner page-spinner"
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<div className="bounce1"></div>
<div className="bounce2"></div>
<div className="bounce3"></div>
</div>
)}
</div>
</BaseWindow>
);
}
interface TopBarProps {
showSpinner: boolean;
setShowSpinner: (value: boolean) => void;
}
function TopBar({ showSpinner, setShowSpinner }: TopBarProps) {
return (
<div
style={{
height: '52px',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: 'var(--theme-color-bg-2)'
}}
>
<HStack
UNSAFE_style={{
alignItems: 'center',
height: '100%'
}}
hasSpacing={6}
>
<Logo
size={LogoSize.Small}
UNSAFE_style={{
marginLeft: '24px'
}}
/>
<TextButton label="Docs" onClick={() => platform.openExternal(getDocsEndpoint())} />
<TextButton label="Community" onClick={() => platform.openExternal('https://www.noodl.net/community')} />
</HStack>
</div>
<Launcher
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onLaunchProject={handleLaunchProject}
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
/>
);
}

View File

@@ -2,44 +2,44 @@
Cloud services popup
------------------------------------------------------------------- */
.csp-header {
padding: 3px;
padding-left: 10px;
background-color: #373737;
color: #ccc;
padding: 3px;
padding-left: 10px;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-default);
}
.csp-title {
color: #ccc;
color: var(--theme-color-fg-default);
font-size: 14px;
}
.csp-sub-title {
color: #999;
color: var(--theme-color-fg-default-shy);
font-size: 12px;
}
.csp-desc {
color: #aaa;
color: var(--theme-color-fg-default-shy);
}
.csp-button {
background-color: #D3942B;
color:#000;
height:30px;
line-height:30px;
background-color: var(--theme-color-notice);
color: var(--theme-color-bg-0);
height: 30px;
line-height: 30px;
text-align: center;
font-family: var(--font-family-bold);
padding-left:20px;
padding-right:20px;
padding-left: 20px;
padding-right: 20px;
}
.csp-button:hover {
background-color: #e4b057;
background-color: var(--theme-color-notice-hover);
}
.csp-button.disabled {
background-color: #37383A;
color: #999;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-default-shy);
}
.csp-input {
@@ -47,8 +47,8 @@
outline: none;
width: 100%;
height: 100%;
background-color: #1f1f1f;
background-color: var(--theme-color-bg-2);
height: 30px;
color: #ccc;
color: var(--theme-color-fg-default);
font-family: var(--font-family-regular);
}
}

View File

@@ -71,7 +71,7 @@
left: 20px;
font: 12px var(--font-family-regular);
line-height: 36px;
color: #aaa;
color: var(--theme-color-fg-default-shy);
white-space: nowrap;
}
@@ -93,7 +93,7 @@
}
.components-panel-isroot {
color: #ffa300;
color: var(--theme-color-notice);
}
.components-panel-icon {
@@ -103,7 +103,7 @@
.components-panel-folder-menu,
.components-panel-item-menu {
background-color: transparent;
color: #cecece;
color: var(--theme-color-fg-default);
font-size: 18px;
}

View File

@@ -2,31 +2,31 @@
Create new node panel
------------------------------------------------------------------- */
.create-node-popup {
background-color: #222222;
background-color: var(--theme-color-bg-2);
}
.create-node-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
cursor: pointer;
background-color: #222222;
background-color: var(--theme-color-bg-2);
}
.create-node-scrollbar::-webkit-scrollbar-thumb {
background-color: #575757;
background-color: var(--theme-color-border-strong);
}
.create-node-search-bg {
background-color: #2e2e2e;
background-color: var(--theme-color-bg-3);
}
.create-node-light-bg {
background-color: #383838;
background-color: var(--theme-color-bg-4);
}
.create-node-dark-input {
background-color: #2e2e2e;
color: #dddddd;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default-contrast);
border: none;
display: block;
padding: 0 0 0 10px;
@@ -37,15 +37,15 @@
.create-node-item.disabled {
background: none;
border: none;
color: #888888;
color: var(--theme-color-fg-muted);
}
.create-node-list-item {
background-color: #383838;
background-color: var(--theme-color-bg-4);
padding-left: 10px;
padding-top: 8px;
padding-bottom: 8px;
color: #f8f8f8;
color: var(--theme-color-fg-highlight);
cursor: default;
}
@@ -54,36 +54,36 @@
}*/
.create-node-list-item.highlighted {
background-color: #14606e;
background-color: var(--theme-color-primary);
}
.create-node-search-icon {
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
margin-left: 5px;
}
.create-node-path-item {
color: white;
color: var(--theme-color-fg-highlight);
padding-left: 3px;
line-height: 35px;
}
.create-node-library-empty {
color: #dddddd;
color: var(--theme-color-fg-default-contrast);
padding: 15px;
}
.create-node-quickbar-icon {
font-size: 20px;
padding: 5px;
color: #ddd;
color: var(--theme-color-fg-default-contrast);
width: 20px;
height: 20px;
object-fit: cover;
}
.create-node-quickbar-icon:hover {
background-color: #444;
background-color: var(--theme-color-bg-5);
}
.create-node-quickbar-icon.text {
@@ -112,7 +112,7 @@
/** docs formatting */
.create-node-docs * {
color: #aaa;
color: var(--theme-color-fg-default-shy);
}
.create-node-docs img {
@@ -120,28 +120,28 @@
}
.create-node-docs h1 {
color: #ddd;
color: var(--theme-color-fg-default-contrast);
}
.create-node-docs h2 {
color: #ccc;
color: var(--theme-color-fg-default);
}
.create-node-docs strong {
color: #ccc;
color: var(--theme-color-fg-default);
}
.create-node-docs a {
color: #d49517;
color: var(--theme-color-notice);
}
.create-node-docs a:hover {
color: #fdb314;
color: var(--theme-color-notice-hover);
}
.create-node-docs .language-javascript {
color: #eee;
background-color: #444;
color: var(--theme-color-fg-highlight);
background-color: var(--theme-color-bg-5);
display: block;
padding: 5px;
overflow: overlay;

View File

@@ -0,0 +1,113 @@
/* =============================================================================
NOODL DESIGN SYSTEM - SPACING
============================================================================= */
:root {
/* ---------------------------------------------------------------------------
SPACING SCALE
4px base unit system
--------------------------------------------------------------------------- */
--spacing-0: 0;
--spacing-px: 1px;
--spacing-0-5: 2px;
--spacing-1: 4px;
--spacing-1-5: 6px;
--spacing-2: 8px;
--spacing-2-5: 10px;
--spacing-3: 12px;
--spacing-3-5: 14px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-7: 28px;
--spacing-8: 32px;
--spacing-9: 36px;
--spacing-10: 40px;
--spacing-11: 44px;
--spacing-12: 48px;
--spacing-14: 56px;
--spacing-16: 64px;
--spacing-20: 80px;
--spacing-24: 96px;
/* ---------------------------------------------------------------------------
SEMANTIC SPACING
Component-specific spacing aliases
--------------------------------------------------------------------------- */
/* Panel spacing */
--spacing-panel-padding: var(--spacing-4);
--spacing-panel-gap: var(--spacing-3);
/* Card spacing */
--spacing-card-padding: var(--spacing-3);
--spacing-card-gap: var(--spacing-2);
/* Section spacing */
--spacing-section-gap: var(--spacing-6);
--spacing-section-padding: var(--spacing-4);
/* Input spacing */
--spacing-input-padding-x: var(--spacing-2);
--spacing-input-padding-y: var(--spacing-1-5);
--spacing-input-gap: var(--spacing-2);
/* Button spacing */
--spacing-button-padding-x: var(--spacing-3);
--spacing-button-padding-y: var(--spacing-2);
--spacing-button-gap: var(--spacing-2);
/* Icon spacing */
--spacing-icon-gap: var(--spacing-2);
/* ---------------------------------------------------------------------------
BORDER RADIUS
--------------------------------------------------------------------------- */
--radius-none: 0;
--radius-sm: 2px;
--radius-default: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 9999px;
/* ---------------------------------------------------------------------------
SHADOWS
--------------------------------------------------------------------------- */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-default: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
/* Dialog/popup shadow */
--shadow-popup: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.2),
0 20px 25px -5px rgba(0, 0, 0, 0.15);
/* ---------------------------------------------------------------------------
TRANSITIONS
--------------------------------------------------------------------------- */
--transition-fast: 100ms;
--transition-default: 150ms;
--transition-slow: 300ms;
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
--transition-ease-in: cubic-bezier(0.4, 0, 1, 1);
--transition-ease-out: cubic-bezier(0, 0, 0.2, 1);
--transition-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ---------------------------------------------------------------------------
Z-INDEX SCALE
--------------------------------------------------------------------------- */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-popover: 600;
--z-tooltip: 700;
}

View File

@@ -2,116 +2,115 @@
Layout panel
------------------------------------------------------------------- */
.layout-panel-item {
/* background-color: #282828;*/
width: 100%;
cursor: default;
color: #cfcfcf;
position: relative;
/* border-bottom: 1px solid #222222;*/
/* background-color: #282828;*/
width: 100%;
cursor: default;
color: var(--theme-color-fg-default);
position: relative;
/* border-bottom: 1px solid #222222;*/
}
.layout-panel-item-hover-indicator {
background-color: #1f1f1f;
background-color: var(--theme-color-bg-2);
}
.layout-panel-header {
color: white;
padding-left: 10px;
font: 12px var(--font-family-bold);
line-height: 35px;
color: var(--theme-color-fg-highlight);
padding-left: 10px;
font: 12px var(--font-family-bold);
line-height: 35px;
}
.layout-panel-divider {
border-bottom: 1px solid #222222;
border-bottom: 1px solid var(--theme-color-border-default);
}
.layout-panel-item-label {
font: 12px var(--font-family-regular);
line-height: 30px;
font: 12px var(--font-family-regular);
line-height: 30px;
}
.layout-panel-item-selected {
background-color: #14606e;
position:absolute;
width:100%;
height:30px;
}
background-color: var(--theme-color-primary);
position: absolute;
width: 100%;
height: 30px;
}
.layout-panel-sheet-label {
color: white;
font: 12px var(--font-family-regular);
line-height: 35px;
color: #aaa;
color: var(--theme-color-fg-highlight);
font: 12px var(--font-family-regular);
line-height: 35px;
color: var(--theme-color-fg-default-shy);
}
.layout-panel-sheet-selector:hover .layout-panel-sheet-label {
color: white;
color: var(--theme-color-fg-highlight);
}
.layout-panel-expand-more-icon {
content:url("../assets/icons/expand_more.svg");
width:20px;
height:20px;
opacity: 0.6
content: url('../assets/icons/expand_more.svg');
width: 20px;
height: 20px;
opacity: 0.6;
}
.layout-panel-sheet-selector:hover .layout-panel-expand-more-icon {
opacity: 1;
opacity: 1;
}
.layout-panel-expand-less-icon {
content:url("../assets/icons/expand_less.svg");
width:20px;
height:20px;
opacity: 0.6
content: url('../assets/icons/expand_less.svg');
width: 20px;
height: 20px;
opacity: 0.6;
}
.layout-panel-sheet-selector:hover .layout-panel-expand-less-icon {
opacity: 1;
opacity: 1;
}
.layout-panel-sheet-item {
font: 12px var(--font-family-regular);
line-height: 30px;
color: #cfcfcf;
padding-left: 10px;
font: 12px var(--font-family-regular);
line-height: 30px;
color: var(--theme-color-fg-default);
padding-left: 10px;
}
.layout-panel-sheet-item:hover {
background-color: #1f1f1f;
background-color: var(--theme-color-bg-2);
}
.layout-panel-component-item {
font: 12px var(--font-family-regular);
line-height: 30px;
color: #cfcfcf;
padding-left: 10px;
font: 12px var(--font-family-regular);
line-height: 30px;
color: var(--theme-color-fg-default);
padding-left: 10px;
}
.layout-panel-component-item:hover {
background-color: #1f1f1f;
background-color: var(--theme-color-bg-2);
}
.layout-panel-edit-button {
width: 30px;
height: 30px;
background-color: transparent;
color: #7b7b7b;
border: none;
width: 30px;
height: 30px;
background-color: transparent;
color: var(--theme-color-fg-muted);
border: none;
}
.layout-panel-edit-button:hover {
background-color: #1f1f1f;
color: #f8f8f8;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-highlight);
}
.layout-panel-dark-input {
/* background-color: #222222;
/* background-color: #222222;
color: #dddddd;*/
border: none;
display: block;
padding:0 0 0 10px;
box-sizing: border-box;
font: 12px var(--font-family-regular);
}
border: none;
display: block;
padding: 0 0 0 10px;
box-sizing: border-box;
font: 12px var(--font-family-regular);
}

View File

@@ -1,40 +1,40 @@
/* -------------------------------------------------------------------
Update available
------------------------------------------------------------------- */
.updatepopup-group-label {
display: block;
color: #888888;
background-color: #272727;
padding: 5px;
}
.updatepopup-message {
display: block;
}
.updatepopup-button {
background-color: #d49517;
border:none;
color:black;
padding: 10px;
font-weight: bold;
font-size: 12px;
cursor: pointer;
}
.updatepopup-button:hover {
background-color: #fdb314;
}
.updatepopup-group-label {
display: block;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-4);
padding: 5px;
}
.updatepopup-button-grey {
border:none;
background-color: #333333;
color: #999999;
padding: 10px;
font-weight: bold;
font-size: 12px;
}
.updatepopup-button-grey:hover {
background-color: #555555;
}
.updatepopup-message {
display: block;
}
.updatepopup-button {
background-color: var(--theme-color-notice);
border: none;
color: var(--theme-color-bg-0);
padding: 10px;
font-weight: bold;
font-size: 12px;
cursor: pointer;
}
.updatepopup-button:hover {
background-color: var(--theme-color-notice-hover);
}
.updatepopup-button-grey {
border: none;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-default-shy);
padding: 10px;
font-weight: bold;
font-size: 12px;
}
.updatepopup-button-grey:hover {
background-color: var(--theme-color-border-strong);
}

View File

@@ -15,7 +15,7 @@
}
.popup-layer.dim {
background-color: #0000007f;
background-color: var(--base-color-black-transparent-50);
transition: background-color 0.2s linear;
pointer-events: all;
}
@@ -93,7 +93,7 @@
}
.popup-layer-popup-arrow.dark {
border-bottom-color: rgba(0, 0, 0, 0.7);
border-bottom-color: var(--base-color-black-transparent-70);
}
/** popout **/
@@ -122,7 +122,7 @@
width: 0;
position: absolute;
pointer-events: none;
border-bottom-color: #333;
border-bottom-color: var(--theme-color-bg-5);
border-width: 10px;
margin-left: -10px;
}
@@ -136,7 +136,7 @@
width: 0;
position: absolute;
pointer-events: none;
border-left-color: #333;
border-left-color: var(--theme-color-bg-5);
border-width: 10px;
margin-top: -10px;
}
@@ -150,7 +150,7 @@
width: 0;
position: absolute;
pointer-events: none;
border-top-color: #333;
border-top-color: var(--theme-color-bg-5);
border-width: 10px;
margin-left: -10px;
}
@@ -164,7 +164,7 @@
width: 0;
position: absolute;
pointer-events: none;
border-right-color: #333;
border-right-color: var(--theme-color-bg-5);
border-width: 10px;
margin-top: -10px;
}
@@ -197,19 +197,19 @@
.popup-layer-popup-menu-divider {
height: 0px;
border-top: 2px #333 solid;
border-top: 2px var(--theme-color-border-default) solid;
margin-top: 3px;
margin-bottom: 3px;
}
.popup-layer-toast {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
background-color: var(--base-color-black-transparent-70);
border-radius: 3px;
border-color: rgba(0, 0, 0, 0.7);
border-color: var(--base-color-black-transparent-70);
border-width: 3px;
padding: 5px;
color: white;
color: var(--theme-color-fg-highlight);
opacity: 0;
transition: opacity 500ms;
font-size: 15px;
@@ -218,10 +218,10 @@
.popup-layer-dragger {
position: absolute;
background-color: rgba(0, 0, 0, 0.8);
background-color: var(--base-color-black-transparent-80);
border-radius: 3px;
border-width: 3px;
color: white;
color: var(--theme-color-fg-highlight);
font-size: 12px;
-webkit-transition: opacity 0.25s ease-out;
padding: 5px;
@@ -243,13 +243,13 @@
border-color: var(--popup-layer-tooltip-border-color);
border-width: 1px;
padding: 12px 16px;
color: white;
color: var(--theme-color-fg-highlight);
position: absolute;
opacity: 0;
-webkit-transition: opacity 0.3s;
pointer-events: none;
z-index: 2000;
box-shadow: -4px 4px 16px rgba(0, 0, 0, 0.3);
box-shadow: -4px 4px 16px var(--base-color-black-transparent-30);
}
.popup-layer-tooltip-content {
@@ -343,7 +343,7 @@
.popup {
padding-bottom: 3px;
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
}
.popup p {
@@ -353,20 +353,20 @@
.popup-group-label {
display: block;
color: #cccccc;
background-color: #272727;
color: var(--theme-color-fg-default);
background-color: var(--theme-color-bg-4);
padding: 5px;
}
.popup-text {
display: block;
color: #777777;
color: var(--theme-color-fg-muted);
margin-left: 5px;
margin-right: 5px;
}
.popup-file-drop {
background-color: #000000;
background-color: var(--theme-color-bg-0);
opacity: 0.7;
width: 100%;
height: 100%;
@@ -374,10 +374,10 @@
.popup-file-drop-msg {
margin: auto;
color: #eeeeee;
color: var(--theme-color-fg-default-contrast);
width: 400px;
background-color: transparent;
border-color: #aaaaaa;
border-color: var(--theme-color-fg-default-shy);
border-width: 2px;
border-style: dashed;
font-size: 20px;
@@ -393,12 +393,12 @@
.popup-layer-activity {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
background-color: var(--base-color-black-transparent-70);
border-radius: 3px;
border-color: rgba(0, 0, 0, 0.7);
border-color: var(--base-color-black-transparent-70);
border-width: 3px;
padding: 10px;
color: white;
color: var(--theme-color-fg-highlight);
opacity: 0;
transition: opacity 500ms;
font-size: 15px;
@@ -417,7 +417,7 @@
.popup-layer-activity-progress {
margin-left: 10px;
margin-right: 10px;
background-color: black;
background-color: var(--theme-color-bg-0);
opacity: 0.5;
height: 10px;
display: none;
@@ -426,69 +426,69 @@
.popup-layer-activity-progress-bar {
width: 0%;
height: 100%;
background-color: #cccccc;
background-color: var(--theme-color-fg-default);
}
.popup-title {
display: block;
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
margin-left: 5px;
margin-right: 5px;
}
.popup-title > strong {
color: #cccccc;
color: var(--theme-color-fg-default);
}
.popup-message {
display: block;
color: #aaa;
color: var(--theme-color-fg-default-shy);
margin-left: 5px;
margin-right: 5px;
}
.popup-message > strong {
color: #ddd;
color: var(--theme-color-fg-default-contrast);
}
.popup-button {
border: none;
padding: 0px;
background-color: #d49517;
color: #171717;
background-color: var(--theme-color-notice);
color: var(--theme-color-bg-2);
padding: 2px 15px 2px 15px;
font-size: 12px;
text-transform: uppercase;
}
.popup-button:hover {
background-color: #fdb314;
background-color: var(--theme-color-notice-hover);
}
.popup-button-grey {
border: none;
padding: 0px;
background-color: #333333;
color: #999999;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-default-shy);
padding: 2px 15px 2px 15px;
font-size: 12px;
text-transform: uppercase;
}
.popup-button-grey:hover {
background-color: #555555;
background-color: var(--theme-color-border-strong);
}
.popup-small-docs {
position: absolute;
background-color: #171717;
background-color: var(--theme-color-bg-2);
z-index: 2;
bottom: 2px;
}
/* Confim modal */
.confirm-modal {
color: #c4c4c4;
color: var(--theme-color-fg-default);
max-width: 70vw;
max-height: 70vh;
overflow-y: auto;
@@ -496,7 +496,7 @@
.confirm-modal label {
display: block;
background-color: #272727;
background-color: var(--theme-color-bg-4);
padding: 5px 10px;
}
@@ -519,19 +519,19 @@
}
.confirm-modal .confirm-button {
background-color: #f67465;
color: #1d1f20;
background-color: var(--theme-color-primary);
color: var(--theme-color-bg-2);
}
.confirm-modal .confirm-button:hover {
background-color: #f89387;
background-color: var(--theme-color-primary-hover);
}
.confirm-modal .cancel-button {
background-color: #1d1f20;
color: #c4c4c4;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
}
.confirm-modal .cancel-button:hover {
background-color: #2f3335;
background-color: var(--theme-color-bg-3);
}

View File

@@ -4,7 +4,7 @@
.projects-main {
width: 100%;
height: 100%;
background-color: #131313;
background-color: var(--theme-color-bg-1);
position: absolute;
top: 0px;
left: 0px;
@@ -24,36 +24,36 @@
}*/
.projects-lab-title {
color: white;
color: var(--theme-color-fg-highlight);
font: 20px var(--font-family-bold);
}
.projects-lab-desc {
color: #8e8e8e;
color: var(--theme-color-fg-muted);
font: 12px var(--font-family-regular);
}
.projects-header-tab {
color: #8e8e8e;
color: var(--theme-color-fg-muted);
font-size: 15px;
}
.projects-header-tab:hover {
color: white;
color: var(--theme-color-fg-highlight);
}
.projects-header-tab-selected {
color: white;
color: var(--theme-color-fg-highlight);
opacity: 1;
}
.projects-header-tab-small {
color: #8e8e8e;
color: var(--theme-color-fg-muted);
font-size: 16px;
}
.projects-header-tab-small:hover {
color: white;
color: var(--theme-color-fg-highlight);
}
.projects-sign-out-icon {
@@ -64,23 +64,23 @@
}
.projects-header-button {
background-color: #333;
background-color: var(--theme-color-bg-5);
border: none;
color: #d4d4d4;
color: var(--theme-color-fg-default);
padding: 10px;
font-weight: bold;
font-size: 12px;
}
.projects-header-button:hover {
background-color: #555;
color: white;
background-color: var(--theme-color-border-strong);
color: var(--theme-color-fg-highlight);
}
.projects-create-new-project-button {
background-color: #d49517;
background-color: var(--theme-color-notice);
border: none;
color: black;
color: var(--theme-color-bg-0);
padding: 10px;
font-weight: bold;
font-size: 12px;
@@ -88,13 +88,13 @@
}
.projects-create-new-project-button:hover {
background-color: #fdb314;
color: black;
background-color: var(--theme-color-notice-hover);
color: var(--theme-color-bg-0);
}
.projects-create-new-project-button:disabled {
opacity: 0.5;
background-color: #d49517;
background-color: var(--theme-color-notice);
}
.projects-discord-button {
@@ -107,23 +107,23 @@
}
.projects-button-grey {
background-color: #333;
background-color: var(--theme-color-bg-5);
border: none;
color: #d4d4d4;
color: var(--theme-color-fg-default);
padding: 10px;
font-weight: bold;
font-size: 12px;
}
.projects-button-grey:hover {
background-color: #555;
color: white;
background-color: var(--theme-color-border-strong);
color: var(--theme-color-fg-highlight);
}
.projects-checkbox {
width: 20px;
height: 20px;
border: 1px solid #aaa;
border: 1px solid var(--theme-color-fg-default-shy);
}
.projects-checkmark {
@@ -134,12 +134,12 @@
}
.projects-search-bg {
background-color: #191919;
background-color: var(--theme-color-bg-2);
}
.projects-search-input {
background-color: #191919;
color: #aaaaaa;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default-shy);
border: none;
display: block;
padding: 0 0 0 10px;
@@ -148,8 +148,8 @@
}
.projects-project-name-input {
background-color: #191919;
color: #aaaaaa;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default-shy);
border: none;
display: block;
padding: 0 0 0 10px;
@@ -166,35 +166,35 @@
}
.projects-header-user-label {
color: white;
color: var(--theme-color-fg-highlight);
font-size: 15px;
}
.projects-header-user-sublabel {
display: inline;
color: #aaa;
color: var(--theme-color-fg-default-shy);
font-size: 11px;
}
.projects-header-user-expand-menu {
color: #ccc;
color: var(--theme-color-fg-default);
margin-left: 5px;
margin-top: 1px;
}
.projects-header-workspace-label {
color: white;
color: var(--theme-color-fg-highlight);
font-size: 11px;
margin: 0px;
padding: 5px 10px;
}
.projects-header-workspace-label:hover {
background-color: #555;
background-color: var(--theme-color-border-strong);
}
.projects-header-workspace-label-btn {
color: white;
color: var(--theme-color-fg-highlight);
font-size: 11px;
padding-left: 10px;
margin: 0px;
@@ -203,16 +203,16 @@
}
.projects-header-workspace-label-btn:hover {
background-color: #555;
background-color: var(--theme-color-border-strong);
}
.projects-header-workspace-divider {
height: 0px;
border-top: 2px #333 solid;
border-top: 2px var(--theme-color-bg-5) solid;
}
.projects-header-workspace-title {
color: #aaa;
color: var(--theme-color-fg-default-shy);
font-size: 10px;
text-transform: uppercase;
margin-top: 5px;
@@ -221,7 +221,7 @@
}
.projects-header-workspaces {
background-color: #444;
background-color: var(--theme-color-bg-5);
}
.projects-header-settings-icon {
@@ -236,7 +236,7 @@
}
.projects-header-settings:hover {
background-color: #363637;
background-color: var(--theme-color-bg-5);
}
.projects-header-settings-icon:hover {
@@ -255,8 +255,8 @@
}
.projects-panel-header {
background-color: #363637;
color: white;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-highlight);
padding: 15px;
font-size: 16px;
margin: 5px;
@@ -265,16 +265,16 @@
#admin-settings {
box-sizing: border-box;
padding: 20px;
background-color: #131313;
background-color: var(--theme-color-bg-1);
}
.projects-feed-title {
color: #a3a2a2;
color: var(--theme-color-fg-default-shy);
font-size: 16px;
}
.projects-feed-body {
color: #838282;
color: var(--theme-color-fg-muted);
font-size: 12px;
line-height: 15px;
padding-top: 5px;
@@ -305,26 +305,26 @@
}
.projects-lesson-big-item-plate {
background-color: #181818;
background-color: var(--theme-color-bg-2);
}
.projects-lesson-big-item-plate:hover {
background-color: #292929;
background-color: var(--theme-color-bg-4);
}
.projects-lesson-big-item-label {
color: white;
color: var(--theme-color-fg-highlight);
font: 16px var(--font-family-bold);
}
.projects-lesson-big-item-desc {
color: #8e8e8e;
color: var(--theme-color-fg-muted);
font: 12px var(--font-family-regular);
}
.projects-lesson-big-item-progress {
color: #181818;
background-color: #333333;
color: var(--theme-color-bg-2);
background-color: var(--theme-color-bg-5);
overflow: hidden;
}
@@ -336,13 +336,13 @@
.projects-lesson-big-item-progress-label {
font-size: 15px;
font-weight: bold;
color: #8e8e8e;
color: var(--theme-color-fg-muted);
}
.projects-lesson-item-progress-label {
font-size: 12px;
font-weight: bold;
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
}
.projects-lesson-image {
@@ -368,51 +368,51 @@
}
.projects-lesson-item-progress {
color: #8e8e8e;
color: var(--theme-color-fg-muted);
font-size: 15px;
}
.projects-lesson-item-progress-completed {
color: #e4bc4f;
color: var(--theme-color-notice);
}
.projects-lesson-item-progress-not-started {
color: #838282;
color: var(--theme-color-fg-muted);
}
.projects-lesson-item-progress-has-started {
color: #af9234;
color: var(--theme-color-notice);
}
.projects-lesson-item-progress-has-completed {
color: #436845;
color: var(--theme-color-success);
}
.projects-panel-icon {
font-size: 20px;
color: #737272;
color: var(--theme-color-fg-muted);
}
.projects-panel-dark {
font-size: 20px;
color: #333232;
color: var(--theme-color-bg-5);
}
.projects-panel-icon:hover {
color: #c3c2c2;
color: var(--theme-color-fg-default);
}
.projects-panel-circle-ellipse-icon {
border-radius: 50%;
background-color: #737272;
background-color: var(--theme-color-fg-muted);
width: 18px;
height: 18px;
text-align: center;
color: #464647;
color: var(--theme-color-bg-5);
}
.projects-panel-circle-ellipse-icon:hover {
background-color: #c3c2c2;
background-color: var(--theme-color-fg-default);
}
.projects-panel-icon-show-on-hover {
@@ -432,8 +432,8 @@
}
.projects-section-header {
background-color: #171717;
color: white;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-highlight);
line-height: 30px;
padding-left: 5px;
}
@@ -453,15 +453,15 @@
}
.projects-item-plate {
background-color: #333;
background-color: var(--theme-color-bg-5);
}
.projects-item-plate:hover {
background-color: #555;
background-color: var(--theme-color-border-strong);
}
.projects-item-label-bg {
background-color: #333333;
background-color: var(--theme-color-bg-5);
text-align: center;
opacity: 0.6;
}
@@ -474,7 +474,7 @@
.projects-item-cloud-synced {
font: 10px var(--font-family-bold);
color: #838282;
color: var(--theme-color-fg-muted);
}
.projects-item-cloud-synced-icon {
@@ -489,30 +489,30 @@
.projects-item-cloud-download {
text-align: center;
font-size: 40px;
color: #777777;
color: var(--theme-color-fg-muted);
display: none;
background-color: #222222;
background-color: var(--theme-color-bg-2);
}
.projects-add-new-item-border {
border-width: 2px;
border-style: dashed;
border-color: #888888;
border-color: var(--theme-color-fg-muted);
}
.projects-item-remove-icon {
opacity: 0;
background-color: #333333;
border-color: #333333;
background-color: var(--theme-color-bg-5);
border-color: var(--theme-color-bg-5);
border-width: 3px;
border-radius: 3px;
padding: 5px;
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
}
.projects-item-remove-icon:hover {
background-color: #555555;
border-color: #555555;
background-color: var(--theme-color-border-strong);
border-color: var(--theme-color-border-strong);
}
.projects-item:hover .projects-item-remove-icon {
@@ -524,8 +524,8 @@
}
.projects-update-banner {
background-color: #557766;
color: #cccccc;
background-color: var(--theme-color-success);
color: var(--theme-color-fg-default);
border-radius: 2px;
padding: 10px;
padding-top: 3px;
@@ -533,24 +533,25 @@
}
.projects-update-banner:hover {
background-color: #779988;
background-color: var(--theme-color-success);
opacity: 0.8;
}
.projects-remote-url-label {
color: #888888;
color: var(--theme-color-fg-muted);
}
.projects-remote-url-clone-button {
background-color: #8f7204;
background-color: var(--theme-color-notice);
border: none;
color: #cccccc;
color: var(--theme-color-fg-default);
height: 100%;
margin-top: 5px;
float: right;
}
.projects-remote-url-clone-button:hover {
background-color: #af9224;
background-color: var(--theme-color-notice-hover);
}
.projects-feed-top-item {
@@ -581,50 +582,50 @@
}
.projects-feed-item {
background-color: #1f1f1f;
background-color: var(--theme-color-bg-2);
}
.projects-feed-item:hover {
background-color: #2f2f2f;
background-color: var(--theme-color-bg-3);
}
.projects-feed-item-title {
color: white;
color: var(--theme-color-fg-highlight);
font: 14px var(--font-family-regular);
}
.projects-feed-item-body {
color: #929292;
color: var(--theme-color-fg-muted);
font: 12px var(--font-family-regular);
}
.projects-create-from-template-title {
color: white;
color: var(--theme-color-fg-highlight);
font-size: 20px;
margin-bottom: 10px;
margin-top: 20px;
}
.projects-title {
color: white;
color: var(--theme-color-fg-highlight);
font: 24px var(--font-family-semibold);
text-transform: capitalize;
}
.projects-popup-title {
color: white;
color: var(--theme-color-fg-highlight);
font: 20px var(--font-family-semibold);
text-transform: capitalize;
}
.projects-divider-label {
color: white;
color: var(--theme-color-fg-highlight);
font: 24px var(--font-family-semibold);
text-transform: capitalize;
}
.projects-sub-divider-label {
color: #777;
color: var(--theme-color-fg-muted);
font-weight: 600;
font-size: 16px;
padding-top: 48px;
@@ -632,32 +633,32 @@
}
.projects-label {
color: #dedede;
color: var(--theme-color-fg-default-contrast);
font: 14px var(--font-family-bold);
opacity: 0.5;
}
.projects-label.highlighted {
color: white;
color: var(--theme-color-fg-highlight);
}
.projects-label-small {
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
font: 12px var(--font-family-regular);
text-transform: uppercase;
}
.projects-sublabel {
color: #dedede;
color: var(--theme-color-fg-default-contrast);
font: 12px var(--font-family-regular);
}
.projects-sublabel.highlighted {
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
}
.projects-create-new-project-cs {
color: #dedede;
color: var(--theme-color-fg-default-contrast);
font: 14px var(--font-family-bold);
}
@@ -700,7 +701,7 @@
}
.projects-template-item-title {
color: white;
color: var(--theme-color-fg-highlight);
font: 14px var(--font-family-semibold);
padding-bottom: 4px;
}
@@ -737,13 +738,13 @@
}
.projects-item-label {
color: white;
color: var(--theme-color-fg-highlight);
font-weight: var(--font-weight-semibold);
}
.projects-item-sublabel {
font: 10px var(--font-family-bold);
color: #838282;
color: var(--theme-color-fg-muted);
}
.projects-edit-icon {
@@ -761,13 +762,13 @@
}
.projects-start-pane-label {
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
font: 16px var(--font-family-regular);
text-transform: initial;
}
.projects-start-pane-feed-big {
background-color: #131313dd;
background-color: var(--base-color-black-transparent-85);
}
.projects-start-pane-thumb {
@@ -778,16 +779,16 @@
}
.projects-start-pane-item {
background-color: #191919;
background-color: var(--theme-color-bg-2);
}
.projects-start-pane-item:hover {
background-color: #222222;
background-color: var(--theme-color-bg-2);
}
.projects-rename-input {
background-color: #181818;
color: white;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-highlight);
border: none;
display: block;
padding: 0px;
@@ -799,17 +800,17 @@
}
.projects-icon {
color: #aaaaaa;
color: var(--theme-color-fg-default-shy);
margin-left: 5px;
}
.projects-icon.highlighted {
color: white;
color: var(--theme-color-fg-highlight);
margin-left: 5px;
}
.projects-cloudservices-bg {
background-color: #191919;
background-color: var(--theme-color-bg-2);
}
.projects-getting-started-item {
@@ -820,7 +821,7 @@
}
.projects-getting-started-item:hover {
background-color: #333;
background-color: var(--theme-color-bg-5);
}
.tutorial-items {
@@ -869,14 +870,14 @@
}
.projects-tutorial-item-label {
color: black;
color: var(--theme-color-bg-0);
font: 20px var(--font-family-semibold);
line-height: 27px;
white-space: normal;
}
.projects-tutorial-item-sublabel {
color: white;
color: var(--theme-color-fg-highlight);
font: 12px var(--font-family-regular);
}
@@ -905,7 +906,7 @@
}
.tutorial-category-item {
color: white;
color: var(--theme-color-fg-highlight);
font: 24px var(--font-family-semibold);
font-weight: 600;
margin-right: 40px;
@@ -929,21 +930,21 @@
}
.projects-new-version-available-link {
color: #f5bc41;
color: var(--theme-color-notice);
}
.projects-new-version-available-link:hover {
color: #ffd373;
color: var(--theme-color-notice-hover);
-webkit-app-region: no-drag;
}
.projects-new-update-available {
color: #f5bc41;
color: var(--theme-color-notice);
-webkit-app-region: no-drag;
}
.projects-new-update-available:hover {
color: #ffd373;
color: var(--theme-color-notice-hover);
}
.account-menu-workspaces {
@@ -957,12 +958,12 @@
/* Legacy project card modifier */
.projects-item--legacy {
border: 2px solid #d49517;
border: 2px solid var(--theme-color-notice);
box-sizing: border-box;
}
.projects-item--legacy:hover {
border-color: #fdb314;
border-color: var(--theme-color-notice-hover);
}
/* Legacy badge in top-right corner */
@@ -974,7 +975,7 @@
align-items: center;
gap: 4px;
background-color: rgba(212, 149, 23, 0.9);
color: #000;
color: var(--theme-color-bg-0);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
@@ -1014,9 +1015,9 @@
/* Migrate Project button */
.projects-item-migrate-btn {
background-color: #d49517;
background-color: var(--theme-color-notice);
border: none;
color: #000;
color: var(--theme-color-bg-0);
padding: 10px 20px;
font-weight: 600;
font-size: 12px;
@@ -1028,14 +1029,14 @@
}
.projects-item-migrate-btn:hover {
background-color: #fdb314;
background-color: var(--theme-color-notice-hover);
}
/* Open Read-Only button */
.projects-item-readonly-btn {
background-color: transparent;
border: 1px solid #666;
color: #aaa;
border: 1px solid var(--theme-color-border-default);
color: var(--theme-color-fg-default-shy);
padding: 8px 16px;
font-weight: 500;
font-size: 11px;
@@ -1047,9 +1048,9 @@
}
.projects-item-readonly-btn:hover {
background-color: #333;
border-color: #888;
color: #fff;
background-color: var(--theme-color-bg-5);
border-color: var(--theme-color-fg-muted);
color: var(--theme-color-fg-highlight);
}
/* Runtime detection pending indicator */
@@ -1058,14 +1059,14 @@
}
.projects-item-detecting::after {
content: "";
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 12px;
height: 12px;
border: 2px solid #666;
border-top-color: #d49517;
border: 2px solid var(--theme-color-border-default);
border-top-color: var(--theme-color-notice);
border-radius: 50%;
animation: spin 1s linear infinite;
}

View File

@@ -9,7 +9,7 @@
.tutorial-lesson-item .progress-bar {
box-sizing: border-box;
background-color: #0000007f;
background-color: var(--base-color-black-transparent-50);
width: 100%;
height: 8px;
}
@@ -162,7 +162,7 @@
.tutorial-lesson-item.in-progress.is-feature-highlight,
.tutorial-lesson-item.next-up.is-feature-highligh {
/*always highligt this*/
background-color: #332c7d;
background-color: var(--theme-color-bg-4);
}
.tutorial-lesson-item.not-started.is-feature-highlight button,
@@ -179,16 +179,16 @@
}
.tutorial-lesson-item.completed.is-feature-highlight {
background-color: #1f1b52;
background-color: var(--theme-color-bg-2);
}
.tutorial-lesson-item.completed.is-feature-highlight button {
background-color: #3a3578;
background-color: var(--theme-color-bg-5);
color: var(--base-color-teal-100);
}
.tutorial-lesson-item.in-progress.is-feature-highlight button {
background-color: #5b54a6;
background-color: var(--theme-color-border-strong);
color: var(--base-color-teal-100);
}

View File

@@ -1,32 +1,32 @@
.iconpicker-bg {
background-color: #222;
background-color: var(--theme-color-bg-2);
}
.iconpicker-header {
background-color: #292929;
background-color: var(--theme-color-bg-4);
flex: 0 0 26px;
line-height: 30px;
padding-left: 10px;
}
.iconpicker-search-header {
background-color: #292929;
background-color: var(--theme-color-bg-4);
}
.iconpicker-iconset-header {
background-color: #292929;
background-color: var(--theme-color-bg-4);
flex: 0 0 26px;
line-height: 30px;
padding-left: 10px;
}
.iconpicker-label {
color: #7a7a7a;
color: var(--theme-color-fg-muted);
}
.iconpicker-search-input {
background-color: #333333;
color: #dddddd;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-default-contrast);
border: none;
display: block;
padding: 0 0 0 10px;
@@ -35,7 +35,7 @@
}
.iconpicker-icon {
color: white;
color: var(--theme-color-fg-highlight);
width: 24px;
height: 24px;
padding: 5px;

View File

@@ -1,56 +1,54 @@
.router-pages-page {
padding: 7px;
margin:2px;
margin-right:10px;
margin-left:10px;
background-color: #222;
display:flex;
flex-grow: 1;
padding: 7px;
margin: 2px;
margin-right: 10px;
margin-left: 10px;
background-color: var(--theme-color-bg-2);
display: flex;
flex-grow: 1;
}
.router-pages-page:hover {
background-color: #333;
background-color: var(--theme-color-bg-5);
}
.router-pages-component {
color: #f8f8f8;
color: var(--theme-color-fg-highlight);
}
.router-pages-path {
color: #777;
color: var(--theme-color-fg-muted);
}
.router-pages-icon {
content:url("../assets/icons/page.svg");
width:18px;
opacity:0.7;
align-self:center;
margin-right:7px;
content: url('../assets/icons/page.svg');
width: 18px;
opacity: 0.7;
align-self: center;
margin-right: 7px;
}
.router-pages-icon.start-page {
content:url("../assets/icons/page-filled.svg");
content: url('../assets/icons/page-filled.svg');
}
.router-pages-label {
color: #999;
margin:5px;
margin-right:15px;
color: var(--theme-color-fg-default-shy);
margin: 5px;
margin-right: 15px;
}
.router-pages-actions-icon {
color: #999;
align-self:center;;
visibility: hidden;
padding:8px 15px;
color: var(--theme-color-fg-default-shy);
align-self: center;
visibility: hidden;
padding: 8px 15px;
}
.router-pages-page:hover .router-pages-actions-icon {
visibility: visible;
visibility: visible;
}
.router-pages-actions-icon:hover {
color: #fff;
color: var(--theme-color-fg-highlight);
}

View File

@@ -29,7 +29,7 @@
top: 12px;
height: 10px;
width: 10px;
background-color: #555;
background-color: var(--theme-color-fg-muted);
border-radius: 50%;
}
@@ -58,28 +58,28 @@
}
.property-input-dropdown-arrow {
color: #7b7b7b;
color: var(--theme-color-fg-muted);
}
.property-input-dropdown-arrow:hover {
color: #f8f8f8;
color: var(--theme-color-fg-highlight);
}
.property-input-enum:hover {
background-color: #555555;
background-color: var(--theme-color-border-strong);
cursor: pointer;
}
.property-input-enum {
background-color: #000000;
color: #f8f8f8;
background-color: var(--theme-color-bg-0);
color: var(--theme-color-fg-highlight);
min-height: 30px;
line-height: 30px;
padding-left: 10px;
}
.property-input-enum + .property-input-enum {
border-top: 1px solid #2e2e2e;
border-top: 1px solid var(--theme-color-border-subtle);
}
.property-input-connected {
@@ -92,12 +92,12 @@
}
.property-input-focused {
color: #f8f8f8;
background-color: #000000;
color: var(--theme-color-fg-highlight);
background-color: var(--theme-color-bg-0);
}
.property-input-color {
background-color: #222222;
background-color: var(--theme-color-bg-2);
}
.property-input-color-hidden {
@@ -115,8 +115,8 @@
}
.property-input-textarea:focus {
color: #f8f8f8;
background-color: #000000;
color: var(--theme-color-fg-highlight);
background-color: var(--theme-color-bg-0);
}
.property-input-textarea-bg {
@@ -150,7 +150,7 @@
}
.property-header-bar-label {
color: #f8f8f8;
color: var(--theme-color-fg-highlight);
font: 12px var(--font-family-regular);
padding: 10px 0 10px 10px;
text-overflow: ellipsis;
@@ -169,12 +169,12 @@
}
.property-editor-highlight {
color: #ffa300;
color: var(--theme-color-notice);
}
.property-editor-highlight:hover {
background-color: #ffa300;
color: #f8f8f8;
background-color: var(--theme-color-notice);
color: var(--theme-color-fg-highlight);
}
.property-codeeditor-button {
@@ -195,19 +195,19 @@
}
.property-number-unit-enum {
background-color: #000000;
color: #f8f8f8;
background-color: var(--theme-color-bg-0);
color: var(--theme-color-fg-highlight);
height: 30px;
line-height: 30px;
cursor: default;
}
.property-number-unit-enum + .property-number-unit-enum {
border-top: 1px solid #2e2e2e;
border-top: 1px solid var(--theme-color-border-subtle);
}
.property-number-unit-enum:hover {
background-color: #555555;
background-color: var(--theme-color-border-strong);
}
.property-dimension-fixed-disabled {

View File

@@ -2,23 +2,23 @@
String list
------------------------------------------------------------------- */
.proplist-add-button-container {
position: relative;
padding-right: 0;
}
.proplist-default-label {
color: #666666;
}
position: relative;
padding-right: 0;
}
.proplist-default-label {
color: var(--theme-color-fg-muted);
}
.proplist-item {
cursor: default;
color: #f8f8f8;
position: relative;
line-height: 35px;
cursor: default;
color: var(--theme-color-fg-highlight);
position: relative;
line-height: 35px;
}
.proplist-header {
background-color: #1f1f1f;
margin-bottom:2px;
margin-top:2px;
}
background-color: var(--theme-color-bg-2);
margin-bottom: 2px;
margin-top: 2px;
}

View File

@@ -1,5 +1,5 @@
.queryeditor-popup {
background-color: #333;
background-color: var(--theme-color-bg-5);
padding: 8px;
display: flex;
flex-direction: column;
@@ -13,27 +13,27 @@
.queryeditor-add-filter-group {
margin: 5px;
border: 1px solid #393939;
border: 1px solid var(--theme-color-border-default);
padding: 3px;
}
.queryeditor-add-filter-group-inner {
background-color: #222;
color: #999;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default-shy);
padding: 10px;
text-align: center;
flex-grow: 1;
}
.queryeditor-add-filter-group-inner:hover {
background-color: #333;
color: #ccc;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-default);
}
.queryeditor-group {
display: flex;
flex-direction: column;
border: 1px solid #393939;
border: 1px solid var(--theme-color-border-default);
padding: 5px;
margin-right: 3px;
margin-left: 2px;
@@ -56,21 +56,21 @@
.queryeditor-remove-icon {
margin-left: 10px;
color: #999;
color: var(--theme-color-fg-default-shy);
width: 20px;
height: 20px;
}
.queryeditor-remove-icon:hover {
color: #ccc;
color: var(--theme-color-fg-default);
}
.queryeditor-and-toggle {
color: #999;
background-color: #333;
border-left: 1px solid #999;
border-top: 1px solid #999;
border-bottom: 1px solid #999;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-5);
border-left: 1px solid var(--theme-color-fg-default-shy);
border-top: 1px solid var(--theme-color-fg-default-shy);
border-bottom: 1px solid var(--theme-color-fg-default-shy);
border-radius: 5px 0px 0px 5px;
padding: 3px;
padding-left: 5px;
@@ -78,20 +78,20 @@
}
.queryeditor-and-toggle:hover {
color: #fff;
color: var(--theme-color-fg-highlight);
}
.queryeditor-and-toggle.selected {
background-color: #777;
color: #fff;
background-color: var(--theme-color-fg-muted);
color: var(--theme-color-fg-highlight);
}
.queryeditor-or-toggle {
color: #999;
background-color: #333;
border-right: 1px solid #999;
border-top: 1px solid #999;
border-bottom: 1px solid #999;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-5);
border-right: 1px solid var(--theme-color-fg-default-shy);
border-top: 1px solid var(--theme-color-fg-default-shy);
border-bottom: 1px solid var(--theme-color-fg-default-shy);
border-radius: 0px 5px 5px 0px;
padding: 3px;
padding-right: 5px;
@@ -99,23 +99,23 @@
}
.queryeditor-or-toggle.selected {
background-color: #777;
color: #fff;
background-color: var(--theme-color-fg-muted);
color: var(--theme-color-fg-highlight);
}
.queryeditor-or-toggle:hover {
color: #fff;
color: var(--theme-color-fg-highlight);
}
.queryeditor-add-rule {
display: flex;
color: #999;
color: var(--theme-color-fg-default-shy);
align-items: center;
margin-right: 10px;
}
.queryeditor-add-rule:hover {
color: #ccc;
color: var(--theme-color-fg-default);
}
.queryeditor-add-icon {
@@ -129,19 +129,19 @@
.queryeditor-add-group {
display: flex;
color: #999;
color: var(--theme-color-fg-default-shy);
align-items: center;
}
.queryeditor-add-group:hover {
color: #ccc;
color: var(--theme-color-fg-default);
}
.queryeditor-rule {
display: flex;
padding: 10px;
align-items: flex-start;
background-color: #222;
background-color: var(--theme-color-bg-2);
flex-shrink: 0;
}
@@ -155,11 +155,11 @@
.queryeditor-component {
position: relative;
background-color: #333;
background-color: var(--theme-color-bg-5);
padding: 5px;
padding-left: 10px;
padding-right: 10px;
color: #ccc;
color: var(--theme-color-fg-default);
display: flex;
align-items: flex-start;
min-height: 32px;
@@ -168,7 +168,7 @@
}
.queryeditor-component.hoverable:hover {
color: #fff;
color: var(--theme-color-fg-highlight);
}
.queryeditor-property-inner {
@@ -179,7 +179,7 @@
}
.queryeditor-property-label {
color: #999;
color: var(--theme-color-fg-default-shy);
font-size: 10px;
font-weight: bold;
}
@@ -190,7 +190,7 @@
outline: none;
border: none;
background-color: transparent;
color: #fff;
color: var(--theme-color-fg-highlight);
}
.queryeditor-caret-icon {
@@ -202,8 +202,8 @@
.queryeditor-dropdown {
position: absolute;
top: 47px;
background-color: #181818;
color: #ccc;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
border-radius: 3px;
margin-left: -10px;
z-index: 10;
@@ -218,32 +218,32 @@
}
.queryeditor-dropdown-item:hover {
color: #fff;
background-color: #333;
color: var(--theme-color-fg-highlight);
background-color: var(--theme-color-bg-5);
}
.queryeditor-value-type-icon {
padding: 5px;
padding-left: 10px;
padding-right: 10px;
color: #ccc;
color: var(--theme-color-fg-default);
margin-right: 10px;
}
.queryeditor-value-type-icon:hover {
color: #fff;
color: var(--theme-color-fg-highlight);
}
.queryeditor-header {
display: flex;
font-size: 12px;
color: #999;
color: var(--theme-color-fg-default-shy);
margin-left: 5px;
margin-bottom: 8px;
}
.queryeditor-desc {
color: #999;
color: var(--theme-color-fg-default-shy);
margin-right: 10px;
line-height: 12px;
flex-grow: 0;
@@ -251,16 +251,16 @@
.queryeditor-combinator-label {
padding: 5px;
color: #999;
color: var(--theme-color-fg-default-shy);
}
.queryeditor-combinator-label:hover {
color: #ccc;
color: var(--theme-color-fg-default);
}
.queryeditor-add-button {
background-color: #222;
color: #ccc;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
padding: 3px;
padding-left: 7px;
padding-right: 7px;
@@ -268,14 +268,14 @@
}
.queryeditor-add-button:hover {
background-color: #292929;
color: #fff;
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
}
.queryeditor-sorting {
display: flex;
flex-direction: column;
border: 1px solid #666;
border: 1px solid var(--theme-color-border-strong);
padding: 5px;
}
@@ -286,28 +286,28 @@
}
.queryeditor-span {
color: #999;
color: var(--theme-color-fg-default-shy);
}
.queryeditor-strong {
color: #ccc;
color: var(--theme-color-fg-default);
font-weight: bold;
}
.queryeditor-span-strong {
color: #ccc;
color: var(--theme-color-fg-default);
font-weight: bold;
margin-left: 5px;
margin-right: 5px;
}
.queryeditor-text-rule {
background-color: #222;
background-color: var(--theme-color-bg-2);
padding: 3px;
}
.queryeditor-text-rule:hover {
background-color: #333;
background-color: var(--theme-color-bg-5);
}
.queryeditor-trash-icon {
@@ -327,7 +327,7 @@
.queryeditor-sorting-rules {
display: flex;
flex-direction: column;
border: 1px solid #393939;
border: 1px solid var(--theme-color-border-default);
padding: 5px;
margin-right: 3px;
margin-left: 2px;

View File

@@ -108,8 +108,8 @@
}
.variants-name:hover {
background-color: #333;
color: #fff;
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-highlight);
}
.variants-input-container {

View File

@@ -31,7 +31,7 @@
}
.visual-state-default-transition .label {
background-color: #292929;
background-color: var(--theme-color-bg-4);
padding: 2px 2px 2px 8px;
color: rgba(255, 255, 255, 0.75);
flex-grow: 1;

View File

@@ -48,7 +48,7 @@
.comment-layer-comment.background.fill:not(.selected),
.comment-layer-comment.background.transparent:not(.selected) {
border: 2px solid #00000020;
border: 2px solid rgba(0, 0, 0, 0.125);
}
.comment-layer-comment.background.selected.fill,
@@ -78,15 +78,15 @@
}
.comment-layer-comment.background.has-annotation.changed {
outline: 2px solid #83b8ba;
outline: 2px solid var(--theme-color-primary);
}
.comment-layer-comment.background.has-annotation.deleted {
outline: 2px solid #f57569;
outline: 2px solid var(--theme-color-danger);
}
.comment-layer-comment.background.has-annotation.created {
outline: 2px solid #5bf59e;
outline: 2px solid var(--theme-color-success);
}
.comment-drag-area {

View File

@@ -36,7 +36,7 @@
position: relative;
&.disabled {
color: #ffffff80;
color: rgba(255, 255, 255, 0.5);
}
}
@@ -67,13 +67,13 @@
&.disabled::after {
@extend %overlay;
background-color: #00000060;
background-color: rgba(0, 0, 0, 0.4);
}
}
.noPortsMessage {
padding: 10px;
color: #ccc;
color: var(--theme-color-fg-default-contrast);
}
.listElement {
@@ -96,7 +96,7 @@
}
&.disabled {
color: #ffffff40;
color: rgba(255, 255, 255, 0.25);
.signalIcon {
opacity: 0.3;
@@ -108,7 +108,7 @@
&::before {
@extend %overlay;
background-color: #ffffff33;
background-color: rgba(255, 255, 255, 0.2);
}
}
}
@@ -134,7 +134,7 @@
display: flex;
align-items: center;
position: relative;
color: #ffffffaa;
color: rgba(255, 255, 255, 0.67);
}
.groupLabel {
@@ -148,14 +148,14 @@
.docsPopup {
width: 300px;
background-color: #171717;
background-color: var(--theme-color-bg-2);
padding: 10px;
color: #fff;
color: var(--theme-color-fg-highlight);
}
.docsType {
margin-left: 5px;
color: #72babb;
color: var(--theme-color-primary);
}
.docsHeader {
@@ -163,7 +163,7 @@
}
.docsBody {
color: #ccc;
color: var(--theme-color-fg-default-contrast);
}
.docsEnumValue {
@@ -184,7 +184,7 @@
&,
&::placeholder {
color: rgba(255,255,255,0.6);
color: rgba(255, 255, 255, 0.6);
}
}

View File

@@ -63,7 +63,7 @@ $_scroll-bar-width: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: #575757;
background-color: var(--theme-color-bg-5);
}
}

View File

@@ -6,7 +6,7 @@
display: flex;
justify-content: center;
align-items: center;
border-left: 1px solid #292929;
border-left: 1px solid var(--theme-color-bg-4);
}
.textstyles-edit-style::before {

View File

@@ -1,5 +1,5 @@
.lessonlayerview {
background-color: #1f1f1f;
background-color: var(--theme-color-bg-2);
}
.lesson-layer-progressbar {
@@ -31,7 +31,7 @@
box-sizing: border-box;
width: 230px;
padding: 8px 16px;
color: #f0f7f980;
color: rgba(240, 247, 249, 0.5);
border-left: 1px solid var(--theme-color-bg-1);
flex-shrink: 0;
overflow: hidden;

View File

@@ -347,25 +347,31 @@ export class NodeGraphEditor extends View {
KeyboardHandler.instance.registerCommands(this.keyboardCommands);
// Load icons using webpack require to ensure proper bundling
this.homeIcon = new Image();
this.homeIcon.src = '../assets/icons/core-ui-temp/home--nodegraph.svg';
this.homeIcon.src = require('../../../assets/icons/core-ui-temp/home--nodegraph.svg');
this.homeIcon.onload = () => this.repaint();
this.homeIcon.onerror = (e) => console.error('Failed to load home icon:', e);
this.componentIcon = new Image();
this.componentIcon.src = '../assets/icons/core-ui-temp/component--nodegraph.svg';
this.componentIcon.src = require('../../../assets/icons/core-ui-temp/component--nodegraph.svg');
this.componentIcon.onload = () => this.repaint();
this.componentIcon.onerror = (e) => console.error('Failed to load component icon:', e);
this.aiAssistantInnerIcon = new Image();
this.aiAssistantInnerIcon.src = '../assets/icons/core-ui-temp/aiAssistant--nodegraph-inner.svg';
this.aiAssistantInnerIcon.src = require('../../../assets/icons/core-ui-temp/aiAssistant--nodegraph-inner.svg');
this.aiAssistantInnerIcon.onload = () => this.repaint();
this.aiAssistantInnerIcon.onerror = (e) => console.error('Failed to load AI assistant inner icon:', e);
this.aiAssistantOuterIcon = new Image();
this.aiAssistantOuterIcon.src = '../assets/icons/core-ui-temp/aiAssistant--nodegraph-outer.svg';
this.aiAssistantOuterIcon.src = require('../../../assets/icons/core-ui-temp/aiAssistant--nodegraph-outer.svg');
this.aiAssistantOuterIcon.onload = () => this.repaint();
this.aiAssistantOuterIcon.onerror = (e) => console.error('Failed to load AI assistant outer icon:', e);
this.warningIcon = new Image();
this.warningIcon.src = '../assets/icons/core-ui-temp/warning_triangle.svg';
this.warningIcon.src = require('../../../assets/icons/core-ui-temp/warning_triangle.svg');
this.warningIcon.onload = () => this.repaint();
this.warningIcon.onerror = (e) => console.error('Failed to load warning icon:', e);
SidebarModel.instance.on(
SidebarModelEvent.activeChanged,

View File

@@ -1,13 +1,13 @@
$pin-icon-size: 14px;
.Root {
background-color: #383838;
background-color: var(--theme-color-bg-4);
max-height: 300px;
max-width: 500px;
display: flex;
gap: 5px;
padding: 5px;
border: 1px solid #2a2a2a;
border: 1px solid var(--theme-color-border-default);
}
.ValueContainer {
@@ -32,14 +32,14 @@ $pin-icon-size: 14px;
svg,
path {
color: #a0a0a0 !important;
fill: #a0a0a0 !important;
color: var(--theme-color-fg-default-shy) !important;
fill: var(--theme-color-fg-default-shy) !important;
}
}
:global(.object-key) {
span {
color: #ffffff;
color: var(--theme-color-fg-highlight);
}
}
@@ -50,7 +50,7 @@ $pin-icon-size: 14px;
.ValueInspector {
font-family: monospace;
color: #f6f6f6;
color: var(--theme-color-fg-highlight);
white-space: pre;
cursor: text;
user-select: text;
@@ -71,7 +71,7 @@ $pin-icon-size: 14px;
margin: 0;
padding: 0;
border: none;
background: #b9b9b9;
background: var(--theme-color-fg-default);
flex-shrink: 0;
&.is-pinned {

View File

@@ -642,21 +642,26 @@ export class NodeGraphEditorNode {
ctx.globalAlpha = 1;
}
if (this.icon) {
if (this.icon && this.icon.complete && this.icon.naturalWidth > 0) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const offset = Math.abs(this.iconSize - 18);
ctx.drawImage(
this.icon,
x + horizontalSpacing + this.nodeSize.width - horizontalSpacing - connectionDragAreaWidth - 16 - offset,
y + NodeGraphEditorNode.verticalSpacing + 1 - offset / 2,
this.iconSize,
this.iconSize
);
try {
ctx.drawImage(
this.icon,
x + horizontalSpacing + this.nodeSize.width - horizontalSpacing - connectionDragAreaWidth - 16 - offset,
y + NodeGraphEditorNode.verticalSpacing + 1 - offset / 2,
this.iconSize,
this.iconSize
);
} catch (e) {
// Icon failed to load, skip drawing
console.warn('Failed to draw node icon:', e);
}
if (this.rotatingIcon) {
if (this.rotatingIcon && this.rotatingIcon.complete && this.rotatingIcon.naturalWidth > 0) {
ctx.save();
ctx.translate(
x +
@@ -670,7 +675,13 @@ export class NodeGraphEditorNode {
y + NodeGraphEditorNode.verticalSpacing + 1 - offset / 2 + this.iconSize / 2
);
ctx.rotate(this.iconRotation);
ctx.drawImage(this.rotatingIcon, -this.iconSize / 2, -this.iconSize / 2, this.iconSize, this.iconSize);
try {
ctx.drawImage(this.rotatingIcon, -this.iconSize / 2, -this.iconSize / 2, this.iconSize, this.iconSize);
} catch (e) {
// Rotating icon failed to load, skip drawing
console.warn('Failed to draw rotating icon:', e);
}
ctx.restore();
}

View File

@@ -1,6 +1,7 @@
import '../../editor/src/styles/custom-properties/animations.css';
import '../../editor/src/styles/custom-properties/fonts.css';
import '../../editor/src/styles/custom-properties/colors.css';
import '../../editor/src/styles/custom-properties/spacing.css';
import PopupLayer from '../../editor/src/views/popuplayer';
import Viewer from './src/views/viewer';

View File

@@ -565,7 +565,12 @@ function generateNodeLibrary(nodeRegister) {
},
{
name: 'BYOB Data',
items: ['noodl.byob.QueryData']
items: [
'noodl.byob.QueryData',
'noodl.byob.CreateRecord',
'noodl.byob.UpdateRecord',
'noodl.byob.DeleteRecord'
]
}
]
},

View File

@@ -0,0 +1,540 @@
/**
* BYOB Create Record Node
*
* Creates a new record in a BYOB backend collection.
* Supports Directus system tables and user collections.
*
* @module noodl-runtime
* @since 2.0.0
*/
const ByobUtils = require('./byob-utils');
console.log('[BYOB Create Record] 📦 Module loaded');
var CreateRecordNode = {
name: 'noodl.byob.CreateRecord',
displayNodeName: 'Create Record',
docs: 'https://docs.noodl.net/nodes/data/byob/create-record',
category: 'Data',
color: 'data',
searchTags: ['byob', 'create', 'insert', 'add', 'data', 'database', 'records', 'directus', 'api', 'backend'],
initialize: function () {
console.log('[BYOB Create Record] 🚀 INITIALIZE called');
this._internal.fieldValues = {};
this._internal.loading = false;
this._internal.apiPathMode = 'items';
},
getInspectInfo() {
if (!this._internal.lastResult) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.lastResult };
},
inputs: {
create: {
type: 'signal',
displayName: 'Create',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Create Record] ⚡ CREATE SIGNAL RECEIVED');
this.scheduleCreate();
}
}
},
outputs: {
record: {
type: 'object',
displayName: 'Record',
group: 'Results',
getter: function () {
return this._internal.record;
}
},
recordId: {
type: 'string',
displayName: 'Record ID',
group: 'Results',
getter: function () {
return this._internal.recordId;
}
},
loading: {
type: 'boolean',
displayName: 'Loading',
group: 'Status',
getter: function () {
return this._internal.loading;
}
},
error: {
type: 'object',
displayName: 'Error',
group: 'Status',
getter: function () {
return this._internal.error;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
prototypeExtensions: {
/**
* Store field value (for dynamic field inputs)
*/
_storeFieldValue: function (name, value) {
this._internal.fieldValues[name] = value;
},
/**
* Resolve the backend configuration from metadata
*/
resolveBackend: function () {
const backendId = this._internal.backendId || '_active_';
return ByobUtils.resolveBackend(backendId);
},
scheduleCreate: function () {
console.log('[BYOB Create Record] scheduleCreate called');
if (this._internal.hasScheduledCreate) {
console.log('[BYOB Create Record] Already scheduled, skipping');
return;
}
this._internal.hasScheduledCreate = true;
this.scheduleAfterInputsHaveUpdated(this.doCreate.bind(this));
},
doCreate: function () {
console.log('[BYOB Create Record] doCreate executing');
this._internal.hasScheduledCreate = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Create Record] No backend configured');
this._internal.error = {
message: 'No backend configured. Please add a backend in the Backend Services panel.'
};
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
const collection = this._internal.collection;
const apiPathMode = this._internal.apiPathMode || 'items';
if (!collection) {
console.log('[BYOB Create Record] No collection specified');
this._internal.error = { message: 'Collection is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build URL
const url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode);
if (!url) {
console.log('[BYOB Create Record] Failed to build URL');
this._internal.error = { message: 'Failed to build request URL' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build headers
const headers = ByobUtils.buildHeaders(backendConfig.token);
// Get field schema for value normalization
const fieldSchema = this._internal.fieldSchema || {};
// Collect field values from dynamic inputs and normalize them (especially dates)
const body = {};
for (const [fieldName, value] of Object.entries(this._internal.fieldValues)) {
body[fieldName] = ByobUtils.normalizeValue(value, fieldSchema[fieldName]);
}
console.log('[BYOB Create Record] Request:', {
url,
backendType: backendConfig.type,
fieldCount: Object.keys(body).length
});
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Perform fetch
fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
return response.json();
})
.then((data) => {
console.log('[BYOB Create Record] Response received');
// Directus response format: { data: {...} }
this._internal.record = data.data || data;
this._internal.recordId = this._internal.record?.id || null;
this._internal.error = null;
this._internal.loading = false;
// Store for inspect
this._internal.lastResult = {
url,
collection,
record: this._internal.record
};
// Flag outputs dirty
this.flagOutputDirty('record');
this.flagOutputDirty('recordId');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Create Record] Error:', error);
this._internal.loading = false;
this._internal.record = null;
this._internal.recordId = null;
// Format error for output
if (error.body && error.body.errors) {
this._internal.error = {
status: error.status,
message: error.body.errors.map((e) => e.message).join(', '),
errors: error.body.errors
};
} else {
this._internal.error = {
status: error.status || 0,
message: error.message || error.statusText || 'Network error'
};
}
// Store for inspect
this._internal.lastResult = {
url,
collection,
error: this._internal.error
};
this.flagOutputDirty('record');
this.flagOutputDirty('recordId');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of configuration input names to their setters
const configSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
}
};
// Register configuration inputs
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
// Register dynamic field inputs (field_<fieldname>)
if (name.startsWith('field_')) {
const fieldName = name.substring(6); // Remove 'field_' prefix
return this.registerInput(name, {
set: (value) => {
this._internal.fieldValues[fieldName] = value;
}
});
}
}
}
};
/**
* Update dynamic ports based on node configuration
*/
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get backend services metadata
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
// Backend selection dropdown
const backendEnums = [{ label: 'Active Backend', value: '_active_' }];
backends.forEach((b) => {
backendEnums.push({ label: b.name, value: b.id });
});
ports.push({
name: 'backendId',
displayName: 'Backend',
type: {
name: 'enum',
enums: backendEnums,
allowEditOnly: true
},
default: '_active_',
plug: 'input',
group: 'Backend'
});
// Resolve the selected backend
const selectedBackendId =
parameters.backendId === '_active_' || !parameters.backendId
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Configuration'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
ports.push({
name: 'collection',
displayName: 'Collection',
type: {
name: 'enum',
enums: collectionEnums,
allowEditOnly: true
},
plug: 'input',
group: 'Configuration'
});
// Dynamic field inputs based on selected collection schema
const selectedCollection = allCollections.find((c) => c.name === parameters.collection);
const fields = selectedCollection?.fields || [];
// Read-only fields that should never be editable
const readOnlyFields = ['id', 'date_created', 'date_updated', 'user_created', 'user_updated'];
fields.forEach((field) => {
// Skip read-only fields
if (readOnlyFields.includes(field.name)) {
return;
}
// Skip presentation elements and hidden fields
if (!ByobUtils.shouldShowField(field)) {
return;
}
// Get enhanced field type (with enum support, placeholders, etc.)
const fieldType = ByobUtils.getEnhancedFieldType(field);
ports.push({
name: `field_${field.name}`,
displayName: field.displayName || field.name,
type: fieldType.type,
plug: 'input',
group: 'Fields'
});
});
// NOTE: 'create' signal is defined in static inputs
// Outputs
ports.push({
name: 'record',
displayName: 'Record',
type: 'object',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'recordId',
displayName: 'Record ID',
type: 'string',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'loading',
displayName: 'Loading',
type: 'boolean',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'error',
displayName: 'Error',
type: 'object',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'success',
displayName: 'Success',
type: 'signal',
plug: 'output',
group: 'Events'
});
ports.push({
name: 'failure',
displayName: 'Failure',
type: 'signal',
plug: 'output',
group: 'Events'
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: CreateRecordNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
// Store field schema in node for value normalization
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters || {}, context.editorConnection, graphModel);
node.on('parameterUpdated', function () {
// Update field schema when collection changes
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.CreateRecord', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.CreateRecord')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,422 @@
/**
* BYOB Delete Record Node
*
* Deletes a record from a BYOB backend collection.
* Supports Directus system tables and user collections.
*
* @module noodl-runtime
* @since 2.0.0
*/
const ByobUtils = require('./byob-utils');
console.log('[BYOB Delete Record] 📦 Module loaded');
var DeleteRecordNode = {
name: 'noodl.byob.DeleteRecord',
displayNodeName: 'Delete Record',
docs: 'https://docs.noodl.net/nodes/data/byob/delete-record',
category: 'Data',
color: 'data',
searchTags: ['byob', 'delete', 'remove', 'data', 'database', 'records', 'directus', 'api', 'backend'],
initialize: function () {
console.log('[BYOB Delete Record] 🚀 INITIALIZE called');
this._internal.loading = false;
this._internal.apiPathMode = 'items';
},
getInspectInfo() {
if (!this._internal.lastResult) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.lastResult };
},
inputs: {
delete: {
type: 'signal',
displayName: 'Delete',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Delete Record] ⚡ DELETE SIGNAL RECEIVED');
this.scheduleDelete();
}
}
},
outputs: {
loading: {
type: 'boolean',
displayName: 'Loading',
group: 'Status',
getter: function () {
return this._internal.loading;
}
},
error: {
type: 'object',
displayName: 'Error',
group: 'Status',
getter: function () {
return this._internal.error;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
prototypeExtensions: {
/**
* Resolve the backend configuration from metadata
*/
resolveBackend: function () {
const backendId = this._internal.backendId || '_active_';
return ByobUtils.resolveBackend(backendId);
},
scheduleDelete: function () {
console.log('[BYOB Delete Record] scheduleDelete called');
if (this._internal.hasScheduledDelete) {
console.log('[BYOB Delete Record] Already scheduled, skipping');
return;
}
this._internal.hasScheduledDelete = true;
this.scheduleAfterInputsHaveUpdated(this.doDelete.bind(this));
},
doDelete: function () {
console.log('[BYOB Delete Record] doDelete executing');
this._internal.hasScheduledDelete = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Delete Record] No backend configured');
this._internal.error = {
message: 'No backend configured. Please add a backend in the Backend Services panel.'
};
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
const collection = this._internal.collection;
const recordId = this._internal.recordId;
const apiPathMode = this._internal.apiPathMode || 'items';
if (!collection) {
console.log('[BYOB Delete Record] No collection specified');
this._internal.error = { message: 'Collection is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
if (!recordId) {
console.log('[BYOB Delete Record] No record ID specified');
this._internal.error = { message: 'Record ID is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build URL with record ID
const url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode, recordId);
if (!url) {
console.log('[BYOB Delete Record] Failed to build URL');
this._internal.error = { message: 'Failed to build request URL' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build headers
const headers = ByobUtils.buildHeaders(backendConfig.token);
console.log('[BYOB Delete Record] Request:', {
url,
backendType: backendConfig.type,
recordId
});
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Perform fetch
fetch(url, {
method: 'DELETE',
headers: headers
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
// DELETE may return 204 No Content or empty response
if (response.status === 204) {
return null;
}
return response.json().catch(() => null);
})
.then(() => {
console.log('[BYOB Delete Record] Delete successful');
this._internal.error = null;
this._internal.loading = false;
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
deleted: true
};
// Flag outputs dirty
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Delete Record] Error:', error);
this._internal.loading = false;
// Format error for output
if (error.body && error.body.errors) {
this._internal.error = {
status: error.status,
message: error.body.errors.map((e) => e.message).join(', '),
errors: error.body.errors
};
} else {
this._internal.error = {
status: error.status || 0,
message: error.message || error.statusText || 'Network error'
};
}
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
error: this._internal.error
};
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of configuration input names to their setters
const configSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
recordId: (value) => {
this._internal.recordId = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
}
};
// Register configuration inputs
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
}
}
};
/**
* Update dynamic ports based on node configuration
*/
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get backend services metadata
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
// Backend selection dropdown
const backendEnums = [{ label: 'Active Backend', value: '_active_' }];
backends.forEach((b) => {
backendEnums.push({ label: b.name, value: b.id });
});
ports.push({
name: 'backendId',
displayName: 'Backend',
type: {
name: 'enum',
enums: backendEnums,
allowEditOnly: true
},
default: '_active_',
plug: 'input',
group: 'Backend'
});
// Resolve the selected backend
const selectedBackendId =
parameters.backendId === '_active_' || !parameters.backendId
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Configuration'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
ports.push({
name: 'collection',
displayName: 'Collection',
type: {
name: 'enum',
enums: collectionEnums,
allowEditOnly: true
},
plug: 'input',
group: 'Configuration'
});
// Record ID input (required for delete)
ports.push({
name: 'recordId',
displayName: 'Record ID',
type: 'string',
plug: 'input',
group: 'Configuration'
});
// NOTE: 'delete' signal is defined in static inputs
// Outputs
ports.push({
name: 'loading',
displayName: 'Loading',
type: 'boolean',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'error',
displayName: 'Error',
type: 'object',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'success',
displayName: 'Success',
type: 'signal',
plug: 'output',
group: 'Events'
});
ports.push({
name: 'failure',
displayName: 'Failure',
type: 'signal',
plug: 'output',
group: 'Events'
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: DeleteRecordNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
updatePorts(node.id, node.parameters || {}, context.editorConnection, graphModel);
node.on('parameterUpdated', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.DeleteRecord', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.DeleteRecord')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -11,6 +11,7 @@
*/
const NoodlRuntime = require('../../../../noodl-runtime');
const ByobUtils = require('./byob-utils');
console.log('[BYOB Query Data] 📦 Module loaded');
@@ -29,6 +30,7 @@ var QueryDataNode = {
this._internal.records = [];
this._internal.totalCount = 0;
this._internal.inspectData = null;
this._internal.apiPathMode = 'items'; // 'items' or 'system'
},
getInspectInfo() {
@@ -133,38 +135,8 @@ var QueryDataNode = {
* Returns { url, token, type } or null if not found
*/
resolveBackend: function () {
// Get metadata from NoodlRuntime (same pattern as cloudstore.js uses for cloudservices)
const backendServices = NoodlRuntime.instance.getMetaData('backendServices');
if (!backendServices || !backendServices.backends) {
console.log('[BYOB Query Data] No backend services metadata found');
console.log('[BYOB Query Data] Available metadata keys:', Object.keys(NoodlRuntime.instance.metadata || {}));
return null;
}
const backendId = this._internal.backendId || '_active_';
const backends = backendServices.backends || [];
// Resolve the backend
let backend;
if (backendId === '_active_') {
backend = backends.find((b) => b.id === backendServices.activeBackendId);
} else {
backend = backends.find((b) => b.id === backendId);
}
if (!backend) {
console.log('[BYOB Query Data] Backend not found:', backendId);
return null;
}
// Return backend config (using publicToken for runtime, NOT adminToken)
return {
url: backend.url,
token: backend.auth?.publicToken || '',
type: backend.type,
endpoints: backend.endpoints
};
return ByobUtils.resolveBackend(backendId);
},
scheduleFetch: function () {
@@ -178,17 +150,19 @@ var QueryDataNode = {
},
buildUrl: function (backendConfig) {
const baseUrl = backendConfig?.url || '';
const collection = this._internal.collection || '';
const apiPathMode = this._internal.apiPathMode || 'items';
if (!baseUrl || !collection) {
if (!backendConfig?.url || !collection) {
return null;
}
// TODO: Use backendConfig.type for backend-specific URL formats (Supabase, Appwrite, etc.)
// Currently only Directus format is implemented
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
let url = `${cleanBaseUrl}/items/${collection}`;
// Build base URL with system table support
let url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode);
if (!url) {
return null;
}
// Build query parameters
const params = new URLSearchParams();
@@ -242,16 +216,7 @@ var QueryDataNode = {
},
buildHeaders: function (backendConfig) {
const headers = {
'Content-Type': 'application/json'
};
const authToken = backendConfig?.token;
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
return ByobUtils.buildHeaders(backendConfig?.token);
},
doFetch: function () {
@@ -404,6 +369,9 @@ var QueryDataNode = {
collection: (value) => {
this._internal.collection = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
},
filter: (value) => {
this._internal.filter = value;
},
@@ -612,11 +580,34 @@ function updatePorts(nodeId, parameters, editorConnection, graphModel) {
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const collections = selectedBackend?.schema?.collections || [];
const allCollections = selectedBackend?.schema?.collections || [];
// Collection dropdown (populated from schema)
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Query'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
collections.forEach((c) => {
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
@@ -632,8 +623,8 @@ function updatePorts(nodeId, parameters, editorConnection, graphModel) {
group: 'Query'
});
// Sort field dropdown (populated from selected collection's fields)
const selectedCollection = collections.find((c) => c.name === parameters.collection);
// Get selected collection for field-based dropdowns
const selectedCollection = allCollections.find((c) => c.name === parameters.collection);
// Filter port - uses Visual Filter Builder when schema is available
ports.push({

View File

@@ -0,0 +1,537 @@
/**
* BYOB Update Record Node
*
* Updates an existing record in a BYOB backend collection.
* Supports Directus system tables and user collections.
*
* @module noodl-runtime
* @since 2.0.0
*/
const ByobUtils = require('./byob-utils');
console.log('[BYOB Update Record] 📦 Module loaded');
var UpdateRecordNode = {
name: 'noodl.byob.UpdateRecord',
displayNodeName: 'Update Record',
docs: 'https://docs.noodl.net/nodes/data/byob/update-record',
category: 'Data',
color: 'data',
searchTags: ['byob', 'update', 'edit', 'modify', 'data', 'database', 'records', 'directus', 'api', 'backend'],
initialize: function () {
console.log('[BYOB Update Record] 🚀 INITIALIZE called');
this._internal.fieldValues = {};
this._internal.loading = false;
this._internal.apiPathMode = 'items';
},
getInspectInfo() {
if (!this._internal.lastResult) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.lastResult };
},
inputs: {
update: {
type: 'signal',
displayName: 'Update',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Update Record] ⚡ UPDATE SIGNAL RECEIVED');
this.scheduleUpdate();
}
}
},
outputs: {
record: {
type: 'object',
displayName: 'Record',
group: 'Results',
getter: function () {
return this._internal.record;
}
},
loading: {
type: 'boolean',
displayName: 'Loading',
group: 'Status',
getter: function () {
return this._internal.loading;
}
},
error: {
type: 'object',
displayName: 'Error',
group: 'Status',
getter: function () {
return this._internal.error;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
prototypeExtensions: {
/**
* Resolve the backend configuration from metadata
*/
resolveBackend: function () {
const backendId = this._internal.backendId || '_active_';
return ByobUtils.resolveBackend(backendId);
},
scheduleUpdate: function () {
console.log('[BYOB Update Record] scheduleUpdate called');
if (this._internal.hasScheduledUpdate) {
console.log('[BYOB Update Record] Already scheduled, skipping');
return;
}
this._internal.hasScheduledUpdate = true;
this.scheduleAfterInputsHaveUpdated(this.doUpdate.bind(this));
},
doUpdate: function () {
console.log('[BYOB Update Record] doUpdate executing');
this._internal.hasScheduledUpdate = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Update Record] No backend configured');
this._internal.error = {
message: 'No backend configured. Please add a backend in the Backend Services panel.'
};
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
const collection = this._internal.collection;
const recordId = this._internal.recordId;
const apiPathMode = this._internal.apiPathMode || 'items';
if (!collection) {
console.log('[BYOB Update Record] No collection specified');
this._internal.error = { message: 'Collection is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
if (!recordId) {
console.log('[BYOB Update Record] No record ID specified');
this._internal.error = { message: 'Record ID is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build URL with record ID
const url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode, recordId);
if (!url) {
console.log('[BYOB Update Record] Failed to build URL');
this._internal.error = { message: 'Failed to build request URL' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build headers
const headers = ByobUtils.buildHeaders(backendConfig.token);
// Get field schema for value normalization
const fieldSchema = this._internal.fieldSchema || {};
// Collect field values from dynamic inputs and normalize them (especially dates)
const body = {};
for (const [fieldName, value] of Object.entries(this._internal.fieldValues)) {
body[fieldName] = ByobUtils.normalizeValue(value, fieldSchema[fieldName]);
}
console.log('[BYOB Update Record] Request:', {
url,
backendType: backendConfig.type,
recordId,
fieldCount: Object.keys(body).length
});
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Perform fetch
fetch(url, {
method: 'PATCH',
headers: headers,
body: JSON.stringify(body)
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
return response.json();
})
.then((data) => {
console.log('[BYOB Update Record] Response received');
// Directus response format: { data: {...} }
this._internal.record = data.data || data;
this._internal.error = null;
this._internal.loading = false;
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
record: this._internal.record
};
// Flag outputs dirty
this.flagOutputDirty('record');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Update Record] Error:', error);
this._internal.loading = false;
this._internal.record = null;
// Format error for output
if (error.body && error.body.errors) {
this._internal.error = {
status: error.status,
message: error.body.errors.map((e) => e.message).join(', '),
errors: error.body.errors
};
} else {
this._internal.error = {
status: error.status || 0,
message: error.message || error.statusText || 'Network error'
};
}
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
error: this._internal.error
};
this.flagOutputDirty('record');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of configuration input names to their setters
const configSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
recordId: (value) => {
this._internal.recordId = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
}
};
// Register configuration inputs
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
// Register dynamic field inputs (field_<fieldname>)
if (name.startsWith('field_')) {
const fieldName = name.substring(6); // Remove 'field_' prefix
return this.registerInput(name, {
set: (value) => {
this._internal.fieldValues[fieldName] = value;
}
});
}
}
}
};
/**
* Update dynamic ports based on node configuration
*/
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get backend services metadata
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
// Backend selection dropdown
const backendEnums = [{ label: 'Active Backend', value: '_active_' }];
backends.forEach((b) => {
backendEnums.push({ label: b.name, value: b.id });
});
ports.push({
name: 'backendId',
displayName: 'Backend',
type: {
name: 'enum',
enums: backendEnums,
allowEditOnly: true
},
default: '_active_',
plug: 'input',
group: 'Backend'
});
// Resolve the selected backend
const selectedBackendId =
parameters.backendId === '_active_' || !parameters.backendId
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Configuration'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
ports.push({
name: 'collection',
displayName: 'Collection',
type: {
name: 'enum',
enums: collectionEnums,
allowEditOnly: true
},
plug: 'input',
group: 'Configuration'
});
// Record ID input (required for update)
ports.push({
name: 'recordId',
displayName: 'Record ID',
type: 'string',
plug: 'input',
group: 'Configuration'
});
// Dynamic field inputs based on selected collection schema
const selectedCollection = allCollections.find((c) => c.name === parameters.collection);
const fields = selectedCollection?.fields || [];
// Read-only fields that should never be editable
const readOnlyFields = ['id', 'date_created', 'user_created'];
fields.forEach((field) => {
// Skip read-only fields
if (readOnlyFields.includes(field.name)) {
return;
}
// Skip presentation elements and hidden fields
if (!ByobUtils.shouldShowField(field)) {
return;
}
// Get enhanced field type (with enum support, placeholders, etc.)
const fieldType = ByobUtils.getEnhancedFieldType(field);
ports.push({
name: `field_${field.name}`,
displayName: field.displayName || field.name,
type: fieldType.type,
plug: 'input',
group: 'Fields'
});
});
// NOTE: 'update' signal is defined in static inputs
// Outputs
ports.push({
name: 'record',
displayName: 'Record',
type: 'object',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'loading',
displayName: 'Loading',
type: 'boolean',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'error',
displayName: 'Error',
type: 'object',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'success',
displayName: 'Success',
type: 'signal',
plug: 'output',
group: 'Events'
});
ports.push({
name: 'failure',
displayName: 'Failure',
type: 'signal',
plug: 'output',
group: 'Events'
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: UpdateRecordNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
// Store field schema in node for value normalization
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters || {}, context.editorConnection, graphModel);
node.on('parameterUpdated', function () {
// Update field schema when collection changes
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.UpdateRecord', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.UpdateRecord')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,291 @@
/**
* BYOB Utilities
*
* Shared utilities for all BYOB (Bring Your Own Backend) data nodes.
* Provides common functionality for backend resolution, URL building,
* and Directus system table handling.
*
* @module noodl-runtime
* @since 2.0.0
*/
const NoodlRuntime = require('../../../../noodl-runtime');
/**
* Directus system collection endpoint mappings
* Maps internal collection names to their API endpoints
*/
const SYSTEM_ENDPOINTS = {
directus_users: 'users',
directus_roles: 'roles',
directus_files: 'files',
directus_folders: 'folders',
directus_activity: 'activity',
directus_permissions: 'permissions',
directus_settings: 'settings',
directus_webhooks: 'webhooks',
directus_flows: 'flows',
directus_operations: 'operations',
directus_panels: 'panels',
directus_dashboards: 'dashboards',
directus_notifications: 'notifications',
directus_shares: 'shares',
directus_presets: 'presets',
directus_revisions: 'revisions',
directus_translations: 'translations'
};
/**
* Resolve backend configuration from metadata
* @param {string} backendId - Backend ID or '_active_' for active backend
* @returns {Object|null} Backend config with { url, token, type, endpoints } or null
*/
function resolveBackend(backendId) {
const backendServices = NoodlRuntime.instance.getMetaData('backendServices');
if (!backendServices || !backendServices.backends) {
console.log('[BYOB Utils] No backend services metadata found');
return null;
}
const backends = backendServices.backends || [];
let backend;
if (backendId === '_active_') {
backend = backends.find((b) => b.id === backendServices.activeBackendId);
} else {
backend = backends.find((b) => b.id === backendId);
}
if (!backend) {
console.log('[BYOB Utils] Backend not found:', backendId);
return null;
}
return {
url: backend.url,
token: backend.auth?.publicToken || '',
type: backend.type,
endpoints: backend.endpoints
};
}
/**
* Build endpoint path for a collection
* @param {string} collection - Collection name
* @param {string} apiPathMode - 'items' or 'system'
* @returns {string} Endpoint path (e.g., 'items/posts' or 'users')
*/
function buildEndpoint(collection, apiPathMode) {
if (apiPathMode === 'system' && SYSTEM_ENDPOINTS[collection]) {
return SYSTEM_ENDPOINTS[collection];
}
return `items/${collection}`;
}
/**
* Build HTTP headers for API requests
* @param {string} token - Auth token (optional)
* @returns {Object} Headers object
*/
function buildHeaders(token) {
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Check if a collection is a Directus system collection
* @param {string} collection - Collection name
* @returns {boolean} True if it's a system collection
*/
function isSystemCollection(collection) {
return collection && collection.startsWith('directus_');
}
/**
* Auto-detect the appropriate API path mode for a collection
* @param {string} collection - Collection name
* @returns {string} 'system' or 'items'
*/
function detectApiPathMode(collection) {
return isSystemCollection(collection) ? 'system' : 'items';
}
/**
* Build full URL for an API request
* @param {Object} backendConfig - Backend configuration
* @param {string} collection - Collection name
* @param {string} apiPathMode - 'items' or 'system'
* @param {string} recordId - Optional record ID for specific record
* @returns {string|null} Full URL or null if invalid
*/
function buildUrl(backendConfig, collection, apiPathMode, recordId = null) {
const baseUrl = backendConfig?.url;
if (!baseUrl || !collection) {
return null;
}
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
const endpoint = buildEndpoint(collection, apiPathMode);
let url = `${cleanBaseUrl}/${endpoint}`;
if (recordId) {
url += `/${recordId}`;
}
return url;
}
/**
* Normalize a value for API submission
* Handles date conversion to ISO 8601 format
* @param {*} value - The value to normalize
* @param {Object} fieldSchema - Field schema information
* @returns {*} Normalized value
*/
function normalizeValue(value, fieldSchema) {
if (value === null || value === undefined) {
return value;
}
// Handle date/datetime fields - convert to ISO 8601
if (
fieldSchema &&
(fieldSchema.type === 'dateTime' ||
fieldSchema.type === 'date' ||
fieldSchema.type === 'timestamp' ||
fieldSchema.type === 'time')
) {
// If already an ISO string, return as-is
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return value;
}
// Try to parse as date
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toISOString();
}
} catch (e) {
console.warn('[BYOB Utils] Failed to convert date value:', value);
}
}
return value;
}
/**
* Filter collections based on API path mode
* @param {Array} collections - All collections from schema
* @param {string} apiPathMode - 'items' or 'system'
* @returns {Array} Filtered collections
*/
function filterCollectionsByMode(collections, apiPathMode) {
if (!apiPathMode || apiPathMode === 'items') {
// Items mode: exclude system tables
return collections.filter((c) => !isSystemCollection(c.name));
} else {
// System mode: only system tables
return collections.filter((c) => isSystemCollection(c.name));
}
}
/**
* Check if a field should be shown in the property editor
* Filters out presentation elements, hidden fields, and readonly meta fields
* @param {Object} field - Field schema
* @returns {boolean} True if field should be shown
*/
function shouldShowField(field) {
if (!field || !field.name) return false;
// Skip presentation interfaces (dividers, notices, etc.)
if (field.meta?.interface && field.meta.interface.startsWith('presentation-')) {
return false;
}
// Skip explicitly hidden fields
if (field.meta?.hidden === true) {
return false;
}
return true;
}
/**
* Get enhanced field type for property editor
* Maps Directus field types to Noodl port types with additional metadata
* @param {Object} field - Field schema
* @returns {Object} Port type definition { type, options, placeholder }
*/
function getEnhancedFieldType(field) {
const result = {
type: 'string',
options: null,
placeholder: null
};
// Check for enum/select fields
if (field.meta?.interface === 'select-dropdown' || field.meta?.interface === 'select-dropdown-m2o') {
const choices = field.meta?.options?.choices;
if (choices && Array.isArray(choices)) {
result.type = {
name: 'enum',
enums: choices.map((choice) => ({
label: choice.text || choice.value,
value: choice.value
})),
allowEditOnly: false
};
return result;
}
}
// Map basic field types
if (field.type === 'integer' || field.type === 'bigInteger' || field.type === 'float' || field.type === 'decimal') {
result.type = 'number';
} else if (field.type === 'boolean') {
result.type = 'boolean';
} else if (field.type === 'json' || field.type === 'array') {
result.type = 'object';
} else if (field.type === 'dateTime' || field.type === 'timestamp') {
result.type = 'string';
result.placeholder = 'YYYY-MM-DDTHH:mm:ss.sssZ';
} else if (field.type === 'date') {
result.type = 'string';
result.placeholder = 'YYYY-MM-DD';
} else if (field.type === 'time') {
result.type = 'string';
result.placeholder = 'HH:mm:ss';
} else if (field.type === 'uuid') {
result.type = 'string';
result.placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
} else {
result.type = 'string';
}
return result;
}
module.exports = {
SYSTEM_ENDPOINTS,
resolveBackend,
buildEndpoint,
buildHeaders,
buildUrl,
isSystemCollection,
detectApiPathMode,
normalizeValue,
filterCollectionsByMode,
shouldShowField,
getEnhancedFieldType
};

View File

@@ -65,6 +65,9 @@ export default function registerNodes(noodlRuntime) {
// BYOB (Bring Your Own Backend) data nodes
require('@noodl/runtime/src/nodes/std-library/data/byob-query-data'),
require('@noodl/runtime/src/nodes/std-library/data/byob-create-record'),
require('@noodl/runtime/src/nodes/std-library/data/byob-update-record'),
require('@noodl/runtime/src/nodes/std-library/data/byob-delete-record'),
//require('./nodes/std-library/variables/number'), // moved to runtime
//require('./nodes/std-library/variables/string'),