mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Refactored dev-docs folder after multiple additions to organise correctly
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreateProjectModal } from './CreateProjectModal';
|
||||
export type { CreateProjectModalProps } from './CreateProjectModal';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]]
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1,7 +0,0 @@
|
||||
.Root {
|
||||
// Container styling if needed
|
||||
}
|
||||
|
||||
.Button {
|
||||
// Button styling if needed
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ViewModeToggle, ViewMode } from './ViewModeToggle';
|
||||
export type { ViewModeToggleProps } from './ViewModeToggle';
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user