Skip to content

Commit 257e9ce

Browse files
authored
feat: add project management features (#72)
* chore: refactor project actions to allow management of not loaded projects * feat: add project delete and details * feat: support preferences editing for projects not currently loaded * chore: naming conventions and types * fix: remove trailing white space in generated project names * chore: remove debugging logs * chore: update jsdocs and fmt * feat: add project cloning * chore: changelog v2.4.2
1 parent 64e9093 commit 257e9ce

26 files changed

+1102
-311
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.4.2] - 2025-10-24
9+
10+
### Added
11+
12+
- Added Project Management features: - @imaginarny
13+
- Project Details, including updated/created dates and project size
14+
- Preferences, now editable for projects not currently loaded
15+
- Cloning projects
16+
- Deleting projects
17+
- Added Context menu for project management in the Projects Browser
18+
- Added Details and Delete project actions to the main toolbar
19+
20+
### Fixed
21+
22+
- Fixed trailing whitespace on newly generated project names - @imaginarny
23+
824
## [2.4.1] - 2025-09-19
925

1026
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "kaplayground",
33
"type": "module",
4-
"version": "2.4.1",
4+
"version": "2.4.2",
55
"bin": "scripts/cli.js",
66
"scripts": {
77
"dev": "vite dev",

src/components/Playground/Playground.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ const Playground = () => {
8484
kaplayVersion: preferredVersion(),
8585
});
8686

