@liqvid/studio 1.0.0-alpha.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 +9 -0
- package/dist/esm/LiqvidDevToolsProvider.js +49 -0
- package/dist/esm/api/contract.mjs +48 -0
- package/dist/esm/api/project-meta.mjs +33 -0
- package/dist/esm/api/recording.mjs +156 -0
- package/dist/esm/api/root.mjs +4 -0
- package/dist/esm/api/static-file.mjs +82 -0
- package/dist/esm/api/types.mjs +1 -0
- package/dist/esm/client.mjs +98 -0
- package/dist/esm/conventions.mjs +3 -0
- package/dist/esm/index.mjs +20 -0
- package/dist/esm/initialize.mjs +50 -0
- package/dist/esm/jobs/watch-assets.mjs +99 -0
- package/dist/esm/jobs/watch-project-files.mjs +216 -0
- package/dist/esm/next/api.mjs +48 -0
- package/dist/esm/next/page.js +15 -0
- package/dist/esm/pages/NewProjectButton.js +62 -0
- package/dist/esm/pages/RebuildButton.js +24 -0
- package/dist/esm/pages/root-actions.js +151 -0
- package/dist/esm/pages/root.js +25 -0
- package/dist/esm/pages/root.module.css +326 -0
- package/dist/esm/palette.css +279 -0
- package/dist/esm/providers/hosting/github-pages.mjs +10 -0
- package/dist/esm/providers/hosting/liqvid-studio.mjs +8 -0
- package/dist/esm/providers/hosting/s3.mjs +9 -0
- package/dist/esm/providers/hosting/sftp.mjs +22 -0
- package/dist/esm/providers/index.mjs +10 -0
- package/dist/esm/providers/social/bluesky.mjs +8 -0
- package/dist/esm/providers/social/facebook.mjs +8 -0
- package/dist/esm/providers/social/instagram.mjs +8 -0
- package/dist/esm/providers/social/twitter.mjs +7 -0
- package/dist/esm/providers/social/youtube.mjs +7 -0
- package/dist/esm/providers/types.mjs +1 -0
- package/dist/esm/publish.mjs +37 -0
- package/dist/esm/recording/RecordingControl.js +110 -0
- package/dist/esm/recording/RecordingControl.module.css +0 -0
- package/dist/esm/recording/RecordingDialog.js +114 -0
- package/dist/esm/recording/RecordingDialog.module.css +194 -0
- package/dist/esm/schemas/liqvid-config.mjs +32 -0
- package/dist/esm/schemas/project.mjs +27 -0
- package/dist/esm/schemas/recording-meta.mjs +11 -0
- package/dist/esm/types/assets.mjs +1 -0
- package/dist/esm/types.mjs +12 -0
- package/dist/esm/ui/Dialog.js +71 -0
- package/dist/esm/ui/DockableDialog.js +131 -0
- package/dist/esm/ui/DockableDialog.module.css +63 -0
- package/dist/esm/ui/RadioTabs.js +13 -0
- package/dist/esm/ui/RadioTabs.module.css +54 -0
- package/dist/esm/ui/Tabs.js +29 -0
- package/dist/esm/ui/Tabs.module.css +31 -0
- package/dist/esm/ui/Toast.js +64 -0
- package/dist/esm/ui/Toast.module.css +50 -0
- package/dist/esm/ui/Toaster.js +13 -0
- package/dist/esm/ui/Toaster.module.css +9 -0
- package/dist/esm/ui/test.js +14 -0
- package/dist/esm/utils/dom.mjs +6 -0
- package/dist/esm/utils/fs.mjs +94 -0
- package/dist/esm/utils/misc.mjs +15 -0
- package/dist/esm/utils/react.mjs +8 -0
- package/dist/esm/utils/rsync.mjs +57 -0
- package/dist/templates/project.json.hbs +5 -0
- package/dist/templates/projects/code/page.tsx.hbs +23 -0
- package/dist/templates/projects/code/src/assets.ts.hbs +9 -0
- package/dist/templates/projects/code/src/client.tsx.hbs +22 -0
- package/dist/templates/projects/code/src/helpers.ts.hbs +21 -0
- package/dist/templates/projects/code/src/highlights.ts.hbs +3 -0
- package/dist/templates/projects/code/src/markers.ts.hbs +13 -0
- package/dist/templates/projects/code/src/project.ts.hbs +23 -0
- package/dist/templates/projects/code/template.json +3 -0
- package/dist/templates/projects/default/page.tsx.hbs +23 -0
- package/dist/templates/projects/default/src/assets.ts.hbs +9 -0
- package/dist/templates/projects/default/src/client.tsx.hbs +22 -0
- package/dist/templates/projects/default/src/helpers.ts.hbs +21 -0
- package/dist/templates/projects/default/src/highlights.ts.hbs +3 -0
- package/dist/templates/projects/default/src/markers.ts.hbs +13 -0
- package/dist/templates/projects/default/src/project.ts.hbs +23 -0
- package/dist/templates/projects/default/template.json +4 -0
- package/dist/templates/types.ts.hbs +20 -0
- package/dist/types/LiqvidDevToolsProvider.d.ts +12 -0
- package/dist/types/api/contract.d.mts +66 -0
- package/dist/types/api/project-meta.d.mts +1 -0
- package/dist/types/api/recording.d.mts +5 -0
- package/dist/types/api/root.d.mts +1 -0
- package/dist/types/api/static-file.d.mts +6 -0
- package/dist/types/api/types.d.mts +1 -0
- package/dist/types/client.d.mts +43 -0
- package/dist/types/conventions.d.mts +3 -0
- package/dist/types/index.d.mts +19 -0
- package/dist/types/initialize.d.mts +14 -0
- package/dist/types/jobs/watch-assets.d.mts +18 -0
- package/dist/types/jobs/watch-project-files.d.mts +4 -0
- package/dist/types/next/api.d.mts +14 -0
- package/dist/types/next/page.d.ts +4 -0
- package/dist/types/pages/NewProjectButton.d.ts +1 -0
- package/dist/types/pages/RebuildButton.d.ts +1 -0
- package/dist/types/pages/root-actions.d.ts +29 -0
- package/dist/types/pages/root.d.ts +1 -0
- package/dist/types/providers/hosting/github-pages.d.mts +11 -0
- package/dist/types/providers/hosting/liqvid-studio.d.mts +10 -0
- package/dist/types/providers/hosting/s3.d.mts +11 -0
- package/dist/types/providers/hosting/sftp.d.mts +13 -0
- package/dist/types/providers/index.d.mts +10 -0
- package/dist/types/providers/social/bluesky.d.mts +10 -0
- package/dist/types/providers/social/facebook.d.mts +10 -0
- package/dist/types/providers/social/instagram.d.mts +10 -0
- package/dist/types/providers/social/twitter.d.mts +9 -0
- package/dist/types/providers/social/youtube.d.mts +9 -0
- package/dist/types/providers/types.d.mts +9 -0
- package/dist/types/publish.d.mts +1 -0
- package/dist/types/recording/RecordingControl.d.ts +16 -0
- package/dist/types/recording/RecordingDialog.d.ts +10 -0
- package/dist/types/schemas/liqvid-config.d.mts +51 -0
- package/dist/types/schemas/project.d.mts +51 -0
- package/dist/types/schemas/recording-meta.d.mts +17 -0
- package/dist/types/types/assets.d.mts +3 -0
- package/dist/types/types.d.mts +20 -0
- package/dist/types/ui/Dialog.d.ts +31 -0
- package/dist/types/ui/DockableDialog.d.ts +25 -0
- package/dist/types/ui/RadioTabs.d.ts +18 -0
- package/dist/types/ui/Tabs.d.ts +9 -0
- package/dist/types/ui/Toast.d.ts +23 -0
- package/dist/types/ui/Toaster.d.ts +6 -0
- package/dist/types/ui/test.d.ts +9 -0
- package/dist/types/utils/dom.d.mts +3 -0
- package/dist/types/utils/fs.d.mts +32 -0
- package/dist/types/utils/misc.d.mts +4 -0
- package/dist/types/utils/react.d.mts +5 -0
- package/dist/types/utils/rsync.d.mts +10 -0
- package/package.json +94 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { Duration } from "@liqvid/duration";
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
import { PROJECT_FILE, PROJECT_META_FILE } from "../conventions.mjs";
|
|
7
|
+
import { AutoGenProjectMeta, ProjectJson, } from "../schemas/project.mjs";
|
|
8
|
+
import { getBiomePath, loadJson, walkDir } from "../utils/fs.mjs";
|
|
9
|
+
import { ASSETS_DIRNAME } from "./watch-assets.mjs";
|
|
10
|
+
const TARGET_DIR = path.join(process.cwd(), "app");
|
|
11
|
+
export async function watchProjectFiles(projects) {
|
|
12
|
+
// initial check
|
|
13
|
+
await walkDir(TARGET_DIR, async ({ basename, dirname, filename }) => {
|
|
14
|
+
// initialize project metadata
|
|
15
|
+
if (basename === "project.json") {
|
|
16
|
+
await createProject({ basename, dirname, filename, projects });
|
|
17
|
+
}
|
|
18
|
+
}, ({ basename }) => {
|
|
19
|
+
if (basename === ".liqvid")
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
});
|
|
23
|
+
// set up watch
|
|
24
|
+
fs.watch(TARGET_DIR, { recursive: true }, async (_eventName, relPath) => {
|
|
25
|
+
if (!relPath)
|
|
26
|
+
return;
|
|
27
|
+
const filename = path.join(TARGET_DIR, relPath);
|
|
28
|
+
const basename = path.basename(filename);
|
|
29
|
+
const dirname = path.dirname(filename);
|
|
30
|
+
switch (basename) {
|
|
31
|
+
case PROJECT_FILE: {
|
|
32
|
+
await handleProjectJson({
|
|
33
|
+
basename,
|
|
34
|
+
dirname,
|
|
35
|
+
filename,
|
|
36
|
+
projects,
|
|
37
|
+
});
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case PROJECT_META_FILE: {
|
|
41
|
+
await handleProjectMeta({
|
|
42
|
+
basename,
|
|
43
|
+
dirname,
|
|
44
|
+
filename,
|
|
45
|
+
projects,
|
|
46
|
+
});
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// values
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Handle new or deleted project.json files
|
|
55
|
+
*/
|
|
56
|
+
async function handleProjectJson({ dirname, filename, projects }) {
|
|
57
|
+
const entryFile = path.join(dirname, "page.tsx");
|
|
58
|
+
if (!fs.existsSync(entryFile)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const biomePath = await getBiomePath(dirname);
|
|
62
|
+
// read project file
|
|
63
|
+
const $project = await loadJson(ProjectJson, filename);
|
|
64
|
+
if ($project.isErr) {
|
|
65
|
+
const error = $project.unwrapErr();
|
|
66
|
+
if (error instanceof SyntaxError) {
|
|
67
|
+
console.error(`JSON error in ${filename}`, error);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.error(`invalid ProjectJson format in ${filename}`, error);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const project = $project.unwrap();
|
|
75
|
+
const meta = {
|
|
76
|
+
...project,
|
|
77
|
+
aspectRatio: parseAspectRatio(project.aspectRatio),
|
|
78
|
+
duration: new Duration({ milliseconds: 1000 }),
|
|
79
|
+
openGraph: hasOpenGraphImage(dirname),
|
|
80
|
+
path: path.relative(TARGET_DIR, dirname),
|
|
81
|
+
twitter: hasTwitterImage(dirname),
|
|
82
|
+
};
|
|
83
|
+
projects[meta.path] = meta;
|
|
84
|
+
await generateProjectDir({ biomePath, dirname });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Handle new or deleted project.json files
|
|
88
|
+
*/
|
|
89
|
+
async function createProject({ dirname, filename, projects }) {
|
|
90
|
+
const entryFile = path.join(dirname, "page.tsx");
|
|
91
|
+
if (!fs.existsSync(entryFile)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const biomePath = await getBiomePath(dirname);
|
|
95
|
+
// read project file
|
|
96
|
+
const $project = await loadJson(ProjectJson, filename);
|
|
97
|
+
if ($project.isErr) {
|
|
98
|
+
const error = $project.unwrapErr();
|
|
99
|
+
if (error instanceof SyntaxError) {
|
|
100
|
+
console.error(`JSON error in ${filename}`, error);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.error(`invalid ProjectJson format in ${filename}`, error);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const project = $project.unwrap();
|
|
108
|
+
// read duration
|
|
109
|
+
const $autoGenMeta = await loadJson(AutoGenProjectMeta, path.join(dirname, ".liqvid", PROJECT_META_FILE));
|
|
110
|
+
if ($autoGenMeta.isErr) {
|
|
111
|
+
const error = $autoGenMeta.unwrapErr();
|
|
112
|
+
if (error instanceof SyntaxError) {
|
|
113
|
+
console.error(`JSON error in ${filename}`, error);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
else if (error instanceof ZodError) {
|
|
117
|
+
console.error(`invalid AutoGenProjectMeta format in ${filename}`, error);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const duration = $autoGenMeta.match({
|
|
122
|
+
Err: () => new Duration({ minutes: 1 }),
|
|
123
|
+
Ok: ({ duration }) => new Duration(duration),
|
|
124
|
+
});
|
|
125
|
+
const meta = {
|
|
126
|
+
...project,
|
|
127
|
+
aspectRatio: parseAspectRatio(project.aspectRatio),
|
|
128
|
+
duration,
|
|
129
|
+
openGraph: hasOpenGraphImage(dirname),
|
|
130
|
+
path: path.relative(TARGET_DIR, dirname),
|
|
131
|
+
twitter: hasTwitterImage(dirname),
|
|
132
|
+
};
|
|
133
|
+
projects[meta.path] = meta;
|
|
134
|
+
await generateProjectDir({ biomePath, dirname });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Handle auto-generated project-meta.json files
|
|
138
|
+
*/
|
|
139
|
+
async function handleProjectMeta({ dirname: dotLiqvidDir, filename, projects, }) {
|
|
140
|
+
const projectPath = path.relative(TARGET_DIR, path.dirname(dotLiqvidDir));
|
|
141
|
+
const $projectMeta = await loadJson(AutoGenProjectMeta, filename);
|
|
142
|
+
if ($projectMeta.isErr) {
|
|
143
|
+
const error = $projectMeta.unwrapErr();
|
|
144
|
+
if (error instanceof SyntaxError) {
|
|
145
|
+
console.error(`JSON error in ${filename}`, error);
|
|
146
|
+
}
|
|
147
|
+
else if ($projectMeta instanceof ZodError) {
|
|
148
|
+
console.error(`invalid ProjectMeta format in ${filename}`, error);
|
|
149
|
+
}
|
|
150
|
+
console.error(error);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const projectMeta = $projectMeta.unwrap();
|
|
154
|
+
const project = projects[projectPath];
|
|
155
|
+
if (!project) {
|
|
156
|
+
console.error(`could not find project ${projectPath}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
project.duration = new Duration(projectMeta.duration);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether a project has an Open Graph image defined.
|
|
163
|
+
*/
|
|
164
|
+
function hasOpenGraphImage(dirname) {
|
|
165
|
+
const filenames = [
|
|
166
|
+
"opengraph-image.gif",
|
|
167
|
+
"opengraph-image.jpeg",
|
|
168
|
+
"opengraph-image.jpg",
|
|
169
|
+
"opengraph-image.png",
|
|
170
|
+
];
|
|
171
|
+
return filenames.some((f) => fs.existsSync(path.join(dirname, f)));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether a project has a Twitter image defined.
|
|
175
|
+
*/
|
|
176
|
+
function hasTwitterImage(dirname) {
|
|
177
|
+
const filenames = [
|
|
178
|
+
"twitter-image.gif",
|
|
179
|
+
"twitter-image.jpeg",
|
|
180
|
+
"twitter-image.jpg",
|
|
181
|
+
"twitter-image.png",
|
|
182
|
+
];
|
|
183
|
+
return filenames.some((f) => fs.existsSync(path.join(dirname, f)));
|
|
184
|
+
}
|
|
185
|
+
function parseAspectRatio(value) {
|
|
186
|
+
const defaultValue = { height: 9, width: 16 };
|
|
187
|
+
switch (typeof value) {
|
|
188
|
+
case "object": {
|
|
189
|
+
if (value === null)
|
|
190
|
+
return defaultValue;
|
|
191
|
+
if (Array.isArray(value)) {
|
|
192
|
+
const [width, height] = value;
|
|
193
|
+
if (typeof width === "number" && typeof height === "number") {
|
|
194
|
+
return { height, width };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "string": {
|
|
200
|
+
const [width, height] = value.split(":").map(Number);
|
|
201
|
+
if (typeof width === "number" && typeof height === "number") {
|
|
202
|
+
return { height, width };
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "undefined":
|
|
207
|
+
return defaultValue;
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`Invalid aspect ratio: ${value}`);
|
|
210
|
+
}
|
|
211
|
+
async function generateProjectDir({ dirname, }) {
|
|
212
|
+
const assetsDir = path.join(dirname, ASSETS_DIRNAME);
|
|
213
|
+
if (!fs.existsSync(assetsDir)) {
|
|
214
|
+
await fsp.mkdir(assetsDir);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as url from "node:url";
|
|
2
|
+
import { StatusCodes } from "http-status-codes";
|
|
3
|
+
import { listRecordingsOperation, saveRecordingOperation, setProjectMetaOperation, staticFileOperation, } from "../api/contract.mjs";
|
|
4
|
+
import { setProjectMeta } from "../api/project-meta.mjs";
|
|
5
|
+
import { listRecordings, saveRecording } from "../api/recording.mjs";
|
|
6
|
+
import { getRoot } from "../api/root.mjs";
|
|
7
|
+
import { serveStaticFile } from "../api/static-file.mjs";
|
|
8
|
+
import { initializeServer } from "../initialize.mjs";
|
|
9
|
+
/**
|
|
10
|
+
* Liqvid server GET handler
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(req, { params }) {
|
|
13
|
+
const paramsObject = await params;
|
|
14
|
+
const keys = Object.keys(paramsObject);
|
|
15
|
+
const routeParams = keys.length === 1 ? paramsObject[keys[0]] : [];
|
|
16
|
+
const route = "/" + routeParams.join("/");
|
|
17
|
+
const { search } = url.parse(req.url, true);
|
|
18
|
+
const searchParams = new URLSearchParams(search ?? "");
|
|
19
|
+
await initializeServer();
|
|
20
|
+
switch (route) {
|
|
21
|
+
case "/":
|
|
22
|
+
return getRoot();
|
|
23
|
+
case listRecordingsOperation.endpoint:
|
|
24
|
+
return listRecordings(searchParams);
|
|
25
|
+
case staticFileOperation.endpoint:
|
|
26
|
+
return serveStaticFile(searchParams);
|
|
27
|
+
}
|
|
28
|
+
return Response.json({ error: "not_found" }, { status: StatusCodes.NOT_FOUND });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Liqvid server POST handler
|
|
32
|
+
*/
|
|
33
|
+
export async function POST(req, { params }) {
|
|
34
|
+
const paramsObject = await params;
|
|
35
|
+
const keys = Object.keys(paramsObject);
|
|
36
|
+
const routeParams = keys.length === 1 ? paramsObject[keys[0]] : [];
|
|
37
|
+
const route = "/" + routeParams.join("/");
|
|
38
|
+
const { search } = url.parse(req.url, true);
|
|
39
|
+
const searchParams = new URLSearchParams(search ?? "");
|
|
40
|
+
await initializeServer();
|
|
41
|
+
switch (route) {
|
|
42
|
+
case setProjectMetaOperation.endpoint:
|
|
43
|
+
return setProjectMeta(searchParams, await req.json());
|
|
44
|
+
case saveRecordingOperation.endpoint:
|
|
45
|
+
return saveRecording(searchParams, await req.formData());
|
|
46
|
+
}
|
|
47
|
+
return Response.json({ error: "not_found" }, { status: StatusCodes.NOT_FOUND });
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { Homepage } from "../pages/root";
|
|
4
|
+
export default async function Pages({ params: asyncParams, searchParams: asyncSearchParams, }) {
|
|
5
|
+
const params = await asyncParams;
|
|
6
|
+
const paramsKeys = Object.keys(params);
|
|
7
|
+
const searchParams = await asyncSearchParams;
|
|
8
|
+
const route = "/" + (paramsKeys.length === 1 ? params[paramsKeys[0]].join("/") : "");
|
|
9
|
+
switch (route) {
|
|
10
|
+
case "/":
|
|
11
|
+
return _jsx(Homepage, {});
|
|
12
|
+
}
|
|
13
|
+
notFound();
|
|
14
|
+
return (_jsx("pre", { children: JSON.stringify({ params, paramsKeys, route, searchParams }) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Dialog } from "@base-ui/react/dialog";
|
|
4
|
+
import { Select } from "@base-ui/react/select";
|
|
5
|
+
import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react";
|
|
6
|
+
import { useCallback, useEffect, useState } from "react";
|
|
7
|
+
import { createProjectAction, loadTemplatesAction, } from "./root-actions";
|
|
8
|
+
import styles from "./root.module.css";
|
|
9
|
+
export function NewProjectButton() {
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
const [name, setName] = useState("");
|
|
12
|
+
const [projectPath, setProjectPath] = useState("");
|
|
13
|
+
const [templateId, setTemplateId] = useState("");
|
|
14
|
+
const [templates, setTemplates] = useState([]);
|
|
15
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
// Load templates when dialog opens
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (open) {
|
|
20
|
+
loadTemplatesAction().then((loadedTemplates) => {
|
|
21
|
+
setTemplates(loadedTemplates);
|
|
22
|
+
// Select the default template, or the first one if none is marked as default
|
|
23
|
+
// (templates are sorted with default first)
|
|
24
|
+
if (loadedTemplates.length > 0) {
|
|
25
|
+
const defaultTemplate = loadedTemplates.find((t) => t.default);
|
|
26
|
+
setTemplateId(defaultTemplate?.id ?? loadedTemplates[0].id);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}, [open]);
|
|
31
|
+
const closeDialog = useCallback(() => {
|
|
32
|
+
setOpen(false);
|
|
33
|
+
setName("");
|
|
34
|
+
setProjectPath("");
|
|
35
|
+
setTemplateId("");
|
|
36
|
+
setError(null);
|
|
37
|
+
}, []);
|
|
38
|
+
const handleSubmit = useCallback(async (e) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
setIsCreating(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
try {
|
|
43
|
+
const result = await createProjectAction({
|
|
44
|
+
name,
|
|
45
|
+
projectPath,
|
|
46
|
+
templateId,
|
|
47
|
+
});
|
|
48
|
+
if (result.success) {
|
|
49
|
+
closeDialog();
|
|
50
|
+
// Refresh the page to show the new project
|
|
51
|
+
window.location.reload();
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
setError(result.error ?? "Failed to create project");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
setIsCreating(false);
|
|
59
|
+
}
|
|
60
|
+
}, [name, projectPath, templateId, closeDialog]);
|
|
61
|
+
return (_jsxs(Dialog.Root, { onOpenChange: setOpen, open: open, children: [_jsx(Dialog.Trigger, { className: styles.newProjectButton, render: _jsx("button", { title: "Create a new project", type: "button" }), children: "+ New Project" }), _jsxs(Dialog.Portal, { children: [_jsx(Dialog.Backdrop, { className: styles.dialogOverlay }), _jsxs(Dialog.Popup, { className: styles.dialog, children: [_jsx(Dialog.Title, { className: styles.dialogTitle, children: "Create New Project" }), _jsxs("form", { className: styles.dialogForm, onSubmit: handleSubmit, children: [_jsxs("div", { className: styles.formField, children: [_jsx("label", { htmlFor: "project-name", children: "Project Name" }), _jsx("input", { autoComplete: "off", disabled: isCreating, id: "project-name", onChange: (e) => setName(e.target.value), placeholder: "My Project", required: true, type: "text", value: name })] }), _jsxs("div", { className: styles.formField, children: [_jsx("label", { htmlFor: "project-path", children: "Project Path" }), _jsx("input", { autoComplete: "off", disabled: isCreating, id: "project-path", onChange: (e) => setProjectPath(e.target.value), placeholder: "category/project-slug", required: true, type: "text", value: projectPath }), _jsx("span", { className: styles.fieldHint, children: "Path under app/" })] }), _jsxs("div", { className: styles.formField, children: [_jsx("label", { htmlFor: "project-template", children: "Template" }), _jsxs(Select.Root, { disabled: isCreating || templates.length === 0, onValueChange: (value) => value && setTemplateId(value), value: templateId, children: [_jsxs(Select.Trigger, { className: styles.selectTrigger, id: "project-template", children: [_jsx(Select.Value, { placeholder: "Select a template" }), _jsx(Select.Icon, { className: styles.selectIcon, children: _jsx(CaretDownIcon, {}) })] }), _jsx(Select.Portal, { children: _jsx(Select.Positioner, { sideOffset: 4, children: _jsx(Select.Popup, { className: styles.selectContent, children: _jsx(Select.List, { className: styles.selectViewport, children: templates.map((template) => (_jsxs(Select.Item, { className: styles.selectItem, value: template.id, children: [_jsx(Select.ItemText, { children: template.name }), _jsx(Select.ItemIndicator, { className: styles.selectItemIndicator, children: _jsx(CheckIcon, {}) })] }, template.id))) }) }) }) })] })] }), error && _jsx("div", { className: styles.error, children: error }), _jsxs("div", { className: styles.dialogActions, children: [_jsx(Dialog.Close, { className: styles.cancelButton, disabled: isCreating, render: _jsx("button", { type: "button" }), children: "Cancel" }), _jsx("button", { className: styles.submitButton, disabled: isCreating || !name || !projectPath || !templateId, type: "submit", children: isCreating ? "Creating..." : "Create Project" })] })] })] })] })] }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { rebuildAction } from "./root-actions";
|
|
5
|
+
import styles from "./root.module.css";
|
|
6
|
+
export function RebuildButton() {
|
|
7
|
+
const [isBuilding, setIsBuilding] = useState(false);
|
|
8
|
+
const handleRebuild = useCallback(async () => {
|
|
9
|
+
setIsBuilding(true);
|
|
10
|
+
try {
|
|
11
|
+
const result = await rebuildAction();
|
|
12
|
+
if (result.success) {
|
|
13
|
+
// Optionally show success feedback
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
console.error("Build failed");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
setIsBuilding(false);
|
|
21
|
+
}
|
|
22
|
+
}, []);
|
|
23
|
+
return (_jsx("button", { className: styles.rebuildButton, disabled: isBuilding, onClick: handleRebuild, title: "Rebuild projects for production", type: "button", children: isBuilding ? "🔨 Building..." : "📦 Rebuild" }));
|
|
24
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import Handlebars from "handlebars";
|
|
6
|
+
import { runNextBuild } from "../initialize.mjs";
|
|
7
|
+
export async function rebuildAction() {
|
|
8
|
+
try {
|
|
9
|
+
await runNextBuild();
|
|
10
|
+
return { success: true };
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
console.error("Failed to run next build:", e);
|
|
14
|
+
return { success: false };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
|
|
18
|
+
const PROJECT_TEMPLATES_DIR = path.join(TEMPLATES_DIR, "projects");
|
|
19
|
+
const APP_DIR = path.join(process.cwd(), "app");
|
|
20
|
+
/**
|
|
21
|
+
* Load all available project templates.
|
|
22
|
+
* Templates are sorted with default first, then alphabetically by name.
|
|
23
|
+
*/
|
|
24
|
+
export async function loadTemplatesAction() {
|
|
25
|
+
const templates = [];
|
|
26
|
+
try {
|
|
27
|
+
const entries = await fsp.readdir(PROJECT_TEMPLATES_DIR, {
|
|
28
|
+
withFileTypes: true,
|
|
29
|
+
});
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (!entry.isDirectory())
|
|
32
|
+
continue;
|
|
33
|
+
const templateDir = path.join(PROJECT_TEMPLATES_DIR, entry.name);
|
|
34
|
+
const templateJsonPath = path.join(templateDir, "template.json");
|
|
35
|
+
try {
|
|
36
|
+
const content = await fsp.readFile(templateJsonPath, "utf8");
|
|
37
|
+
const templateJson = JSON.parse(content);
|
|
38
|
+
templates.push({
|
|
39
|
+
default: templateJson.default === true,
|
|
40
|
+
id: entry.name,
|
|
41
|
+
name: templateJson.name,
|
|
42
|
+
path: templateDir,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Skip directories without valid template.json
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// If templates directory doesn't exist, return empty array
|
|
52
|
+
}
|
|
53
|
+
// Sort: default first, then alphabetically by name
|
|
54
|
+
templates.sort((a, b) => {
|
|
55
|
+
if (a.default && !b.default)
|
|
56
|
+
return -1;
|
|
57
|
+
if (!a.default && b.default)
|
|
58
|
+
return 1;
|
|
59
|
+
return a.name.localeCompare(b.name);
|
|
60
|
+
});
|
|
61
|
+
return templates;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Compile a Handlebars template and write it to the output path.
|
|
65
|
+
*/
|
|
66
|
+
async function compileTemplate(templatePath, outputPath, data) {
|
|
67
|
+
const templateContent = await fsp.readFile(templatePath, "utf8");
|
|
68
|
+
const template = Handlebars.compile(templateContent);
|
|
69
|
+
const result = template(data);
|
|
70
|
+
await fsp.writeFile(outputPath, result);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Recursively copy and compile template files from source to destination.
|
|
74
|
+
* Files ending in .hbs are compiled with Handlebars and have the .hbs extension removed.
|
|
75
|
+
* Other files are copied as-is. template.json is skipped.
|
|
76
|
+
*/
|
|
77
|
+
async function copyTemplateDir(srcDir, destDir, data) {
|
|
78
|
+
await fsp.mkdir(destDir, { recursive: true });
|
|
79
|
+
const entries = await fsp.readdir(srcDir, { withFileTypes: true });
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
82
|
+
const destName = entry.name.endsWith(".hbs")
|
|
83
|
+
? entry.name.slice(0, -4)
|
|
84
|
+
: entry.name;
|
|
85
|
+
const destPath = path.join(destDir, destName);
|
|
86
|
+
if (entry.name === "template.json") {
|
|
87
|
+
// Skip template.json
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
await copyTemplateDir(srcPath, destPath, data);
|
|
92
|
+
}
|
|
93
|
+
else if (entry.name.endsWith(".hbs")) {
|
|
94
|
+
await compileTemplate(srcPath, destPath, data);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await fsp.copyFile(srcPath, destPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function createProjectAction(input) {
|
|
102
|
+
try {
|
|
103
|
+
const { name, projectPath, templateId } = input;
|
|
104
|
+
// Validate project path
|
|
105
|
+
if (!projectPath || projectPath.includes("..")) {
|
|
106
|
+
return { error: "Invalid project path", success: false };
|
|
107
|
+
}
|
|
108
|
+
// Validate and load template
|
|
109
|
+
const templates = await loadTemplatesAction();
|
|
110
|
+
const template = templates.find((t) => t.id === templateId);
|
|
111
|
+
if (!template) {
|
|
112
|
+
return { error: "Template not found", success: false };
|
|
113
|
+
}
|
|
114
|
+
const fullProjectPath = path.join(APP_DIR, projectPath);
|
|
115
|
+
// Check if directory already exists
|
|
116
|
+
try {
|
|
117
|
+
await fsp.access(fullProjectPath);
|
|
118
|
+
return { error: "Project already exists at this path", success: false };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Directory doesn't exist, which is what we want
|
|
122
|
+
}
|
|
123
|
+
// Create base directory structure
|
|
124
|
+
await fsp.mkdir(fullProjectPath, { recursive: true });
|
|
125
|
+
await fsp.mkdir(path.join(fullProjectPath, ".liqvid", "recordings"), {
|
|
126
|
+
recursive: true,
|
|
127
|
+
});
|
|
128
|
+
const templateData = { name };
|
|
129
|
+
// Copy and compile template files
|
|
130
|
+
await copyTemplateDir(template.path, fullProjectPath, templateData);
|
|
131
|
+
// Create shared files (project.json and .liqvid files)
|
|
132
|
+
await compileTemplate(path.join(TEMPLATES_DIR, "project.json.hbs"), path.join(fullProjectPath, "project.json"), templateData);
|
|
133
|
+
// Generate .liqvid/project-meta.json (initial empty duration)
|
|
134
|
+
await fsp.writeFile(path.join(fullProjectPath, ".liqvid", "project-meta.json"), JSON.stringify({ duration: { milliseconds: 0 } }, null, 2));
|
|
135
|
+
// Generate .liqvid/types.ts (initial structure)
|
|
136
|
+
await compileTemplate(path.join(TEMPLATES_DIR, "types.ts.hbs"), path.join(fullProjectPath, ".liqvid", "types.ts"), {
|
|
137
|
+
directoryStructure: {
|
|
138
|
+
"project-meta.json": null,
|
|
139
|
+
recordings: {},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return { success: true };
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
console.error("Failed to create project:", e);
|
|
146
|
+
return {
|
|
147
|
+
error: e instanceof Error ? e.message : "Unknown error",
|
|
148
|
+
success: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatTime, formatTimeDuration } from "@liqvid/utils";
|
|
3
|
+
import { EyeIcon } from "@phosphor-icons/react/dist/ssr";
|
|
4
|
+
import { getServerState, initializeServer } from "../initialize.mjs";
|
|
5
|
+
import { NewProjectButton } from "./NewProjectButton";
|
|
6
|
+
import { RebuildButton } from "./RebuildButton";
|
|
7
|
+
import styles from "./root.module.css";
|
|
8
|
+
export async function Homepage() {
|
|
9
|
+
await initializeServer();
|
|
10
|
+
const { productionServerPort, projects } = getServerState();
|
|
11
|
+
return (_jsxs("main", { className: styles.main, children: [_jsxs("div", { className: styles.headerRow, children: [_jsx("h1", { className: styles.header, children: "Projects" }), _jsx(NewProjectButton, {}), _jsx(RebuildButton, {})] }), _jsx("ul", { className: styles.projectList, children: Object.entries(projects)
|
|
12
|
+
.sort(([, a], [, b]) => a.path.localeCompare(b.path))
|
|
13
|
+
.map(([key, project]) => (_jsxs("li", { children: [_jsxs("a", { href: project.path, children: [_jsx(Thumbnail, { ...project }), _jsxs("div", { className: "flex flex-col", children: [project.name, _jsx("pre", { className: "text-sm", children: project.path })] })] }), _jsx("div", { className: styles.actions, children: _jsx("a", { className: styles.productionLink, href: `http://localhost:${productionServerPort}/${project.path}`, rel: "noopener noreferrer", target: "_blank", title: "Preview", children: _jsx(EyeIcon, { size: 24 }) }) })] }, key))) })] }));
|
|
14
|
+
}
|
|
15
|
+
function Thumbnail({ aspectRatio, duration, path, openGraph }) {
|
|
16
|
+
return (_jsx("div", { className: styles.thumbnail, style: {
|
|
17
|
+
aspectRatio: `${aspectRatio.width} / ${aspectRatio.height}`,
|
|
18
|
+
backgroundSize: "100% 100%",
|
|
19
|
+
...(openGraph
|
|
20
|
+
? {
|
|
21
|
+
backgroundImage: `url("/${path}/opengraph-image.png")`,
|
|
22
|
+
}
|
|
23
|
+
: {}),
|
|
24
|
+
}, children: duration && (_jsx("time", { className: styles.duration, dateTime: formatTimeDuration(duration), children: formatTime(duration) })) }));
|
|
25
|
+
}
|