feat: update thumbnail api

This commit is contained in:
Khairul Hidayat 2024-03-03 17:25:22 +07:00
parent 110bdd88e6
commit 24717db264
14 changed files with 476 additions and 61 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ dist/
build/
storage/**/*
!.gitkeep
.env

View File

@ -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"
>
<img
src={`/api/thumbnail/${project.slug}`}
className="w-full aspect-[3/2] bg-background object-cover"
/>
{project.thumbnail ? (
<img
src={`/api/thumbnail/${project.thumbnail}`}
className="w-full aspect-[3/2] bg-background object-cover"
/>
) : (
<div className="w-full aspect-[3/2] bg-gray-900"></div>
)}
<div className="py-2 px-3 flex items-center gap-3">
<div className="size-8 rounded-full bg-white/80"></div>

View File

@ -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<Data>();
@ -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<any[]>(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);

7
rest.http Normal file
View File

@ -0,0 +1,7 @@
@baseUrl = http://localhost:3001
GET {{baseUrl}}/api/thumbnail/t2mo3o3j.jpg
###
PATCH {{baseUrl}}/api/thumbnail/t2mo3o3j

View File

@ -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;

View File

@ -1,44 +0,0 @@
import { Request, Response } from "express";
import { screenshot } from "../lib/screenshot";
const cache = new Map<string, { data: Buffer; timestamp: number }>();
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);
};

View File

@ -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;

View File

@ -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` (

View File

@ -0,0 +1 @@
ALTER TABLE projects ADD `thumbnail` text;

View File

@ -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": {}
}
}

View File

@ -1 +1,20 @@
{"version":"5","dialect":"sqlite","entries":[{"idx":0,"version":"5","when":1709221275637,"tag":"0000_swift_mandroid","breakpoints":true}]}
{
"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
}
]
}

View File

@ -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"],

View File

@ -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) {

View File

@ -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({