87+
// Init saved projects array
88+
useProject.getState().updateSavedProjects();
89+
8790
// Initialize ESBuild
8891
esbuild.initialize({
8992
wasmURL: "https://unpkg.com/[email protected]/esbuild.wasm",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { forwardRef, useMemo } from "react";
2+
import { fileSize } from "../../util/fileSize";
3+
4+
type ProjectDetailsProps = {
5+
project: {
6+
key: string;
7+
type: string;
8+
formattedName: string;
9+
tags: { name: string; displayName?: string }[];
10+
createdAt: string;
11+
updatedAt: string;
12+
buildMode?: string;
13+
};
14+
};
15+
16+
const Tag = ({ name }: { name: string }) => (
17+
<div className="badge badge-xs badge-ghost h-auto min-h-0 px-1.5 py-1 font-medium leading-none capitalize rounded-full border-transparent bg-base-content/10">
18+
{name}
19+
</div>
20+
);
21+
22+
export const ProjectDetails = forwardRef<HTMLDivElement, ProjectDetailsProps>(
23+
({ project, ...props }, ref) => {
24+
const tags = useMemo(
25+
() =>
26+
project.tags?.filter(t =>
27+
!["example", "project"].includes(t.name)
28+
),
29+
[project?.tags],
30+
);
31+
const size = useMemo(
32+
() =>
33+
new TextEncoder().encode(
34+
localStorage.getItem(project.key) || "",
35+
)
36+
.length,
37+
[project.key],
38+
);
39+
40+
return (
41+
<div
42+
className="space-y-3 text-sm [&>*+*]:pt-3 [&>*+*]:border-t [&>*+*]:border-base-content/[8%]"
43+
{...props}
44+
ref={ref}
45+
>
46+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
47+
<div className="flex flex-col gap-0.5 flex-1">
48+
<div className="font-medium text-white">Name</div>
49+
<h2 className="text-base">{project.formattedName}</h2>
50+
</div>
51+
52+
<div className="flex flex-col gap-0.5 flex-1">
53+
<h3 className="font-medium text-white">Type</h3>
54+
<div className="mt-0.5 -mx-0.5">
55+
<Tag name={project.type} />
56+
</div>
57+
</div>
58+
</div>
59+
60+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
61+
<div className="flex flex-col gap-0.5 flex-1">
62+
<h3 className="font-medium text-white">Last Updated</h3>
63+
<div>
64+
{new Date(project.updatedAt).toLocaleString()}
65+
</div>
66+
</div>
67+
68+
<div className="flex flex-col gap-0.5 flex-1">
69+
<h3 className="font-medium text-white">Created</h3>
70+
<div>
71+
{project.createdAt
72+
? new Date(project.createdAt).toLocaleString()
73+
: "Unknown"}
74+
</div>
75+
</div>
76+
</div>
77+
78+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
79+
{(project.type == "Project" && project.buildMode) && (
80+
<div className="flex flex-col gap-0.5 flex-1 only:contents">
81+
<h3 className="flex-1 font-medium text-white">
82+
Build Mode
83+
</h3>
84+
<div className="flex-1">{project.buildMode}</div>
85+
</div>
86+
)}
87+
88+
<div className="flex flex-col gap-0.5 flex-1 only:contents">
89+
<h3 className="flex-1 font-medium text-white">
90+
Project Size
91+
</h3>
92+
<div className="flex-1">{fileSize(size)}</div>
93+
</div>
94+
</div>
95+
96+
{tags.length > 0 && (
97+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
98+
<div className="flex flex-col gap-0.5 flex-1">
99+
<h3 className="font-medium text-white">Tags</h3>
100+
<div className="mt-0.5 -mx-0.5">
101+
{tags?.map((t) => (
102+
<Tag
103+
key={t.name}
104+
name={t.displayName || t.name}
105+
/>
106+
))}
107+
</div>
108+
</div>
109+
</div>
110+
)}
111+
</div>
112+
);
113+
},
114+
);

src/components/Project/ProjectPreferences.tsx

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { ChangeEvent, useRef, useState } from "react";
1+
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
22
import { Tooltip } from "react-tooltip";
33
import { validateProjectName } from "../../features/Projects/application/validateProjectName";
44
import { Project } from "../../features/Projects/models/Project";
@@ -20,21 +20,30 @@ const buildOptions = {
2020
};
2121

2222
const ProjectPreferences = () => {
23-
const project = useProject((s) => s.project);
24-
const projectDataKeys = Object.keys(project);
23+
const currentProject = useProject((s) => s.project);
2524
const projectKey = useProject((s) => s.projectKey);
2625
const setProject = useProject((s) => s.setProject);
26+
const saveProject = useProject((s) => s.saveProject);
2727

28+
const [editedKey, setEditedKey] = useState<string | null>(null);
29+
30+
const editedProject: Project = useMemo(() => {
31+
if (!editedKey) return currentProject;
32+
const p = useProject.getState().unserializeProject(editedKey);
33+
return (p ?? currentProject) as Project;
34+
}, [editedKey, currentProject]);
35+
36+
const projectDataKeys = Object.keys(editedProject);
2837
const formRef = useRef<HTMLFormElement>(null);
2938
const [prevBuildMode, setPrevBuildMode] = useState<ProjectBuildMode | null>(
3039
null,
3140
);
3241
const [errors, setErrors] = useState<Record<string, string>>({});
3342

34-
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
43+
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
3544
const key = e.target.name;
3645
const value = e.target.value;
37-
const [_, error] = validateProjectName(value);
46+
const [_, error] = validateProjectName(value, editedKey ?? projectKey);
3847

3948
if (error) {
4049
setErrors({
@@ -59,41 +68,61 @@ const ProjectPreferences = () => {
5968
confirmText: `Yes, change`,
6069
dismissText: `No, keep the ${
6170
buildOptions[
62-
project.buildMode as keyof typeof buildOptions
71+
editedProject.buildMode as keyof typeof buildOptions
6372
]
6473
}`,
6574
cancelImmediate: true,
6675
},
6776
)
68-
) e.target.value = prevBuildMode || project.buildMode;
77+
) e.target.value = prevBuildMode || editedProject.buildMode;
6978

7079
setPrevBuildMode(e.target.value as ProjectBuildMode);
7180
};
7281

82+
useEffect(() => {
83+
const handler = (e: Event) => {
84+
const d = (e as CustomEvent).detail;
85+
if (!d || d.id !== "project-preferences") return;
86+
setEditedKey(d.params?.projectKey ?? null);
87+
};
88+
window.addEventListener("dialog-open", handler);
89+
return () => window.removeEventListener("dialog-open", handler);
90+
}, []);
91+
7392
const handleSave = () => {
7493
let projectData: Partial<Project> = {};
7594

7695
for (const [key, value] of new FormData(formRef.current!).entries()) {
7796
if (
7897
!projectDataKeys.includes(key)
7998
|| typeof value != "string"
80-
|| typeof project[key as keyof Project] != "string"
99+
|| typeof editedProject[key as keyof Project] != "string"
81100
) continue;
82101

83102
projectData[key as keyof Project] = value as any;
84103
}
85104

86-
if (
87-
Object.entries(projectData).some(
88-
([key, value]) => project[key as keyof Project] != value,
89-
)
90-
) setProject(projectData);
105+
if (!Object.keys(projectData).length) return;
106+
107+
if (editedKey && editedKey !== projectKey) {
108+
saveProject(editedKey, { ...editedProject, ...projectData });
109+
} else {
110+
if (
111+
Object.entries(projectData).some(
112+
([key, value]) =>
113+
editedProject[key as keyof Project] != value,
114+
)
115+
) setProject(projectData);
116+
}
117+
118+
setEditedKey(null);
91119
};
92120

93121
const handleDismiss = () => {
94122
formRef.current?.reset();
95123
setPrevBuildMode(null);
96124
setErrors({});
125+
setEditedKey(null);
97126
setTimeout(() => {
98127
(formRef.current!).querySelectorAll("[name]").forEach(el => {
99128
el.dispatchEvent(new Event("reset", { bubbles: false }));
@@ -124,11 +153,13 @@ const ProjectPreferences = () => {
124153
<form
125154
ref={formRef}
126155
className="-mx-1 [&>*]:px-1"
127-
key={projectKey}
156+
key={editedKey ?? projectKey}
128157
onKeyDown={e => e.key == "Enter" && e.preventDefault()}
129158
>
130159
<div className="mt-2 !px-0 space-y-1">
131-
<ProjectFavicon defaultValue={project.favicon} />
160+
<ProjectFavicon
161+
defaultValue={editedProject.favicon}
162+
/>
132163

133164
<label className="label gap-2 pl-3 pr-2 bg-base-200 rounded-xl border border-base-content/10">
134165
<span className="flex flex-col gap-1">
@@ -140,19 +171,20 @@ const ProjectPreferences = () => {
140171
<input
141172
name="name"
142173
className="input input-bordered input-sm w-full max-w-60 placeholder:text-base-content/45 data-[tooltip-content]:border-error data-[tooltip-content]:focus-visible:outline-error"
143-
defaultValue={project.name}
144-
placeholder={project.name}
174+
defaultValue={editedProject.name}
175+
placeholder={editedProject.name}
145176
onInput={handleNameChange}
146177
onBlur={e =>
147178
(!e.target.value)
148-
&& (e.target.value = project.name)}
179+
&& (e.target.value =
180+
editedProject.name)}
149181
onKeyDownCapture={e => {
150182
if (e.key != "Escape") return;
151183
e.preventDefault();
152184
e.stopPropagation();
153185
const target = e
154186
.target as HTMLInputElement;
155-
target.value = project.name;
187+
target.value = editedProject.name;
156188
resetError("name");
157189
target.blur();
158190
}}
@@ -165,7 +197,7 @@ const ProjectPreferences = () => {
165197
</label>
166198
</div>
167199

168-
{project.mode == "pj" && (
200+
{editedProject.mode == "pj" && (
169201
<>
170202
<div className="divider mt-1.5 mb-0 first:hidden">
171203
</div>
@@ -196,7 +228,7 @@ const ProjectPreferences = () => {
196228
id="build-mode"
197229
name="buildMode"
198230
className="select select-bordered select-sm"
199-
defaultValue={project.buildMode}
231+
defaultValue={editedProject.buildMode}
200232
onChange={handleBuildModeChange}
201233
>
202234
{Object.entries(buildOptions).map((

0 commit comments

Comments
 (0)