mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
initial ux ui improvements and revised dashboard
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
};
|
||||
124
packages/noodl-core-ui/src/components/layout/TabBar/TabBar.tsx
Normal file
124
packages/noodl-core-ui/src/components/layout/TabBar/TabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TabBar } from './TabBar';
|
||||
export type { TabBarItem, TabBarProps } from './TabBar';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
height: 100%;
|
||||
cursor: nwse-resize;
|
||||
|
||||
fill: #7a7a7a;
|
||||
fill: var(--theme-color-fg-muted);
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -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**
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.Tooltip {
|
||||
// Tooltip wrapper styling if needed
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GitStatusBadge, GitStatusType } from './GitStatusBadge';
|
||||
export type { GitStatusBadgeProps, GitStatusDetails } from './GitStatusBadge';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LauncherFooter } from './LauncherFooter';
|
||||
export type { LauncherFooterProps } from './LauncherFooter';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LauncherHeader } from './LauncherHeader';
|
||||
export type { LauncherHeaderProps } from './LauncherHeader';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]]
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,7 @@
|
||||
.Root {
|
||||
// Container styling if needed
|
||||
}
|
||||
|
||||
.Button {
|
||||
// Button styling if needed
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ViewModeToggle, ViewMode } from './ViewModeToggle';
|
||||
export type { ViewModeToggleProps } from './ViewModeToggle';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
113
packages/noodl-core-ui/src/styles/custom-properties/spacing.css
Normal file
113
packages/noodl-core-ui/src/styles/custom-properties/spacing.css
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ $_scroll-bar-width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #575757;
|
||||
background-color: var(--theme-color-bg-5);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
291
packages/noodl-runtime/src/nodes/std-library/data/byob-utils.js
Normal file
291
packages/noodl-runtime/src/nodes/std-library/data/byob-utils.js
Normal 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
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user