/ concept-collection / minwebide-demo
Sign in
concept-collection / minwebide-demo
minwebide-demo / src / landing.ts
157 lines · 4.9 KBCodeBlameHistory
68d072bProject picker landing page with per-project file systemsJeremy Magland 1import { applyThemeToElement, type WorkbenchTheme } from 'minwebide';
2import { createProject, deleteProject, duplicateProject, listProjects, nextUntitledName, openProjectFileSystem, renameProject, type ProjectInfo } from './projects';
3import { sampleWorkspace } from './sampleWorkspace';
4import { generateSampleImage } from './sampleImage';
5import './landing.css';
7function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
8 const node = document.createElement(tag);
9 if (className) {
10 node.className = className;
11 }
12 if (text !== undefined) {
13 node.textContent = text;
14 }
15 return node;
18function openProject(id: string): void {
19 location.hash = `#/project/${id}`;
22async function createSampleProject(): Promise<ProjectInfo> {
23 const project = createProject(nextUntitledName('sample'));
24 const fs = await openProjectFileSystem(project.id);
25 try {
26 await fs.seed({
27 ...sampleWorkspace,
28 '/assets/banner.png': await generateSampleImage(),
29 });
30 } finally {
31 fs.dispose();
32 }
33 return project;
36async function createEmptyProject(): Promise<ProjectInfo> {
37 const project = createProject(nextUntitledName());
38 const fs = await openProjectFileSystem(project.id);
39 try {
40 await fs.seed({
41 '/README.md': `# ${project.name}\n\nAn empty minwebide project. Files live in your browser's IndexedDB.\n`,
42 });
43 } finally {
44 fs.dispose();
45 }
46 return project;
49function formatWhen(timestamp: number): string {
50 const delta = Date.now() - timestamp;
51 if (delta < 60_000) {
52 return 'just now';
53 }
54 if (delta < 3_600_000) {
55 return `${Math.round(delta / 60_000)}m ago`;
56 }
57 if (delta < 86_400_000) {
58 return `${Math.round(delta / 3_600_000)}h ago`;
59 }
60 return new Date(timestamp).toLocaleDateString();
63/** Renders the project-picker landing page. Returns a disposable view. */
64export function renderLanding(container: HTMLElement, theme: WorkbenchTheme): { dispose(): void } {
65 const root = el('div', 'landing');
66 applyThemeToElement(theme, root);
67 container.appendChild(root);
68 document.title = 'minwebide demo';
70 const inner = el('div', 'landing-inner');
71 root.appendChild(inner);
73 const header = el('header', 'landing-header');
74 header.appendChild(el('h1', undefined, 'minwebide'));
75 header.appendChild(el('p', 'landing-subtitle', 'A minimalistic web IDE built from VS Code’s own source. Projects are stored in your browser.'));
76 const ghLink = el('a', 'landing-link', 'github.com/magland/minwebide');
77 ghLink.href = 'https://github.com/magland/minwebide';
78 header.appendChild(ghLink);
79 inner.appendChild(header);
81 // start section
82 const start = el('section', 'landing-section');
83 start.appendChild(el('h2', undefined, 'Start'));
84 const startButtons = el('div', 'landing-start');
85 const newButton = el('button', 'landing-button primary', 'New project');
86 newButton.addEventListener('click', async () => {
87 newButton.disabled = true;
88 openProject((await createEmptyProject()).id);
89 });
90 const sampleButton = el('button', 'landing-button', 'New sample project');
91 sampleButton.title = 'Seeded with runnable scripts, a CSV table, markdown preview, and plots';
92 sampleButton.addEventListener('click', async () => {
93 sampleButton.disabled = true;
94 openProject((await createSampleProject()).id);
95 });
96 startButtons.append(newButton, sampleButton);
97 start.appendChild(startButtons);
98 inner.appendChild(start);
100 // projects section
101 const section = el('section', 'landing-section');
102 section.appendChild(el('h2', undefined, 'Projects'));
103 const list = el('div', 'landing-projects');
104 section.appendChild(list);
105 inner.appendChild(section);
107 const renderList = () => {
108 list.textContent = '';
109 const projects = listProjects();
110 if (projects.length === 0) {
111 list.appendChild(el('div', 'landing-empty', 'No projects yet.'));
112 return;
113 }
114 for (const project of projects) {
115 const row = el('div', 'landing-project');
117 const name = el('a', 'landing-project-name', project.name);
118 name.href = `#/project/${project.id}`;
119 row.appendChild(name);
121 row.appendChild(el('span', 'landing-project-meta', `opened ${formatWhen(project.lastOpenedAt)}`));
123 const actions = el('span', 'landing-project-actions');
124 const action = (label: string, handler: () => void | Promise<void>) => {
125 const button = el('button', 'landing-action', label);
126 button.addEventListener('click', () => handler());
127 actions.appendChild(button);
128 };
129 action('Rename', () => {
130 const name = prompt('Project name', project.name);
131 if (name !== null) {
132 renameProject(project.id, name);
133 renderList();
134 }
135 });
136 action('Duplicate', async () => {
137 await duplicateProject(project.id);
138 renderList();
139 });
140 action('Delete', async () => {
141 if (confirm(`Delete project "${project.name}" and all of its files?`)) {
142 await deleteProject(project.id);
143 renderList();
144 }
145 });
146 row.appendChild(actions);
147 list.appendChild(row);
148 }
149 };
150 renderList();
152 return {
153 dispose() {
154 root.remove();
155 },
156 };
moveopenescclose