feat: add personal project list, add project settings, etc

This commit is contained in:
2024-02-24 03:55:50 +00:00
parent 49b80a2f4c
commit 751210c819
31 changed files with 877 additions and 148 deletions
+11 -8
View File
@@ -19,6 +19,7 @@ const get = async (req: Request, res: Response) => {
if (!projectData) {
return res.status(404).send("Project not found!");
}
const settings = projectData.settings || {};
const fileData = await db.query.file.findFirst({
where: and(
@@ -34,16 +35,18 @@ const get = async (req: Request, res: Response) => {
const ext = getFileExt(fileData.filename);
let content = fileData.content || "";
if (["html", "htm"].includes(ext)) {
content = await serveHtml(fileData);
}
if (!req.query.raw) {
if (["html", "htm"].includes(ext)) {
content = await serveHtml(fileData, settings);
}
if (["js", "ts", "jsx", "tsx"].includes(ext)) {
content = await serveJs(fileData);
}
if (["js", "ts", "jsx", "tsx"].includes(ext)) {
content = await serveJs(fileData, settings.js);
}
if (["css"].includes(ext)) {
content = await postcss(fileData);
if (["css"].includes(ext) && settings.css?.preprocessor === "postcss") {
content = await postcss(fileData, settings.css);
}
}
res.setHeader("Content-Type", getMimeType(fileData.filename));
+13 -4
View File
@@ -3,17 +3,26 @@ import tailwindcss from "tailwindcss";
import cssnano from "cssnano";
import { FileSchema } from "~/server/db/schema/file";
import { unpackProject } from "~/server/lib/unpack-project";
import { ProjectSettingsSchema } from "~/server/db/schema/project";
export const postcss = async (fileData: FileSchema) => {
export const postcss = async (
fileData: FileSchema,
cfg?: ProjectSettingsSchema["css"]
) => {
const content = fileData.content || "";
try {
const projectDir = await unpackProject({ ext: "ts,tsx,js,jsx,html" });
const plugins: any[] = [];
if (cfg?.tailwindcss) {
plugins.push(
tailwindcss({ content: [projectDir + "/**/*.{ts,tsx,js,jsx,html}"] })
);
}
const result = await postcssPlugin([
tailwindcss({
content: [projectDir + "/**/*.{ts,tsx,js,jsx,html}"],
}),
...plugins,
cssnano({
preset: ["default", { discardComments: { removeAll: true } }],
}),
+8 -7
View File
@@ -2,8 +2,12 @@ import db from "~/server/db";
import { FileSchema, file } from "~/server/db/schema/file";
import { and, eq, isNull } from "drizzle-orm";
import { IS_DEV } from "~/server/lib/consts";
import type { ProjectSettingsSchema } from "~/server/db/schema/project";
export const serveHtml = async (fileData: FileSchema) => {
export const serveHtml = async (
fileData: FileSchema,
settings: Partial<ProjectSettingsSchema>
) => {
const layout = await db.query.file.findFirst({
where: and(
eq(file.projectId, fileData.projectId),
@@ -24,18 +28,15 @@ export const serveHtml = async (fileData: FileSchema) => {
const firstScriptTagIdx = content.indexOf("<script", bodyOpeningTagIdx);
const injectScripts = ['<script src="/js/hook-console.js"></script>'];
// prevent direct access
if (!IS_DEV) {
// prevent direct access
injectScripts.push(
`<script>if (window === window.parent) {window.location.href = '/';}</script>`
);
}
const importMaps = [
{ name: "react", url: "https://esm.sh/[email protected]" },
{ name: "react-dom/client", url: "https://esm.sh/[email protected]/client" },
];
// js import maps
const importMaps = settings?.js?.packages || [];
if (importMaps.length > 0) {
const imports = importMaps.reduce((a: any, b) => {
a[b.name] = b.url;
+9 -3
View File
@@ -1,11 +1,17 @@
import { FileSchema } from "~/server/db/schema/file";
import { ProjectSettingsSchema } from "~/server/db/schema/project";
import { transformJs } from "~/server/lib/transform-js";
export const serveJs = async (file: FileSchema) => {
export const serveJs = async (
file: FileSchema,
cfg?: ProjectSettingsSchema["js"]
) => {
let content = file.content || "";
// transform js
content = await transformJs(content);
// transpile to es5
if (cfg?.transpiler === "swc") {
content = await transformJs(content);
}
return content;
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { CreateExpressContextOptions } from "@trpc/server/adapters/express";
export const createContext = async (ctx: CreateExpressContextOptions) => {
return ctx;
return { ...ctx, user: ctx.req.user };
};
export type Context = Awaited<ReturnType<typeof createContext>>;
+61 -15
View File
@@ -1,22 +1,48 @@
import { relations, sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import { user } from "./user";
import { file } from "./file";
export const project = sqliteTable("projects", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => user.id),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
deletedAt: text("deleted_at"),
});
const defaultSettings: ProjectSettingsSchema = {
css: {
preprocessor: null,
tailwindcss: false,
},
js: {
transpiler: null,
packages: [],
},
};
export const project = sqliteTable(
"projects",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => user.id),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
visibility: text("visibility", {
enum: ["public", "private", "unlisted"],
}).default("private"),
settings: text("settings", { mode: "json" })
.$type<Partial<ProjectSettingsSchema>>()
.default(defaultSettings),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
deletedAt: text("deleted_at"),
},
(table) => ({
visibilityEnum: index("project_visibility_idx").on(table.visibility),
})
);
export const projectRelations = relations(project, ({ one, many }) => ({
files: many(file),
@@ -26,7 +52,27 @@ export const projectRelations = relations(project, ({ one, many }) => ({
}),
}));
export const insertProjectSchema = createInsertSchema(project);
export const selectProjectSchema = createSelectSchema(project);
export const projectSettingsSchema = z.object({
css: z.object({
preprocessor: z.enum(["", "postcss"]).optional().nullable(),
tailwindcss: z.boolean().optional().nullable(),
}),
js: z.object({
transpiler: z.enum(["", "swc"]).optional().nullable(),
packages: z
.object({ name: z.string().min(1), url: z.string().min(1).url() })
.array(),
}),
});
export type ProjectSettingsSchema = z.infer<typeof projectSettingsSchema>;
export const insertProjectSchema = createInsertSchema(project, {
settings: projectSettingsSchema,
});
export const selectProjectSchema = createSelectSchema(project, {
settings: projectSettingsSchema.partial(),
});
export type ProjectSchema = z.infer<typeof selectProjectSchema>;
+28 -2
View File
@@ -17,7 +17,12 @@ const main = async () => {
const [vanillaProject] = await db
.insert(project)
.values({ userId: adminUser.id, slug: uid(), title: "Vanilla Project" })
.values({
userId: adminUser.id,
slug: uid(),
title: "Vanilla Project",
visibility: "public",
})
.returning();
// vanilla html css js template
@@ -69,7 +74,28 @@ const main = async () => {
const [reactProject] = await db
.insert(project)
.values({ userId: adminUser.id, slug: uid(), title: "React Project" })
.values({
userId: adminUser.id,
slug: uid(),
title: "React Project",
visibility: "public",
settings: {
css: {
preprocessor: "postcss",
tailwindcss: true,
},
js: {
transpiler: "swc",
packages: [
{ name: "react", url: "https://esm.sh/[email protected]" },
{
name: "react-dom/client",
url: "https://esm.sh/[email protected]/client",
},
],
},
},
})
.returning();
// react template
-1
View File
@@ -23,7 +23,6 @@ export const unpackProject = async (
const projectDir = getProjectDir();
if (!fileExists(projectDir)) {
console.log("not exist", projectDir);
await fs.mkdir(projectDir, { recursive: true });
}
+74 -17
View File
@@ -14,21 +14,34 @@ import { faker } from "@faker-js/faker";
import { ucwords } from "~/lib/utils";
import { hashPassword } from "../lib/crypto";
import { createToken } from "../lib/jwt";
import { file } from "../db/schema/file";
const projectRouter = router({
getAll: procedure.query(async () => {
const where = and(isNull(project.deletedAt));
getAll: procedure
.input(z.object({ owned: z.boolean() }).partial().optional())
.query(async ({ ctx, input: opt }) => {
if (opt?.owned && !ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const projects = await db.query.project.findMany({
where,
with: {
user: { columns: { password: false } },
},
orderBy: [desc(project.id)],
});
const where = [
!opt?.owned
? eq(project.visibility, "public")
: eq(project.userId, ctx.user!.id),
isNull(project.deletedAt),
];
return projects;
}),
const projects = await db.query.project.findMany({
where: and(...(where.filter((i) => i != null) as any)),
columns: { settings: false },
with: {
user: { columns: { password: false } },
},
orderBy: [desc(project.id)],
});
return projects;
}),
getById: procedure
.input(z.number().or(z.string()))
@@ -70,10 +83,10 @@ const projectRouter = router({
.mutation(async ({ ctx, input }) => {
const title =
input.title.length > 0 ? input.title : ucwords(faker.lorem.words(2));
let userId = 0;
let userId = ctx.user?.id;
return db.transaction(async (tx) => {
if (input.user) {
if (input.user && !userId) {
const [usr] = await tx
.insert(user)
.values({
@@ -99,15 +112,52 @@ const projectRouter = router({
slug: uid(),
};
const [result] = await tx.insert(project).values(data).returning();
const [projectData] = await tx.insert(project).values(data).returning();
const projectId = projectData.id;
return result;
if (input.forkFromId) {
const forkFiles = await tx.query.file.findMany({
where: and(
eq(file.projectId, input.forkFromId),
isNull(file.deletedAt)
),
columns: {
id: false,
projectId: false,
createdAt: false,
deletedAt: false,
},
});
await tx
.insert(file)
.values(forkFiles.map((file) => ({ ...file, projectId })));
} else {
await tx.insert(file).values([
{
projectId,
path: "index.html",
filename: "index.html",
content: "<p>Open index.html to edit this file.</p>",
isPinned: true,
},
]);
}
return projectData;
});
}),
update: procedure
.input(selectProjectSchema.partial().required({ id: true }))
.input(
selectProjectSchema
.partial()
.omit({ slug: true, userId: true })
.required({ id: true })
)
.mutation(async ({ input }) => {
const data = { ...input };
const projectData = await db.query.project.findFirst({
where: and(eq(project.id, input.id), isNull(project.deletedAt)),
});
@@ -115,9 +165,16 @@ const projectRouter = router({
throw new TRPCError({ code: "NOT_FOUND" });
}
if (data.settings) {
data.settings = Object.assign(
projectData.settings || {},
data.settings
);
}
const [result] = await db
.update(project)
.set(input)
.set(data)
.where(and(eq(project.id, input.id), isNull(project.deletedAt)))
.returning();