kishare 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +91 -0
- package/bin/kishare.js +27 -0
- package/index.html +15 -0
- package/package.json +53 -0
- package/src/components/project-list.ts +326 -0
- package/src/components/viewer-panel.ts +546 -0
- package/src/components/workspace-app.ts +270 -0
- package/src/indexer/copy-public-files.ts +65 -0
- package/src/indexer/indexer-utils.ts +111 -0
- package/src/indexer/scan-projects.ts +399 -0
- package/src/lib/constants.ts +44 -0
- package/src/lib/html-utils.ts +20 -0
- package/src/lib/project-index.ts +60 -0
- package/src/lib/router.ts +208 -0
- package/src/main.ts +13 -0
- package/src/node/cli.ts +161 -0
- package/src/styles/main.css +698 -0
- package/tsconfig.json +26 -0
- package/vendor/kicanvas/tsconfig.json +48 -0
- package/vite.config.ts +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# KiShare
|
|
2
|
+
|
|
3
|
+
A lightweight tool to view and share your KiCad projects online.
|
|
4
|
+
|
|
5
|
+
Built on [KiCanvas](https://kicanvas.org/), KiShare scans for KiCad project
|
|
6
|
+
files and generates a static site.
|
|
7
|
+
|
|
8
|
+
See the demo site [here](https://hmcty.github.io/kishare/).
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Out-of-the-box support for hosting on GitHub Pages
|
|
13
|
+
- Can create and share location markers, e.g. to reference in GitHub issues / PR reviews
|
|
14
|
+
- Generates links for easy download of project-specific ZIP archives
|
|
15
|
+
- Provides basic support for inline documentation
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Configuration
|
|
20
|
+
|
|
21
|
+
Create `kishare-config.json` in the root directory:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"title": "My KiCad Projects",
|
|
26
|
+
"projectDirs": ["projects"],
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Field | Description | Default |
|
|
31
|
+
|-------|-------------|---------|
|
|
32
|
+
| `title` | Displayed as site title | Repository name |
|
|
33
|
+
| `projectDirs` | Array of directories containing KiCad project files | `["projects"]` |
|
|
34
|
+
|
|
35
|
+
### Deployment
|
|
36
|
+
|
|
37
|
+
#### GitHub Pages
|
|
38
|
+
|
|
39
|
+
After enabling [GitHub Pages in your repository settings](https://docs.github.com/en/pages/quickstart), add the following GitHub workflow:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
name: Build and Deploy KiShare
|
|
43
|
+
|
|
44
|
+
on:
|
|
45
|
+
push:
|
|
46
|
+
branches: [main]
|
|
47
|
+
workflow_dispatch:
|
|
48
|
+
|
|
49
|
+
permissions:
|
|
50
|
+
contents: read
|
|
51
|
+
pages: write
|
|
52
|
+
id-token: write
|
|
53
|
+
|
|
54
|
+
concurrency:
|
|
55
|
+
group: "pages"
|
|
56
|
+
cancel-in-progress: false
|
|
57
|
+
|
|
58
|
+
jobs:
|
|
59
|
+
build:
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
62
|
+
- name: Checkout repository
|
|
63
|
+
uses: actions/checkout@v4
|
|
64
|
+
with:
|
|
65
|
+
submodules: recursive
|
|
66
|
+
|
|
67
|
+
- name: Build KiShare
|
|
68
|
+
uses: hmcty/kishare@main
|
|
69
|
+
|
|
70
|
+
- name: Setup Pages
|
|
71
|
+
uses: actions/configure-pages@v4
|
|
72
|
+
|
|
73
|
+
- name: Upload artifact
|
|
74
|
+
uses: actions/upload-pages-artifact@v3
|
|
75
|
+
with:
|
|
76
|
+
path: './dist'
|
|
77
|
+
|
|
78
|
+
deploy:
|
|
79
|
+
environment:
|
|
80
|
+
name: github-pages
|
|
81
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
82
|
+
runs-on: ubuntu-latest
|
|
83
|
+
needs: build
|
|
84
|
+
steps:
|
|
85
|
+
- name: Deploy to GitHub Pages
|
|
86
|
+
id: deployment
|
|
87
|
+
uses: actions/deploy-pages@v4
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
See the [demo workflow](./.github/workflows/deploy.yml) for a complete example.
|
|
91
|
+
|
package/bin/kishare.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
// Resolve tsx binary from our package's node_modules
|
|
12
|
+
const tsxBin = require.resolve('tsx/cli');
|
|
13
|
+
const cliPath = join(__dirname, '..', 'src', 'node', 'cli.ts');
|
|
14
|
+
|
|
15
|
+
// Spawn tsx to run the TypeScript CLI, passing through all arguments
|
|
16
|
+
const child = spawn(
|
|
17
|
+
process.execPath,
|
|
18
|
+
[tsxBin, cliPath, ...process.argv.slice(2)],
|
|
19
|
+
{
|
|
20
|
+
stdio: 'inherit',
|
|
21
|
+
cwd: process.cwd(),
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
child.on('exit', (code) => {
|
|
26
|
+
process.exit(code ?? 0);
|
|
27
|
+
});
|
package/index.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' https://fonts.gstatic.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self';">
|
|
7
|
+
<title>KiShare Workspace</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<workspace-app></workspace-app>
|
|
11
|
+
|
|
12
|
+
<script type="module" src="/kicanvas/kicanvas.js"></script>
|
|
13
|
+
<script type="module" src="/src/main.ts"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kishare",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CLI tool for generating static KiCad project viewer sites",
|
|
6
|
+
"homepage": "https://github.com/hmcty/kishare",
|
|
7
|
+
"license": "GPL-3.0-only",
|
|
8
|
+
"bin": {
|
|
9
|
+
"kishare": "bin/kishare.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"kicad",
|
|
13
|
+
"vite"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"vendor/kicanvas/build",
|
|
19
|
+
"index.html",
|
|
20
|
+
"vite.config.ts",
|
|
21
|
+
"tsconfig.json"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"setup": "cd ./vendor/kicanvas && npm install && npm run build",
|
|
25
|
+
"predev": "npm run index && npm run copy-files",
|
|
26
|
+
"prebuild": "npm run index && npm run copy-files",
|
|
27
|
+
"index": "KISHARE_ROOT=. tsx src/indexer/scan-projects.ts",
|
|
28
|
+
"copy-files": "npm run copy-public && npm run copy-kicanvas",
|
|
29
|
+
"copy-public": "KISHARE_ROOT=. tsx src/indexer/copy-public-files.ts",
|
|
30
|
+
"copy-kicanvas": "mkdir -p public/kicanvas && cp ./vendor/kicanvas/build/kicanvas.js public/kicanvas/",
|
|
31
|
+
"dev": "vite",
|
|
32
|
+
"build": "vite build",
|
|
33
|
+
"preview": "vite preview",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/archiver": "^7.0.0",
|
|
39
|
+
"@types/dompurify": "^3.0.5",
|
|
40
|
+
"@types/marked": "^5.0.2",
|
|
41
|
+
"@types/node": "^20.12.7",
|
|
42
|
+
"typescript": "^5.4.5",
|
|
43
|
+
"vitest": "^3.1.1"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"archiver": "^7.0.1",
|
|
47
|
+
"dompurify": "^3.3.3",
|
|
48
|
+
"marked": "^17.0.6",
|
|
49
|
+
"marked-base-url": "^1.1.8",
|
|
50
|
+
"tsx": "^4.7.2",
|
|
51
|
+
"vite": "^5.2.8"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project list component - displays navigation sidebar
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { marked } from 'marked';
|
|
6
|
+
import { baseUrl } from 'marked-base-url';
|
|
7
|
+
import DOMPurify from 'dompurify';
|
|
8
|
+
import type { ProjectMetadata, GitInfo } from '../lib/project-index.js';
|
|
9
|
+
import { router } from '../lib/router.js';
|
|
10
|
+
import { isSafeUrl, githubIcon } from '../lib/html-utils.js';
|
|
11
|
+
|
|
12
|
+
export class ProjectList extends HTMLElement {
|
|
13
|
+
private projects: ProjectMetadata[] = [];
|
|
14
|
+
private selectedProjectId: string | null = null;
|
|
15
|
+
private viewMode: 'list' | 'detail' = 'list';
|
|
16
|
+
private gitInfo: GitInfo | null = null;
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
connectedCallback() {
|
|
23
|
+
this.render();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Set git info for building GitHub links
|
|
28
|
+
*/
|
|
29
|
+
setGitInfo(git: GitInfo) {
|
|
30
|
+
this.gitInfo = git;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Set the list of projects to display
|
|
35
|
+
*/
|
|
36
|
+
setProjects(projects: ProjectMetadata[]) {
|
|
37
|
+
this.projects = projects;
|
|
38
|
+
this.render();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Set the currently selected project
|
|
43
|
+
*/
|
|
44
|
+
setSelectedProject(projectId: string | null) {
|
|
45
|
+
this.selectedProjectId = projectId;
|
|
46
|
+
// Switch to detail view when a project is selected
|
|
47
|
+
this.viewMode = projectId ? 'detail' : 'list';
|
|
48
|
+
this.render();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return to project list view
|
|
53
|
+
*/
|
|
54
|
+
private showProjectList() {
|
|
55
|
+
this.viewMode = 'list';
|
|
56
|
+
this.selectedProjectId = null;
|
|
57
|
+
router.navigate('/');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handle project click
|
|
62
|
+
*/
|
|
63
|
+
private handleProjectClick(projectId: string) {
|
|
64
|
+
router.navigate('/project', projectId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render the project list
|
|
69
|
+
*/
|
|
70
|
+
private render() {
|
|
71
|
+
if (this.projects.length === 0) {
|
|
72
|
+
this.innerHTML = `
|
|
73
|
+
<div class="empty">
|
|
74
|
+
<p>No projects found</p>
|
|
75
|
+
</div>
|
|
76
|
+
`;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Render based on view mode
|
|
81
|
+
if (this.viewMode === 'detail' && this.selectedProjectId) {
|
|
82
|
+
this.renderDetailView();
|
|
83
|
+
} else {
|
|
84
|
+
this.renderListView();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Render the project list view
|
|
90
|
+
*/
|
|
91
|
+
private renderListView() {
|
|
92
|
+
// Group projects by base name
|
|
93
|
+
const grouped = this.groupProjects();
|
|
94
|
+
|
|
95
|
+
let html = '<ul class="project-list">';
|
|
96
|
+
|
|
97
|
+
for (const [groupName, groupProjects] of grouped) {
|
|
98
|
+
if (groupProjects.length === 1) {
|
|
99
|
+
// Single project, no grouping
|
|
100
|
+
const project = groupProjects[0];
|
|
101
|
+
html += this.renderProjectItem(project);
|
|
102
|
+
} else {
|
|
103
|
+
// Multiple projects, show as group
|
|
104
|
+
html += `
|
|
105
|
+
<li class="project-group">
|
|
106
|
+
<div class="group-name">${groupName}</div>
|
|
107
|
+
<ul class="group-projects">
|
|
108
|
+
${groupProjects.map(p => this.renderProjectItem(p)).join('')}
|
|
109
|
+
</ul>
|
|
110
|
+
</li>
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
html += '</ul>';
|
|
116
|
+
this.innerHTML = DOMPurify.sanitize(html);
|
|
117
|
+
|
|
118
|
+
// Add click event listeners
|
|
119
|
+
this.querySelectorAll('.project-item').forEach(item => {
|
|
120
|
+
item.addEventListener('click', (e) => {
|
|
121
|
+
// Don't navigate if clicking the download button
|
|
122
|
+
if ((e.target as HTMLElement).closest('.download-btn')) return;
|
|
123
|
+
|
|
124
|
+
const projectId = item.getAttribute('data-project-id');
|
|
125
|
+
if (projectId) {
|
|
126
|
+
this.handleProjectClick(projectId);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render the project detail view
|
|
134
|
+
*/
|
|
135
|
+
private renderDetailView() {
|
|
136
|
+
const project = this.projects.find(p => p.id === this.selectedProjectId);
|
|
137
|
+
if (!project) {
|
|
138
|
+
this.renderListView();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Format dates
|
|
143
|
+
const formatDate = (isoDate?: string) => {
|
|
144
|
+
if (!isoDate) return 'Unknown';
|
|
145
|
+
const date = new Date(isoDate);
|
|
146
|
+
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const badges = [];
|
|
150
|
+
if (project.schematics.length > 0) {
|
|
151
|
+
badges.push(`<span class="badge badge-sch">Sch</span>`);
|
|
152
|
+
}
|
|
153
|
+
if (project.pcb) {
|
|
154
|
+
badges.push(`<span class="badge badge-pcb">PCB</span>`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const downloadBtn = project.zip ? `
|
|
158
|
+
<a href="${project.zip}" download class="download-btn" title="Download project">
|
|
159
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
160
|
+
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
|
161
|
+
</svg>
|
|
162
|
+
</a>
|
|
163
|
+
` : '';
|
|
164
|
+
|
|
165
|
+
const html = `
|
|
166
|
+
<div class="project-detail">
|
|
167
|
+
<button class="back-button">
|
|
168
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
169
|
+
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
|
170
|
+
</svg>
|
|
171
|
+
View all projects
|
|
172
|
+
</button>
|
|
173
|
+
|
|
174
|
+
<div class="project-item selected">
|
|
175
|
+
<div class="project-name">${project.name}</div>
|
|
176
|
+
<div class="project-actions">
|
|
177
|
+
<div class="project-badges">${badges.join(' ')}</div>
|
|
178
|
+
${downloadBtn}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div class="detail-meta">
|
|
183
|
+
<div class="detail-meta-item">
|
|
184
|
+
<span class="meta-label">Created:</span>
|
|
185
|
+
<span class="meta-value">${formatDate(project.createdAt)}</span>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="detail-meta-item">
|
|
188
|
+
<span class="meta-label">Last Updated:</span>
|
|
189
|
+
<span class="meta-value">${formatDate(project.updatedAt)}</span>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
${project.readme ? (() => {
|
|
194
|
+
const readmeUrl = this.getProjectUrl(project);
|
|
195
|
+
return `
|
|
196
|
+
<div class="detail-section">
|
|
197
|
+
<div class="detail-section-header">
|
|
198
|
+
<h3>Documentation</h3>
|
|
199
|
+
${readmeUrl ? `
|
|
200
|
+
<a href="${readmeUrl}"
|
|
201
|
+
target="_blank"
|
|
202
|
+
rel="noopener"
|
|
203
|
+
class="github-link"
|
|
204
|
+
title="View on GitHub/GitLab">
|
|
205
|
+
${githubIcon(14)}
|
|
206
|
+
</a>
|
|
207
|
+
` : ''}
|
|
208
|
+
</div>
|
|
209
|
+
<div class="detail-readme">${this.renderMarkdown(project.readme, project.path)}</div>
|
|
210
|
+
</div>
|
|
211
|
+
`;
|
|
212
|
+
})() : ''}
|
|
213
|
+
</div>
|
|
214
|
+
`;
|
|
215
|
+
this.innerHTML = DOMPurify.sanitize(html);
|
|
216
|
+
|
|
217
|
+
// Add back button listener
|
|
218
|
+
const backButton = this.querySelector('.back-button');
|
|
219
|
+
if (backButton) {
|
|
220
|
+
backButton.addEventListener('click', () => {
|
|
221
|
+
this.showProjectList();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get remote URL for a project (handles submodules)
|
|
228
|
+
*/
|
|
229
|
+
private getProjectUrl(project: ProjectMetadata): string | null {
|
|
230
|
+
const git = project.git || this.gitInfo;
|
|
231
|
+
if (!git?.repoUrl || !git?.commitHash) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!isSafeUrl(git.repoUrl)) {
|
|
236
|
+
console.warn('Invalid URL protocol in git.repoUrl:', git.repoUrl);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Build the URL - works for both GitHub and GitLab
|
|
241
|
+
// Note: These values are escaped when inserted into HTML
|
|
242
|
+
return `${git.repoUrl}/-/tree/${git.commitHash}/`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Render markdown content (used for live doc display)
|
|
247
|
+
*/
|
|
248
|
+
private renderMarkdown(markdown: string, projectPath: string): string {
|
|
249
|
+
marked.use(baseUrl(`/${projectPath}/`));
|
|
250
|
+
|
|
251
|
+
const html = marked.parse(markdown) as string;
|
|
252
|
+
return DOMPurify.sanitize(html, {
|
|
253
|
+
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'img', 'ul', 'ol', 'li', 'blockquote'],
|
|
254
|
+
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'target', 'rel'],
|
|
255
|
+
ALLOW_DATA_ATTR: false,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Render a single project item
|
|
261
|
+
*/
|
|
262
|
+
private renderProjectItem(project: ProjectMetadata): string {
|
|
263
|
+
const isSelected = project.id === this.selectedProjectId;
|
|
264
|
+
const selectedClass = isSelected ? ' selected' : '';
|
|
265
|
+
|
|
266
|
+
const badges = [];
|
|
267
|
+
if (project.schematics.length > 0) {
|
|
268
|
+
badges.push(`<span class="badge badge-sch">Sch</span>`);
|
|
269
|
+
}
|
|
270
|
+
if (project.pcb) {
|
|
271
|
+
badges.push(`<span class="badge badge-pcb">PCB</span>`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const downloadBtn = project.zip ? `
|
|
275
|
+
<a href="${project.zip}" download class="download-btn" title="Download project">
|
|
276
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
277
|
+
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
|
278
|
+
</svg>
|
|
279
|
+
</a>
|
|
280
|
+
` : '';
|
|
281
|
+
|
|
282
|
+
return `
|
|
283
|
+
<li class="project-item${selectedClass}" data-project-id="${project.id}">
|
|
284
|
+
<div class="project-name">${project.name}</div>
|
|
285
|
+
<div class="project-actions">
|
|
286
|
+
<div class="project-badges">${badges.join(' ')}</div>
|
|
287
|
+
${downloadBtn}
|
|
288
|
+
</div>
|
|
289
|
+
</li>
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Group projects by base name
|
|
295
|
+
*/
|
|
296
|
+
private groupProjects(): Map<string, ProjectMetadata[]> {
|
|
297
|
+
const grouped = new Map<string, ProjectMetadata[]>();
|
|
298
|
+
|
|
299
|
+
for (const project of this.projects) {
|
|
300
|
+
// Extract base name (e.g., "deloop" from "deloop_mk0" or "deloop_mk1")
|
|
301
|
+
const baseName = this.extractBaseName(project.id);
|
|
302
|
+
|
|
303
|
+
if (!grouped.has(baseName)) {
|
|
304
|
+
grouped.set(baseName, []);
|
|
305
|
+
}
|
|
306
|
+
grouped.get(baseName)!.push(project);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return grouped;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Extract base name from project ID
|
|
314
|
+
*/
|
|
315
|
+
private extractBaseName(projectId: string): string {
|
|
316
|
+
// Try to find common prefixes like "deloop", "pedal", etc.
|
|
317
|
+
const match = projectId.match(/^([a-z]+)_/);
|
|
318
|
+
if (match) {
|
|
319
|
+
return match[1];
|
|
320
|
+
}
|
|
321
|
+
return projectId;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Register custom element
|
|
326
|
+
customElements.define('project-list', ProjectList);
|