Refactored dev-docs folder after multiple additions to organise correctly

This commit is contained in:
Richard Osborne
2026-01-07 20:28:40 +01:00
parent beff9f0886
commit 4a1080d547
125 changed files with 18456 additions and 957 deletions

View File

@@ -17,7 +17,6 @@ import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
import { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab';
import { GitHubUser, LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter';
@@ -43,6 +42,9 @@ export interface LauncherProps {
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
// Project organization service (optional - for Storybook compatibility)
projectOrganizationService?: any;
// GitHub OAuth integration (optional - for Storybook compatibility)
githubUser?: GitHubUser | null;
githubIsAuthenticated?: boolean;
@@ -176,6 +178,7 @@ export function Launcher({
onLaunchProject,
onOpenProjectFolder,
onDeleteProject,
projectOrganizationService,
githubUser,
githubIsAuthenticated,
githubIsConnecting,
@@ -188,16 +191,6 @@ export function Launcher({
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
@@ -221,15 +214,6 @@ export function Launcher({
}
});
// 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) {
@@ -289,14 +273,13 @@ export function Launcher({
value={{
activePageId,
setActivePageId,
viewMode,
setViewMode,
useMockData,
setUseMockData,
projects: activeProjects,
hasRealProjects,
selectedFolderId,
setSelectedFolderId,
projectOrganizationService,
onCreateProject,
onOpenProject,
onLaunchProject,

View File

@@ -9,10 +9,6 @@
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';
@@ -29,8 +25,6 @@ export interface GitHubUser {
export interface LauncherContextValue {
activePageId: LauncherPageId;
setActivePageId: (pageId: LauncherPageId) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
useMockData: boolean;
setUseMockData: (value: boolean) => void;
projects: LauncherProjectData[];
@@ -40,6 +34,9 @@ export interface LauncherContextValue {
selectedFolderId: string | null;
setSelectedFolderId: (folderId: string | null) => void;
// Project organization service (optional for Storybook compatibility)
projectOrganizationService?: any; // Use 'any' to avoid circular deps
// Project management callbacks
onCreateProject?: () => void;
onOpenProject?: () => void;

View File

@@ -0,0 +1,74 @@
.Backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.Modal {
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-lg);
min-width: 500px;
max-width: 600px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.Header {
padding: var(--spacing-5) var(--spacing-6);
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
font-size: 20px;
font-weight: 600;
color: var(--theme-color-fg-default);
margin: 0;
line-height: 1.3;
}
.Content {
padding: var(--spacing-6);
}
.Field {
margin-bottom: var(--spacing-5);
&:last-child {
margin-bottom: 0;
}
}
.LocationRow {
display: flex;
align-items: center;
margin-top: var(--spacing-2);
}
.PathPreview {
margin-top: var(--spacing-4);
padding: var(--spacing-3);
background-color: var(--theme-color-bg-3);
border-radius: var(--radius-default);
border: 1px solid var(--theme-color-border-default);
}
.PathText {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
line-height: 1.4;
}
.Footer {
padding: var(--spacing-4) var(--spacing-6);
border-top: 1px solid var(--theme-color-border-default);
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Label } from '@noodl-core-ui/components/typography/Label';
import css from './CreateProjectModal.module.scss';
export interface CreateProjectModalProps {
isVisible: boolean;
onClose: () => void;
onConfirm: (name: string, location: string) => void;
onChooseLocation?: () => Promise<string | null>; // For folder picker
}
export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLocation }: CreateProjectModalProps) {
const [projectName, setProjectName] = useState('');
const [location, setLocation] = useState('');
const [isChoosingLocation, setIsChoosingLocation] = useState(false);
// Reset state when modal opens
useEffect(() => {
if (isVisible) {
setProjectName('');
setLocation('');
}
}, [isVisible]);
const handleChooseLocation = async () => {
if (!onChooseLocation) return;
setIsChoosingLocation(true);
try {
const chosen = await onChooseLocation();
if (chosen) {
setLocation(chosen);
}
} finally {
setIsChoosingLocation(false);
}
};
const handleCreate = () => {
if (!projectName.trim() || !location) return;
onConfirm(projectName.trim(), location);
};
const isValid = projectName.trim().length > 0 && location.length > 0;
if (!isVisible) return null;
return (
<div className={css['Backdrop']} onClick={onClose}>
<div className={css['Modal']} onClick={(e) => e.stopPropagation()}>
<div className={css['Header']}>
<h3 className={css['Title']}>Create New Project</h3>
</div>
<div className={css['Content']}>
{/* Project Name */}
<div className={css['Field']}>
<Label>Project Name</Label>
<TextInput
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My New Project"
isAutoFocus
UNSAFE_style={{ marginTop: 'var(--spacing-2)' }}
/>
</div>
{/* Location */}
<div className={css['Field']}>
<Label>Location</Label>
<div className={css['LocationRow']}>
<TextInput
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Choose folder..."
isReadonly
UNSAFE_style={{ flex: 1 }}
/>
<PrimaryButton
label="Choose..."
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleChooseLocation}
isDisabled={isChoosingLocation}
UNSAFE_style={{ marginLeft: 'var(--spacing-2)' }}
/>
</div>
</div>
{/* Preview full path */}
{projectName && location && (
<div className={css['PathPreview']}>
<span className={css['PathText']}>
Full path: {location}/{projectName}/
</span>
</div>
)}
</div>
<div className={css['Footer']}>
<PrimaryButton
label="Cancel"
size={PrimaryButtonSize.Default}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
UNSAFE_style={{ marginRight: 'var(--spacing-2)' }}
/>
<PrimaryButton label="Create" size={PrimaryButtonSize.Default} onClick={handleCreate} isDisabled={!isValid} />
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,7 @@
import { useEffect, useMemo, useState } from 'react';
// Import types and service from noodl-editor
// Note: This assumes the service is available in the editor context
// We'll need to expose it through the window or context
declare global {
interface Window {
ProjectOrganizationService?: any;
}
}
import { useLauncherContext } from '../LauncherContext';
export interface Folder {
id: string;
@@ -67,21 +60,25 @@ export interface UseProjectOrganizationReturn {
/**
* Hook to manage project organization through folders and tags.
*
* Note: Currently uses localStorage directly as a fallback.
* In production, this should interface with ProjectOrganizationService
* from the Electron main process.
* Uses the real ProjectOrganizationService when available (in production),
* falls back to localStorage service for Storybook compatibility.
*/
export function useProjectOrganization(): UseProjectOrganizationReturn {
const { projectOrganizationService } = useLauncherContext();
const [folders, setFolders] = useState<Folder[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [, setUpdateTrigger] = useState(0);
// Memoize service to prevent infinite loop - service must be stable across renders
// Use real service if available, otherwise fall back to localStorage
const service = useMemo(() => {
// TODO: In production, get this from window context or inject it
// For now, we'll implement a minimal localStorage version
if (projectOrganizationService) {
console.log('✅ Using real ProjectOrganizationService');
return projectOrganizationService;
}
console.warn('⚠️ ProjectOrganizationService not available, using localStorage fallback');
return createLocalStorageService();
}, []); // Empty deps - create service once
}, [projectOrganizationService]);
// Subscribe to service events and load initial data
useEffect(() => {

View File

@@ -20,20 +20,15 @@ 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 { useProjectOrganization } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import { MOCK_PROJECTS } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useLauncherContext, ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { useLauncherContext } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
export interface ProjectsViewProps {}
export function Projects({}: ProjectsViewProps) {
const {
viewMode,
setViewMode,
projects: allProjects,
selectedFolderId,
setSelectedFolderId,
@@ -93,13 +88,6 @@ 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);
}
@@ -165,87 +153,71 @@ export function Projects({}: ProjectsViewProps) {
projectData={projects.find((project) => project.id === selectedProjectId)}
/>
<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>
<LauncherSearchBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filterValue={filterValue}
setFilterValue={setFilterValue}
filterDropdownItems={visibleTypesDropdownItems}
/>
<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)}
/>
) : (
<>
{/* 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}
onClick={() => onLaunchProject?.(project.id)}
contextMenuItems={[
{
label: 'Launch project',
onClick: () => onLaunchProject?.(project.id)
},
{
label: 'Open project folder',
onClick: () => onOpenProjectFolder?.(project.id)
},
{
label: 'Move to folder...',
onClick: () => onMoveToFolder(project)
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
},
{/* Project list legend */}
<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>
'divider',
{
label: 'Delete project',
onClick: () => onDeleteProject?.(project.id),
icon: IconName.Trash,
isDangerous: true
}
]}
/>
))}
</Columns>
</>
)}
{/* Grid of project cards */}
<Columns layoutString="1" hasXGap hasYGap>
{projects.map((project) => (
<LauncherProjectCard
key={project.id}
{...project}
onClick={() => onLaunchProject?.(project.id)}
contextMenuItems={[
{
label: 'Launch project',
onClick: () => onLaunchProject?.(project.id)
},
{
label: 'Open project folder',
onClick: () => onOpenProjectFolder?.(project.id)
},
{
label: 'Move to folder...',
onClick: () => onMoveToFolder(project)
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
},
'divider',
{
label: 'Delete project',
onClick: () => onDeleteProject?.(project.id),
icon: IconName.Trash,
isDangerous: true
}
]}
/>
))}
</Columns>
</Box>
{/* Folder Picker Modal */}

View File

@@ -9,6 +9,7 @@ import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect, useState } from 'react';
import { filesystem } from '@noodl/platform';
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
import {
CloudSyncType,
LauncherProjectData
@@ -19,6 +20,7 @@ import { GitHubUser } from '@noodl-core-ui/preview/launcher/Launcher/LauncherCon
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { GitHubOAuthService } from '../../services/GitHubOAuthService';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
@@ -52,6 +54,9 @@ export function ProjectsPage(props: ProjectsPageProps) {
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
// Create project modal state
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
// Initialize and fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
@@ -147,40 +152,55 @@ export function ProjectsPage(props: ProjectsPageProps) {
ToastLayer.showSuccess('Disconnected from GitHub');
});
const handleCreateProject = useCallback(async () => {
const handleCreateProject = useCallback(() => {
setIsCreateModalVisible(true);
}, []);
const handleChooseLocation = useCallback(async (): Promise<string | null> => {
try {
const direntry = await filesystem.openDialog({
allowCreateDirectory: true
});
if (!direntry) return;
// 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;
const path = filesystem.makeUniquePath(filesystem.join(direntry, name));
const activityId = 'creating-project';
ToastLayer.showActivity('Creating new project', activityId);
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: '' }
);
return direntry || null;
} catch (error) {
console.error('Failed to create project:', error);
ToastLayer.showError('Failed to create project');
console.error('Failed to choose location:', error);
return null;
}
}, [props.route]);
}, []);
const handleCreateProjectConfirm = useCallback(
async (name: string, location: string) => {
setIsCreateModalVisible(false);
try {
const path = filesystem.makeUniquePath(filesystem.join(location, name));
const activityId = 'creating-project';
ToastLayer.showActivity('Creating new project', activityId);
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 handleCreateModalClose = useCallback(() => {
setIsCreateModalVisible(false);
}, []);
const handleOpenProject = useCallback(async () => {
console.log('🔵 [handleOpenProject] Starting...');
@@ -328,18 +348,28 @@ export function ProjectsPage(props: ProjectsPageProps) {
}, []);
return (
<Launcher
projects={realProjects}
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onLaunchProject={handleLaunchProject}
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
/>
<>
<Launcher
projects={realProjects}
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onLaunchProject={handleLaunchProject}
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
projectOrganizationService={ProjectOrganizationService.instance}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
/>
<CreateProjectModal
isVisible={isCreateModalVisible}
onClose={handleCreateModalClose}
onConfirm={handleCreateProjectConfirm}
onChooseLocation={handleChooseLocation}
/>
</>
);
}

View File

@@ -5,6 +5,8 @@
* Data is stored client-side in electron-store and keyed by project path.
*/
import Store from 'electron-store';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
// ============================================================================
@@ -59,11 +61,23 @@ export const TAG_COLORS = [
export class ProjectOrganizationService extends EventDispatcher {
private static _instance: ProjectOrganizationService;
private store: Store<ProjectOrganizationData>;
private data: ProjectOrganizationData;
private storageKey = 'projectOrganization';
private constructor() {
super();
// Initialize electron-store
this.store = new Store<ProjectOrganizationData>({
name: 'project_organization',
defaults: {
version: 1,
folders: [],
tags: [],
projectMeta: {}
}
});
this.data = this.loadData();
}
@@ -80,26 +94,21 @@ export class ProjectOrganizationService extends EventDispatcher {
private loadData(): ProjectOrganizationData {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
return JSON.parse(stored);
}
return this.store.store; // Get all data from store
} catch (error) {
console.error('[ProjectOrganizationService] Failed to load data:', error);
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
}
// Return default empty structure
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
}
private saveData(): void {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
this.store.store = this.data; // Save all data to store
this.notifyListeners('dataChanged', this.data);
} catch (error) {
console.error('[ProjectOrganizationService] Failed to save data:', error);

View File

@@ -2,6 +2,7 @@ import path from 'node:path';
import { GitStore } from '@noodl-store/GitStore';
import Store from 'electron-store';
import { isEqual } from 'underscore';
import { getTopLevelWorkingDirectory } from '@noodl/git/src/core/open';
import { setRequestGitAccount } from '@noodl/git/src/core/trampoline/trampoline-askpass-handler';
import { filesystem, platform } from '@noodl/platform';
@@ -9,13 +10,12 @@ import { ProjectModel } from '@noodl-models/projectmodel';
import { templateRegistry } from '@noodl-utils/forge';
import Model from '../../../shared/model';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { RuntimeVersionInfo } from '../models/migration/types';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import { RuntimeVersionInfo } from '../models/migration/types';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
import { getTopLevelWorkingDirectory } from '@noodl/git/src/core/open';
export interface ProjectItem {
id: string;
@@ -243,10 +243,6 @@ export class LocalProjectsModel extends Model {
projectFromDirectory(dirEntry, (project) => {
if (!project) {
fn();
// callback({
// result: 'failure',
// message: 'Failed to load project'
// });
return;
}
@@ -258,21 +254,42 @@ export class LocalProjectsModel extends Model {
project.toDirectory(project._retainedProjectDirectory, (res) => {
if (res.result === 'success') {
fn(project);
// callback({
// result: 'success',
// project: project
// });
} else {
fn();
// callback({
// result: 'failure',
// message: 'Failed to clone project'
// });
}
});
});
} else {
this._unzipAndLaunchProject('./external/projecttemplates/helloworld.zip', dirEntry, fn, options);
// Default template path
const defaultTemplatePath = './external/projecttemplates/helloworld.zip';
// Check if template exists, otherwise create an empty project
if (filesystem.exists(defaultTemplatePath)) {
this._unzipAndLaunchProject(defaultTemplatePath, dirEntry, fn, options);
} else {
console.warn('Default project template not found, creating empty project');
// Create minimal project.json for empty project
const minimalProject = {
name: name,
components: [],
settings: {}
};
await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2));
// Load the newly created empty project
projectFromDirectory(dirEntry, (project) => {
if (!project) {
fn();
return;
}
project.name = name;
this._addProject(project);
fn(project);
});
}
}
}
@@ -300,8 +317,8 @@ export class LocalProjectsModel extends Model {
/**
* Check if this project is in a git repository.
*
* @param project
* @returns
* @param project
* @returns
*/
async isGitProject(project: ProjectModel): Promise<boolean> {
const gitPath = await getTopLevelWorkingDirectory(project._retainedProjectDirectory);
@@ -452,7 +469,7 @@ export class LocalProjectsModel extends Model {
*/
async detectAllProjectRuntimes(): Promise<void> {
const projects = this.getProjects();
// Detect in parallel but don't wait for all to complete
// Instead, trigger detection and let events update the UI
for (const project of projects) {