@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.
Files changed (129) hide show
  1. package/LICENSE +9 -0
  2. package/dist/esm/LiqvidDevToolsProvider.js +49 -0
  3. package/dist/esm/api/contract.mjs +48 -0
  4. package/dist/esm/api/project-meta.mjs +33 -0
  5. package/dist/esm/api/recording.mjs +156 -0
  6. package/dist/esm/api/root.mjs +4 -0
  7. package/dist/esm/api/static-file.mjs +82 -0
  8. package/dist/esm/api/types.mjs +1 -0
  9. package/dist/esm/client.mjs +98 -0
  10. package/dist/esm/conventions.mjs +3 -0
  11. package/dist/esm/index.mjs +20 -0
  12. package/dist/esm/initialize.mjs +50 -0
  13. package/dist/esm/jobs/watch-assets.mjs +99 -0
  14. package/dist/esm/jobs/watch-project-files.mjs +216 -0
  15. package/dist/esm/next/api.mjs +48 -0
  16. package/dist/esm/next/page.js +15 -0
  17. package/dist/esm/pages/NewProjectButton.js +62 -0
  18. package/dist/esm/pages/RebuildButton.js +24 -0
  19. package/dist/esm/pages/root-actions.js +151 -0
  20. package/dist/esm/pages/root.js +25 -0
  21. package/dist/esm/pages/root.module.css +326 -0
  22. package/dist/esm/palette.css +279 -0
  23. package/dist/esm/providers/hosting/github-pages.mjs +10 -0
  24. package/dist/esm/providers/hosting/liqvid-studio.mjs +8 -0
  25. package/dist/esm/providers/hosting/s3.mjs +9 -0
  26. package/dist/esm/providers/hosting/sftp.mjs +22 -0
  27. package/dist/esm/providers/index.mjs +10 -0
  28. package/dist/esm/providers/social/bluesky.mjs +8 -0
  29. package/dist/esm/providers/social/facebook.mjs +8 -0
  30. package/dist/esm/providers/social/instagram.mjs +8 -0
  31. package/dist/esm/providers/social/twitter.mjs +7 -0
  32. package/dist/esm/providers/social/youtube.mjs +7 -0
  33. package/dist/esm/providers/types.mjs +1 -0
  34. package/dist/esm/publish.mjs +37 -0
  35. package/dist/esm/recording/RecordingControl.js +110 -0
  36. package/dist/esm/recording/RecordingControl.module.css +0 -0
  37. package/dist/esm/recording/RecordingDialog.js +114 -0
  38. package/dist/esm/recording/RecordingDialog.module.css +194 -0
  39. package/dist/esm/schemas/liqvid-config.mjs +32 -0
  40. package/dist/esm/schemas/project.mjs +27 -0
  41. package/dist/esm/schemas/recording-meta.mjs +11 -0
  42. package/dist/esm/types/assets.mjs +1 -0
  43. package/dist/esm/types.mjs +12 -0
  44. package/dist/esm/ui/Dialog.js +71 -0
  45. package/dist/esm/ui/DockableDialog.js +131 -0
  46. package/dist/esm/ui/DockableDialog.module.css +63 -0
  47. package/dist/esm/ui/RadioTabs.js +13 -0
  48. package/dist/esm/ui/RadioTabs.module.css +54 -0
  49. package/dist/esm/ui/Tabs.js +29 -0
  50. package/dist/esm/ui/Tabs.module.css +31 -0
  51. package/dist/esm/ui/Toast.js +64 -0
  52. package/dist/esm/ui/Toast.module.css +50 -0
  53. package/dist/esm/ui/Toaster.js +13 -0
  54. package/dist/esm/ui/Toaster.module.css +9 -0
  55. package/dist/esm/ui/test.js +14 -0
  56. package/dist/esm/utils/dom.mjs +6 -0
  57. package/dist/esm/utils/fs.mjs +94 -0
  58. package/dist/esm/utils/misc.mjs +15 -0
  59. package/dist/esm/utils/react.mjs +8 -0
  60. package/dist/esm/utils/rsync.mjs +57 -0
  61. package/dist/templates/project.json.hbs +5 -0
  62. package/dist/templates/projects/code/page.tsx.hbs +23 -0
  63. package/dist/templates/projects/code/src/assets.ts.hbs +9 -0
  64. package/dist/templates/projects/code/src/client.tsx.hbs +22 -0
  65. package/dist/templates/projects/code/src/helpers.ts.hbs +21 -0
  66. package/dist/templates/projects/code/src/highlights.ts.hbs +3 -0
  67. package/dist/templates/projects/code/src/markers.ts.hbs +13 -0
  68. package/dist/templates/projects/code/src/project.ts.hbs +23 -0
  69. package/dist/templates/projects/code/template.json +3 -0
  70. package/dist/templates/projects/default/page.tsx.hbs +23 -0
  71. package/dist/templates/projects/default/src/assets.ts.hbs +9 -0
  72. package/dist/templates/projects/default/src/client.tsx.hbs +22 -0
  73. package/dist/templates/projects/default/src/helpers.ts.hbs +21 -0
  74. package/dist/templates/projects/default/src/highlights.ts.hbs +3 -0
  75. package/dist/templates/projects/default/src/markers.ts.hbs +13 -0
  76. package/dist/templates/projects/default/src/project.ts.hbs +23 -0
  77. package/dist/templates/projects/default/template.json +4 -0
  78. package/dist/templates/types.ts.hbs +20 -0
  79. package/dist/types/LiqvidDevToolsProvider.d.ts +12 -0
  80. package/dist/types/api/contract.d.mts +66 -0
  81. package/dist/types/api/project-meta.d.mts +1 -0
  82. package/dist/types/api/recording.d.mts +5 -0
  83. package/dist/types/api/root.d.mts +1 -0
  84. package/dist/types/api/static-file.d.mts +6 -0
  85. package/dist/types/api/types.d.mts +1 -0
  86. package/dist/types/client.d.mts +43 -0
  87. package/dist/types/conventions.d.mts +3 -0
  88. package/dist/types/index.d.mts +19 -0
  89. package/dist/types/initialize.d.mts +14 -0
  90. package/dist/types/jobs/watch-assets.d.mts +18 -0
  91. package/dist/types/jobs/watch-project-files.d.mts +4 -0
  92. package/dist/types/next/api.d.mts +14 -0
  93. package/dist/types/next/page.d.ts +4 -0
  94. package/dist/types/pages/NewProjectButton.d.ts +1 -0
  95. package/dist/types/pages/RebuildButton.d.ts +1 -0
  96. package/dist/types/pages/root-actions.d.ts +29 -0
  97. package/dist/types/pages/root.d.ts +1 -0
  98. package/dist/types/providers/hosting/github-pages.d.mts +11 -0
  99. package/dist/types/providers/hosting/liqvid-studio.d.mts +10 -0
  100. package/dist/types/providers/hosting/s3.d.mts +11 -0
  101. package/dist/types/providers/hosting/sftp.d.mts +13 -0
  102. package/dist/types/providers/index.d.mts +10 -0
  103. package/dist/types/providers/social/bluesky.d.mts +10 -0
  104. package/dist/types/providers/social/facebook.d.mts +10 -0
  105. package/dist/types/providers/social/instagram.d.mts +10 -0
  106. package/dist/types/providers/social/twitter.d.mts +9 -0
  107. package/dist/types/providers/social/youtube.d.mts +9 -0
  108. package/dist/types/providers/types.d.mts +9 -0
  109. package/dist/types/publish.d.mts +1 -0
  110. package/dist/types/recording/RecordingControl.d.ts +16 -0
  111. package/dist/types/recording/RecordingDialog.d.ts +10 -0
  112. package/dist/types/schemas/liqvid-config.d.mts +51 -0
  113. package/dist/types/schemas/project.d.mts +51 -0
  114. package/dist/types/schemas/recording-meta.d.mts +17 -0
  115. package/dist/types/types/assets.d.mts +3 -0
  116. package/dist/types/types.d.mts +20 -0
  117. package/dist/types/ui/Dialog.d.ts +31 -0
  118. package/dist/types/ui/DockableDialog.d.ts +25 -0
  119. package/dist/types/ui/RadioTabs.d.ts +18 -0
  120. package/dist/types/ui/Tabs.d.ts +9 -0
  121. package/dist/types/ui/Toast.d.ts +23 -0
  122. package/dist/types/ui/Toaster.d.ts +6 -0
  123. package/dist/types/ui/test.d.ts +9 -0
  124. package/dist/types/utils/dom.d.mts +3 -0
  125. package/dist/types/utils/fs.d.mts +32 -0
  126. package/dist/types/utils/misc.d.mts +4 -0
  127. package/dist/types/utils/react.d.mts +5 -0
  128. package/dist/types/utils/rsync.d.mts +10 -0
  129. 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
+ }