From 24717db264d9f9eba90472e28e7d7da6205c0d65 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Sun, 3 Mar 2024 17:25:22 +0700 Subject: [PATCH] feat: update thumbnail api --- .gitignore | 1 + components/containers/project-card.tsx | 12 +- pages/project/@slug/components/editor.tsx | 18 +- rest.http | 7 + server/api/index.ts | 5 +- server/api/thumbnail.ts | 44 --- server/api/thumbnail/index.ts | 65 ++++ server/db/drizzle/0000_swift_mandroid.sql | 6 +- server/db/drizzle/0001_steep_dragon_man.sql | 1 + server/db/drizzle/meta/0001_snapshot.json | 339 ++++++++++++++++++++ server/db/drizzle/meta/_journal.json | 21 +- server/db/schema/project.ts | 1 + server/lib/screenshot.ts | 11 +- server/lib/utils.ts | 6 +- 14 files changed, 476 insertions(+), 61 deletions(-) create mode 100644 rest.http delete mode 100644 server/api/thumbnail.ts create mode 100644 server/api/thumbnail/index.ts create mode 100644 server/db/drizzle/0001_steep_dragon_man.sql create mode 100644 server/db/drizzle/meta/0001_snapshot.json 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({