Fixed visual issues with new dashboard and added folder attribution

This commit is contained in:
Richard Osborne
2025-12-31 21:40:47 +01:00
parent 73b5a42122
commit 2e46ab7ea7
41 changed files with 6481 additions and 1619 deletions

View File

@@ -6,25 +6,67 @@
*/
import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { filesystem } from '@noodl/platform';
import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { LocalProjectsModel } from '../../utils/LocalProjectsModel';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
export interface ProjectsPageProps extends IRouteProps {
from: TSFixme;
}
/**
* Map LocalProjectsModel ProjectItem to LauncherProjectData format
*/
function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
return {
id: project.id,
title: project.name || 'Untitled',
localPath: project.retainedProjectDirectory,
lastOpened: new Date(project.latestAccessed).toISOString(),
imageSrc: project.thumbURI || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
cloudSyncMeta: {
type: CloudSyncType.None // TODO: Detect git repos in future
}
// Git-related fields will be populated in future tasks
};
}
export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// Fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initial load
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
const projects = LocalProjectsModel.instance.getProjects();
setRealProjects(projects.map(mapProjectToLauncherData));
};
loadProjects();
}, []);
// Subscribe to project list changes
useEventListener(LocalProjectsModel.instance, 'myProjectsChanged', () => {
console.log('🔔 Projects list changed, updating dashboard');
const projects = LocalProjectsModel.instance.getProjects();
setRealProjects(projects.map(mapProjectToLauncherData));
});
const handleCreateProject = useCallback(async () => {
try {
const direntry = await filesystem.openDialog({
@@ -196,6 +238,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
return (
<Launcher
projects={realProjects}
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onLaunchProject={handleLaunchProject}

View File

@@ -0,0 +1,340 @@
/**
* ProjectOrganizationService
*
* Manages project organization through folders and tags.
* Data is stored client-side in electron-store and keyed by project path.
*/
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
// ============================================================================
// Types
// ============================================================================
export interface Folder {
id: string;
name: string;
parentId: string | null; // null = root level
order: number;
createdAt: string;
}
export interface Tag {
id: string;
name: string;
color: string; // hex color
createdAt: string;
}
export interface ProjectMeta {
folderId: string | null;
tagIds: string[];
customName?: string;
notes?: string;
}
export interface ProjectOrganizationData {
version: 1;
folders: Folder[];
tags: Tag[];
projectMeta: Record<string, ProjectMeta>; // keyed by project path
}
// Tag color palette
export const TAG_COLORS = [
'#EF4444', // Red
'#F97316', // Orange
'#EAB308', // Yellow
'#22C55E', // Green
'#06B6D4', // Cyan
'#3B82F6', // Blue
'#8B5CF6', // Purple
'#EC4899', // Pink
'#6B7280' // Gray
];
// ============================================================================
// Service
// ============================================================================
export class ProjectOrganizationService extends EventDispatcher {
private static _instance: ProjectOrganizationService;
private data: ProjectOrganizationData;
private storageKey = 'projectOrganization';
private constructor() {
super();
this.data = this.loadData();
}
static get instance(): ProjectOrganizationService {
if (!ProjectOrganizationService._instance) {
ProjectOrganizationService._instance = new ProjectOrganizationService();
}
return ProjectOrganizationService._instance;
}
// ============================================================================
// Storage
// ============================================================================
private loadData(): ProjectOrganizationData {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('[ProjectOrganizationService] Failed to load data:', error);
}
// Return default empty structure
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
}
private saveData(): void {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
this.notifyListeners('dataChanged', this.data);
} catch (error) {
console.error('[ProjectOrganizationService] Failed to save data:', error);
}
}
// ============================================================================
// Folder Operations
// ============================================================================
createFolder(name: string, parentId?: string | null): Folder {
const folder: Folder = {
id: this.generateId('folder'),
name,
parentId: parentId || null,
order: this.data.folders.length,
createdAt: new Date().toISOString()
};
this.data.folders.push(folder);
this.saveData();
this.notifyListeners('folderCreated', folder);
return folder;
}
renameFolder(id: string, name: string): void {
const folder = this.data.folders.find((f) => f.id === id);
if (!folder) {
console.warn('[ProjectOrganizationService] Folder not found:', id);
return;
}
folder.name = name;
this.saveData();
this.notifyListeners('folderRenamed', { id, name });
}
deleteFolder(id: string): void {
// Remove folder
this.data.folders = this.data.folders.filter((f) => f.id !== id);
// Remove child folders
this.data.folders = this.data.folders.filter((f) => f.parentId !== id);
// Move projects in deleted folder to uncategorized
Object.keys(this.data.projectMeta).forEach((projectPath) => {
if (this.data.projectMeta[projectPath].folderId === id) {
this.data.projectMeta[projectPath].folderId = null;
}
});
this.saveData();
this.notifyListeners('folderDeleted', id);
}
reorderFolder(id: string, newOrder: number): void {
const folder = this.data.folders.find((f) => f.id === id);
if (!folder) return;
folder.order = newOrder;
this.saveData();
this.notifyListeners('folderReordered', { id, order: newOrder });
}
getFolders(): Folder[] {
return [...this.data.folders].sort((a, b) => a.order - b.order);
}
getFolder(id: string): Folder | undefined {
return this.data.folders.find((f) => f.id === id);
}
// ============================================================================
// Tag Operations
// ============================================================================
createTag(name: string, color?: string): Tag {
const tag: Tag = {
id: this.generateId('tag'),
name,
color: color || this.getNextTagColor(),
createdAt: new Date().toISOString()
};
this.data.tags.push(tag);
this.saveData();
this.notifyListeners('tagCreated', tag);
return tag;
}
renameTag(id: string, name: string): void {
const tag = this.data.tags.find((t) => t.id === id);
if (!tag) {
console.warn('[ProjectOrganizationService] Tag not found:', id);
return;
}
tag.name = name;
this.saveData();
this.notifyListeners('tagRenamed', { id, name });
}
changeTagColor(id: string, color: string): void {
const tag = this.data.tags.find((t) => t.id === id);
if (!tag) return;
tag.color = color;
this.saveData();
this.notifyListeners('tagColorChanged', { id, color });
}
deleteTag(id: string): void {
// Remove tag
this.data.tags = this.data.tags.filter((t) => t.id !== id);
// Remove tag from all projects
Object.keys(this.data.projectMeta).forEach((projectPath) => {
const meta = this.data.projectMeta[projectPath];
meta.tagIds = meta.tagIds.filter((tagId) => tagId !== id);
});
this.saveData();
this.notifyListeners('tagDeleted', id);
}
getTags(): Tag[] {
return [...this.data.tags];
}
getTag(id: string): Tag | undefined {
return this.data.tags.find((t) => t.id === id);
}
private getNextTagColor(): string {
const usedColors = this.data.tags.map((t) => t.color);
const availableColors = TAG_COLORS.filter((c) => !usedColors.includes(c));
return availableColors.length > 0 ? availableColors[0] : TAG_COLORS[0];
}
// ============================================================================
// Project Organization
// ============================================================================
moveProjectToFolder(projectPath: string, folderId: string | null): void {
if (!this.data.projectMeta[projectPath]) {
this.data.projectMeta[projectPath] = {
folderId: null,
tagIds: []
};
}
this.data.projectMeta[projectPath].folderId = folderId;
this.saveData();
this.notifyListeners('projectMoved', { projectPath, folderId });
}
addTagToProject(projectPath: string, tagId: string): void {
if (!this.data.projectMeta[projectPath]) {
this.data.projectMeta[projectPath] = {
folderId: null,
tagIds: []
};
}
const meta = this.data.projectMeta[projectPath];
if (!meta.tagIds.includes(tagId)) {
meta.tagIds.push(tagId);
this.saveData();
this.notifyListeners('projectTagAdded', { projectPath, tagId });
}
}
removeTagFromProject(projectPath: string, tagId: string): void {
const meta = this.data.projectMeta[projectPath];
if (!meta) return;
meta.tagIds = meta.tagIds.filter((id) => id !== tagId);
this.saveData();
this.notifyListeners('projectTagRemoved', { projectPath, tagId });
}
getProjectMeta(projectPath: string): ProjectMeta | null {
return this.data.projectMeta[projectPath] || null;
}
// ============================================================================
// Queries
// ============================================================================
getProjectsInFolder(folderId: string | null): string[] {
return Object.keys(this.data.projectMeta).filter((projectPath) => {
const meta = this.data.projectMeta[projectPath];
return meta.folderId === folderId;
});
}
getProjectsWithTag(tagId: string): string[] {
return Object.keys(this.data.projectMeta).filter((projectPath) => {
const meta = this.data.projectMeta[projectPath];
return meta.tagIds.includes(tagId);
});
}
getProjectCountInFolder(folderId: string | null): number {
return this.getProjectsInFolder(folderId).length;
}
// ============================================================================
// Utilities
// ============================================================================
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// For debugging
exportData(): ProjectOrganizationData {
return JSON.parse(JSON.stringify(this.data));
}
importData(data: ProjectOrganizationData): void {
this.data = data;
this.saveData();
}
clearAll(): void {
this.data = {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
this.saveData();
this.notifyListeners('dataCleared', null);
}
}

View File

@@ -219,13 +219,13 @@ TODO: review this icon
.components-panel-item-selected {
line-height: 36px;
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
opacity: 1;
}
.components-panel-item-selected .caret-icon-container {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
}
.components-panel-item-selected .components-panel-item-selected {
@@ -234,13 +234,13 @@ TODO: review this icon
.components-panel-item-selected:hover,
.components-panel-item-selected:hover .caret-icon-container {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-bg-5);
}
.components-panel-item-selected .components-panel-edit-button:hover,
.components-panel-item-selected .components-panel-item-dropdown:hover {
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-highlight);
}
.is-folder-component:hover .components-panel-folder-label {

View File

@@ -122,9 +122,10 @@
width: 0;
position: absolute;
pointer-events: none;
border-bottom-color: var(--theme-color-bg-5);
border-bottom-color: var(--theme-color-bg-4);
border-width: 10px;
margin-left: -10px;
transform: translateX(0);
}
.popup-layer-popout-arrow.right {

View File

@@ -1,111 +1,112 @@
.Root {
container-name: editortopbar;
height: 36px;
background-color: var(--theme-color-bg-2);
border-bottom: 2px solid var(--theme-color-bg-1);
display: flex;
align-items: stretch;
justify-content: space-between;
}
.LeftSide,
.RightSide {
display: flex;
align-items: stretch;
// please use these general selectors with great caution
> div {
display: flex;
align-items: center;
}
}
.LeftSide {
flex-grow: 1;
}
.is-padded-s {
padding: 0 4px;
}
.is-padded {
padding: 0 8px;
}
.is-padded-l {
padding: 0 16px;
}
.LeftSide {
> div:not(:last-child) {
border-right: 1px solid var(--theme-color-bg-1);
}
}
.RightSide {
> div:not(:first-child) {
border-left: 1px solid var(--theme-color-bg-1);
}
}
.UrlBarWrapper {
flex-grow: 1;
min-width: 150px;
max-width: 500px;
margin-right: -1px;
}
.UrlBarTextInput {
width: 100%;
svg {
cursor: pointer;
&:hover {
path {
fill: var(--theme-color-fg-highlight);
}
}
}
}
.TooltipPositioner {
height: 32px;
padding-bottom: 1px;
display: flex !important;
flex-direction: column;
justify-content: center;
}
.TopbarSelect {
display: flex;
align-items: center;
cursor: pointer;
* {
color: var(--theme-color-fg-default);
fill: var(--theme-color-fg-default);
}
&:hover * {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
}
}
.ZoomSelect {
padding-right: 4px;
padding-left: 8px;
}
.DesignPreviewModeButton {
cursor: pointer;
pointer-events: all;
}
.DeployButton {
.Root.is-small & {
padding: 0 4px 0 8px;
min-width: 0;
}
}
.Root {
container-name: editortopbar;
height: 36px;
background-color: var(--theme-color-bg-2);
border-bottom: 2px solid var(--theme-color-bg-1);
display: flex;
align-items: stretch;
justify-content: space-between;
}
.LeftSide,
.RightSide {
display: flex;
align-items: stretch;
// please use these general selectors with great caution
> div {
display: flex;
align-items: center;
}
}
.LeftSide {
flex-grow: 1;
}
.is-padded-s {
padding: 0 4px;
}
.is-padded {
padding: 0 8px;
}
.is-padded-l {
padding: 0 16px;
}
.LeftSide {
> div:not(:last-child) {
border-right: 1px solid var(--theme-color-bg-1);
}
}
.RightSide {
> div:not(:first-child) {
border-left: 1px solid var(--theme-color-bg-1);
}
}
.UrlBarWrapper {
flex-grow: 1;
min-width: 150px;
max-width: 500px;
margin-right: -1px;
}
.UrlBarTextInput {
width: 100%;
svg {
cursor: pointer;
&:hover {
path {
fill: var(--theme-color-fg-highlight);
}
}
}
}
.TooltipPositioner {
height: 32px;
padding-bottom: 1px;
display: flex !important;
flex-direction: column;
justify-content: center;
}
.TopbarSelect {
display: flex;
align-items: center;
cursor: pointer;
* {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
}
&:hover * {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
opacity: 0.8;
}
}
.ZoomSelect {
padding-right: 4px;
padding-left: 8px;
}
.DesignPreviewModeButton {
cursor: pointer;
pointer-events: all;
}
.DeployButton {
.Root.is-small & {
padding: 0 4px 0 8px;
min-width: 0;
}
}

View File

@@ -76,7 +76,7 @@
}
&.is-current {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
position: relative;
z-index: 1;
}

View File

@@ -1,12 +1,14 @@
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
import classNames from 'classnames';
import React, { ReactNode, useEffect, useState } from 'react';
import { NodeType } from '@noodl-constants/NodeType';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import css from './NodePickerCategory.module.scss';
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
interface NodePickerCategoryProps {
title: string;
@@ -86,7 +88,7 @@ export default function NodePickerCategory({
css['Arrow'],
isCollapsedState ? css['Arrow--is-collapsed'] : css['Arrow--is-not-collapsed']
])}
src="../assets/icons/editor/right_arrow_22.svg"
src="/assets/icons/editor/right_arrow_22.svg"
/>
</header>

View File

@@ -180,7 +180,7 @@ export function NodeLibrary({ model, parentModel, pos, attachToRoot, runtimeType
createNewComment(model, pos);
e.stopPropagation();
}}
icon={<img src="../assets/icons/comment.svg" />}
icon={<img src="/assets/icons/comment.svg" />}
/>
</NodePickerSection>
) : null}

View File

@@ -48,8 +48,8 @@
}
.lesson-item.selected {
background-color: var(--theme-color-fg-highlight);
color: var(--theme-color-secondary-dim);
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
opacity: 1;
transition: background-color 200ms, opacity 200ms;
}
@@ -128,9 +128,9 @@
}
.lesson-item-popup {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-3);
width: 512px;
color: white;
color: var(--theme-color-fg-highlight);
padding: 8px;
}

View File

@@ -17,10 +17,10 @@
}
&.is-active {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
&:hover {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-bg-5);
}
}
}
@@ -33,4 +33,4 @@
.SearchResults {
overflow: hidden overlay;
}
}