diff --git a/.gitignore b/.gitignore
index 4758373..7aa1bed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ dist/
build/
storage/**/*
!.gitkeep
+.env
diff --git a/components/containers/project-card.tsx b/components/containers/project-card.tsx
index 78dc378..95c7d25 100644
--- a/components/containers/project-card.tsx
+++ b/components/containers/project-card.tsx
@@ -16,10 +16,14 @@ const ProjectCard = ({ project }: Props) => {
href={`/${project.slug}`}
className="border border-white/20 hover:border-white/40 rounded-lg transition-colors overflow-hidden"
>
-
+ {project.thumbnail ? (
+
+ ) : (
+
+ )}
diff --git a/pages/project/@slug/components/editor.tsx b/pages/project/@slug/components/editor.tsx
index 59e5778..7949b15 100644
--- a/pages/project/@slug/components/editor.tsx
+++ b/pages/project/@slug/components/editor.tsx
@@ -20,6 +20,8 @@ import StatusBar from "./status-bar";
import { FiTerminal } from "react-icons/fi";
import SettingsDialog from "./settings-dialog";
import FileIcon from "~/components/ui/file-icon";
+import { api } from "~/lib/api";
+import { useMutation } from "@tanstack/react-query";
const Editor = () => {
const { project, initialFiles } = useData
();
@@ -33,11 +35,17 @@ const Editor = () => {
);
const openedFilesData = trpc.file.getAll.useQuery(
- { projectId: project.id, id: curOpenFiles },
+ { projectId: project.id!, id: curOpenFiles },
{ enabled: curOpenFiles.length > 0, initialData: initialFiles }
);
const [openedFiles, setOpenedFiles] = useState(initialFiles);
+ const generateThumbnail = useMutation({
+ mutationFn: () => {
+ return api(`/thumbnail/${project.slug!}`, { method: "PATCH" });
+ },
+ });
+
const deleteFile = trpc.file.delete.useMutation({
onSuccess: (file) => {
trpcUtils.file.getAll.invalidate();
@@ -76,6 +84,14 @@ const Editor = () => {
// api(`/sandbox/${project.slug}/start`, { method: "POST" }).catch(() => {});
// }, [project]);
+ useEffect(() => {
+ const itv = setInterval(() => generateThumbnail.mutate(), 60000);
+
+ return () => {
+ clearInterval(itv);
+ };
+ }, []);
+
const onOpenFile = useCallback(
(fileId: number, autoSwitchTab = true) => {
const idx = curOpenFiles.indexOf(fileId);
diff --git a/rest.http b/rest.http
new file mode 100644
index 0000000..901ae78
--- /dev/null
+++ b/rest.http
@@ -0,0 +1,7 @@
+@baseUrl = http://localhost:3001
+
+GET {{baseUrl}}/api/thumbnail/t2mo3o3j.jpg
+
+###
+
+PATCH {{baseUrl}}/api/thumbnail/t2mo3o3j
diff --git a/server/api/index.ts b/server/api/index.ts
index affe173..a3ad0b3 100644
--- a/server/api/index.ts
+++ b/server/api/index.ts
@@ -1,7 +1,7 @@
import { Router } from "express";
import preview from "./preview";
import trpc from "./trpc/handler";
-import { thumbnail } from "./thumbnail";
+import thumbnail from "./thumbnail";
import sandbox from "./sandbox";
import { nocache } from "../middlewares/nocache";
@@ -10,7 +10,6 @@ const api = Router();
api.use("/trpc", trpc);
api.use("/preview", nocache, preview);
api.use("/sandbox", sandbox);
-
-api.get("/thumbnail/:slug", thumbnail);
+api.use("/thumbnail", thumbnail);
export default api;
diff --git a/server/api/thumbnail.ts b/server/api/thumbnail.ts
deleted file mode 100644
index 0444f59..0000000
--- a/server/api/thumbnail.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Request, Response } from "express";
-import { screenshot } from "../lib/screenshot";
-
-const cache = new Map();
-
-const regenerateThumbnail = async (slug: string) => {
- const curCache = cache.get(slug);
-
- if (curCache?.data) {
- cache.set(slug, {
- data: curCache.data,
- timestamp: Date.now(),
- });
- }
-
- const result = await screenshot(slug);
- if (!result) {
- return curCache;
- }
-
- const data = {
- data: result,
- timestamp: Date.now(),
- };
-
- cache.set(slug, data);
- return data;
-};
-
-export const thumbnail = async (req: Request, res: Response) => {
- const { slug } = req.params;
- let cacheData = cache.get(slug);
-
- if (!cacheData) {
- cacheData = await regenerateThumbnail(slug);
- }
-
- if (cacheData && Date.now() - cacheData.timestamp > 10000) {
- regenerateThumbnail(slug);
- }
-
- res.contentType("image/jpeg");
- res.send(cacheData?.data);
-};
diff --git a/server/api/thumbnail/index.ts b/server/api/thumbnail/index.ts
new file mode 100644
index 0000000..0eef87f
--- /dev/null
+++ b/server/api/thumbnail/index.ts
@@ -0,0 +1,65 @@
+import { and, eq, isNull } from "drizzle-orm";
+import { Request, Response, Router } from "express";
+import db from "~/server/db";
+import fs from "fs";
+import { project } from "~/server/db/schema/project";
+import { BASE_URL } from "~/server/lib/consts";
+import { screenshot } from "~/server/lib/screenshot";
+import { getStorageDir } from "~/server/lib/utils";
+
+export const thumbnail = async (req: Request, res: Response) => {
+ const { filename } = req.params;
+ const path = getStorageDir("thumbnails", filename);
+ const thumbnail = fs.existsSync(path) ? fs.readFileSync(path) : undefined;
+
+ if (!thumbnail) {
+ return res.status(404).send("Thumbnail not found!");
+ }
+
+ res.setHeader("Content-Type", "image/jpeg");
+ res.send(thumbnail);
+};
+
+export const generate = async (req: Request, res: Response) => {
+ const { slug } = req.params;
+
+ const projectData = await db.query.project.findFirst({
+ where: and(eq(project.slug, slug), isNull(project.deletedAt)),
+ });
+
+ if (!projectData) {
+ return res.status(404).send("Project not found!");
+ }
+
+ try {
+ const url = `${BASE_URL}/api/preview/${slug}/index.html`;
+ const data = await screenshot(url);
+
+ if (!data) {
+ throw new Error("Cannot generate thumbnail!");
+ }
+
+ const dir = getStorageDir("thumbnails");
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ const filename = `/${slug}.jpg`;
+ fs.writeFileSync(dir + filename, data);
+
+ await db
+ .update(project)
+ .set({ thumbnail: filename })
+ .where(eq(project.id, projectData.id));
+
+ res.json({ filename });
+ } catch (err) {
+ res.status(400).send((err as any)?.message || "An error occured!");
+ }
+};
+
+const router = Router();
+router.get("/:filename", thumbnail);
+router.patch("/:slug", generate);
+
+export default router;
diff --git a/server/db/drizzle/0000_swift_mandroid.sql b/server/db/drizzle/0000_swift_mandroid.sql
index d8cd3b5..bf63389 100644
--- a/server/db/drizzle/0000_swift_mandroid.sql
+++ b/server/db/drizzle/0000_swift_mandroid.sql
@@ -16,16 +16,16 @@ CREATE TABLE `files` (
--> statement-breakpoint
CREATE TABLE `projects` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
- `fork_id` integer;
`user_id` integer NOT NULL,
+ `fork_id` integer,
`slug` text NOT NULL,
`title` text NOT NULL,
`visibility` text DEFAULT 'private',
`settings` text DEFAULT [object Object],
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`deleted_at` text,
- FOREIGN KEY (`fork_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action,
- FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
+ FOREIGN KEY (`fork_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
diff --git a/server/db/drizzle/0001_steep_dragon_man.sql b/server/db/drizzle/0001_steep_dragon_man.sql
new file mode 100644
index 0000000..226c2e7
--- /dev/null
+++ b/server/db/drizzle/0001_steep_dragon_man.sql
@@ -0,0 +1 @@
+ALTER TABLE projects ADD `thumbnail` text;
\ No newline at end of file
diff --git a/server/db/drizzle/meta/0001_snapshot.json b/server/db/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..bb65338
--- /dev/null
+++ b/server/db/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,339 @@
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "id": "ac734f8a-134c-4d0e-81a9-7d6025e24acc",
+ "prevId": "4d5af5ab-31e5-4202-9c7e-3264e985bf20",
+ "tables": {
+ "files": {
+ "name": "files",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_directory": {
+ "name": "is_directory",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_file": {
+ "name": "is_file",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pinned": {
+ "name": "is_pinned",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "file_path_idx": {
+ "name": "file_path_idx",
+ "columns": [
+ "path"
+ ],
+ "isUnique": false
+ },
+ "file_name_idx": {
+ "name": "file_name_idx",
+ "columns": [
+ "filename"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "files_project_id_projects_id_fk": {
+ "name": "files_project_id_projects_id_fk",
+ "tableFrom": "files",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "parent_id_fk": {
+ "name": "parent_id_fk",
+ "tableFrom": "files",
+ "tableTo": "files",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fork_id": {
+ "name": "fork_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbnail": {
+ "name": "thumbnail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'private'"
+ },
+ "settings": {
+ "name": "settings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": {
+ "css": {
+ "preprocessor": null,
+ "tailwindcss": false
+ },
+ "js": {
+ "transpiler": null,
+ "packages": []
+ }
+ }
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_slug_unique": {
+ "name": "projects_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "project_visibility_idx": {
+ "name": "project_visibility_idx",
+ "columns": [
+ "visibility"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "projects_user_id_users_id_fk": {
+ "name": "projects_user_id_users_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "project_fork_id_fk": {
+ "name": "project_fork_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "fork_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ }
+}
\ No newline at end of file
diff --git a/server/db/drizzle/meta/_journal.json b/server/db/drizzle/meta/_journal.json
index 607012d..e21ad2f 100644
--- a/server/db/drizzle/meta/_journal.json
+++ b/server/db/drizzle/meta/_journal.json
@@ -1 +1,20 @@
-{"version":"5","dialect":"sqlite","entries":[{"idx":0,"version":"5","when":1709221275637,"tag":"0000_swift_mandroid","breakpoints":true}]}
\ No newline at end of file
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1709221275637,
+ "tag": "0000_swift_mandroid",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "5",
+ "when": 1709459027055,
+ "tag": "0001_steep_dragon_man",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/server/db/schema/project.ts b/server/db/schema/project.ts
index f0cba0c..86eacac 100644
--- a/server/db/schema/project.ts
+++ b/server/db/schema/project.ts
@@ -32,6 +32,7 @@ export const project = sqliteTable(
forkId: integer("fork_id"),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
+ thumbnail: text("thumbnail"),
visibility: text("visibility", {
enum: ["public", "private", "unlisted"],
diff --git a/server/lib/screenshot.ts b/server/lib/screenshot.ts
index b8d1e30..411d782 100644
--- a/server/lib/screenshot.ts
+++ b/server/lib/screenshot.ts
@@ -3,7 +3,7 @@ import puppeteer, { Browser } from "puppeteer";
let browser: Browser | null = null;
let closeHandler: any;
-export const screenshot = async (slug: string) => {
+export const screenshot = async (url: string) => {
try {
if (!browser) {
browser = await puppeteer.launch({
@@ -15,12 +15,15 @@ export const screenshot = async (slug: string) => {
const page = await browser.newPage();
await page.setViewport({ width: 512, height: 340 });
- await page.goto(`http://localhost:3000/api/preview/${slug}/index.html`, {
+ await page.goto(url, {
waitUntil: "networkidle0",
timeout: 5000,
});
const result = await page.screenshot();
- await page.close();
+
+ setTimeout(() => {
+ page.close();
+ }, 500);
if (closeHandler) {
clearTimeout(closeHandler);
@@ -30,7 +33,7 @@ export const screenshot = async (slug: string) => {
browser?.close();
browser = null;
closeHandler = null;
- }, 60000);
+ }, 30000);
return result;
} catch (err) {
diff --git a/server/lib/utils.ts b/server/lib/utils.ts
index e63b8db..3ebdc4f 100644
--- a/server/lib/utils.ts
+++ b/server/lib/utils.ts
@@ -12,8 +12,12 @@ export const fileExists = (path: string) => {
}
};
+export const getStorageDir = (...args: string[]) => {
+ return path.join(process.cwd(), "storage", ...args);
+};
+
export const getProjectDir = (project: ProjectSchema) => {
- return path.resolve(process.cwd(), "storage/tmp", project.slug);
+ return path.join(process.cwd(), "storage/tmp", project.slug);
};
export const uid = cuid2.init({