diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index 53f3ff5..06baafe 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -1,13 +1,13 @@ -import type { Config } from "drizzle-kit" -import env from "env-var" +import type { Config } from 'drizzle-kit' +import env from 'env-var' -const DATABASE_URL = env.get("DATABASE_URL").required().asString() +const DATABASE_URL = env.get('DATABASE_URL').required().asString() export default { - schema: "./src/db/schema", - out: "./drizzle", - dialect: "postgresql", - casing: "snake_case", + schema: './src/db/schema', + out: './drizzle', + dialect: 'postgresql', + casing: 'snake_case', dbCredentials: { url: DATABASE_URL, }, diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..aad9af7 --- /dev/null +++ b/backend/eslint.config.js @@ -0,0 +1,30 @@ +import antfu from '@antfu/eslint-config' + +export default antfu( + { + stylistic: { + indent: 2, + quotes: 'single', + }, + }, + { + files: ['**/*.js', '**/*.ts'], + rules: { + 'node/prefer-global/process': 'off', + 'no-console': 'off', + 'antfu/no-top-level-await': 'off', + 'antfu/consistent-chaining': 'error', // Explicitly set the rule + // Or potentially use the stylistic rule if it's managed there + 'antfu/top-level-function': 'off', + // "style/newlice-per-chained-call": ["error", { ignoreChainWithDepth: 2 }], + 'style/object-curly-newline': [ + 'error', + { + ImportDeclaration: { multiline: true, minProperties: 3 }, + ExportDeclaration: { multiline: true, minProperties: 3 }, + }, + ], + }, + plugins: {}, + }, +) diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs deleted file mode 100644 index 0db7cb0..0000000 --- a/backend/eslint.config.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import antfu from "@antfu/eslint-config" -import drizzle from "eslint-plugin-drizzle" - -export default antfu( - { - stylistic: { - indent: 2, - quotes: "double", - }, - }, - { - files: ["**/*.js", "**/*.ts"], - rules: { - "node/prefer-global/process": "off", - "no-console": "off", - "antfu/no-top-level-await": "off", - "antfu/consistent-chaining": "error", // Explicitly set the rule - // Or potentially use the stylistic rule if it's managed there - "antfu/top-level-function": "off", - // "style/newlice-per-chained-call": ["error", { ignoreChainWithDepth: 2 }], - "style/object-curly-newline": ["error", { - ImportDeclaration: { multiline: true, minProperties: 3 }, - ExportDeclaration: { multiline: true, minProperties: 3 }, - }], - }, - plugins: { - drizzle, - }, - - }, -) diff --git a/backend/package.json b/backend/package.json index 62264ac..1b67578 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,16 +15,20 @@ "prepare": "husky" }, "dependencies": { + "elysia": "^1.4.22", "@bogeychan/elysia-logger": "^0.1.10", "@elysiajs/cors": "^1.4.1", "@elysiajs/openapi": "^1.4.14", "@elysiajs/server-timing": "^1.4.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", "better-auth": "^1.4.17", "cloudinary": "^2.9.0", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "env-var": "^7.5.0", "ioredis": "^5.9.2", + "isomorphic-dompurify": "2.16.0", "nanoid": "^5.1.6", "postgres": "^3.4.8", "uuid": "^13.0.0", diff --git a/backend/src/config.ts b/backend/src/config.ts index a54b074..8132e21 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,16 +1,16 @@ -import env from "env-var" +import env from 'env-var' export const config = { NODE_ENV: env - .get("NODE_ENV") - .default("development") - .asEnum(["production", "test", "development"]), + .get('NODE_ENV') + .default('development') + .asEnum(['production', 'test', 'development']), - PORT: env.get("PORT").default(3000).asPortNumber(), - DATABASE_URL: env.get("DATABASE_URL").required().asString(), - REDIS_HOST: env.get("REDIS_HOST").default("localhost").asString(), - GITHUB_CLIENT_ID: env.get("GITHUB_CLIENT_ID").required().asString(), - GITHUB_CLIENT_SECRET: env.get("GITHUB_CLIENT_SECRET").required().asString(), - UI_CLIENT_URL: env.get("UI_CLIENT_URL").required().asString(), - CLOUDINARY_URL: env.get("CLOUDINARY_URL").required().asString(), + PORT: env.get('PORT').default(3000).asPortNumber(), + DATABASE_URL: env.get('DATABASE_URL').required().asString(), + REDIS_HOST: env.get('REDIS_HOST').default('localhost').asString(), + GITHUB_CLIENT_ID: env.get('GITHUB_CLIENT_ID').required().asString(), + GITHUB_CLIENT_SECRET: env.get('GITHUB_CLIENT_SECRET').required().asString(), + UI_CLIENT_URL: env.get('UI_CLIENT_URL').required().asString(), + CLOUDINARY_URL: env.get('CLOUDINARY_URL').required().asString(), } diff --git a/backend/src/controllers/article.controller.ts b/backend/src/controllers/article.controller.ts index aff3ad9..c4c792b 100644 --- a/backend/src/controllers/article.controller.ts +++ b/backend/src/controllers/article.controller.ts @@ -1,34 +1,36 @@ -import { authMiddleware } from "@backend/middlewares/auth.middleware.ts" +import { authMiddleware } from '@backend/middlewares/auth.middleware.ts' import { CreatePost, DeletePost, GetPostBySlug, GetPosts, UpdatePost, -} from "@backend/services/article.service.ts" +} from '@backend/services/article.service.ts' import { CreatePostBody, GetPostsQuery, UpdatePostBody, -} from "@backend/shared/article.model.ts" -import { Elysia } from "elysia" +} from '@backend/shared/article.model.ts' +import { Elysia } from 'elysia' -export const articleController = new Elysia({ prefix: "/article" }) +export const articleController = new Elysia({ prefix: '/article' }) .use(authMiddleware) .post( - "/", + '/', async ({ body, user }) => await CreatePost(body, user), { body: CreatePostBody, isAuth: true, }, ) - .get("/", async ({ query }) => await GetPosts(query), { + .get('/', async ({ query }) => await GetPosts(query), { query: GetPostsQuery, }) - .get("/:slug", async ({ params: { slug } }) => await GetPostBySlug(slug)) + .get('/:slug', async ({ params: { slug }, user }) => await GetPostBySlug(slug, user), { + isAuthOptional: true, + }) .put( - "/:id", + '/id/:id', async ({ params: { id }, body, user }) => await UpdatePost(id, body, user), { body: UpdatePostBody, @@ -36,7 +38,7 @@ export const articleController = new Elysia({ prefix: "/article" }) }, ) .delete( - "/:id", + '/id/:id', async ({ params: { id }, user }) => await DeletePost(id, user), { isAuth: true, diff --git a/backend/src/controllers/comment.controller.ts b/backend/src/controllers/comment.controller.ts index af281a2..2bc47c9 100644 --- a/backend/src/controllers/comment.controller.ts +++ b/backend/src/controllers/comment.controller.ts @@ -1,37 +1,37 @@ -import { authMiddleware } from "@backend/middlewares/auth.middleware.ts" +import { authMiddleware } from '@backend/middlewares/auth.middleware.ts' import { CreateComment, DeleteComment, GetComments, UpdateComment, -} from "@backend/services/comment.service.ts" +} from '@backend/services/comment.service.ts' import { CreateCommentBody, GetCommentsQuery, UpdateCommentBody, -} from "@backend/shared/comment.model.ts" -import { Elysia } from "elysia" +} from '@backend/shared/comment.model.ts' +import { Elysia } from 'elysia' -export const commentController = new Elysia({ prefix: "/comments" }) +export const commentController = new Elysia({ prefix: '/comments' }) .use(authMiddleware) - .post("/", async ({ body, user }) => { + .post('/', async ({ body, user }) => { return await CreateComment(body, user) }, { body: CreateCommentBody, isAuth: true, }) - .get("/:articleId", async ({ params: { articleId }, query }) => { + .get('/:articleId', async ({ params: { articleId }, query }) => { return await GetComments(articleId, query) }, { query: GetCommentsQuery, }) - .patch("/:id", async ({ params: { id }, body, user }) => { + .patch('/:id', async ({ params: { id }, body, user }) => { return await UpdateComment(id, body, user) }, { body: UpdateCommentBody, isAuth: true, }) - .delete("/:id", async ({ params: { id }, user }) => { + .delete('/:id', async ({ params: { id }, user }) => { return await DeleteComment(id, user) }, { isAuth: true, diff --git a/backend/src/controllers/controller.ts b/backend/src/controllers/controller.ts index 565c7d3..3b348bd 100644 --- a/backend/src/controllers/controller.ts +++ b/backend/src/controllers/controller.ts @@ -1,5 +1,5 @@ -import { HttpError, SetupOnErorr } from "@backend/services/error.service.ts" -import { Elysia } from "elysia" +import { HttpError, SetupOnErorr } from '@backend/services/error.service.ts' +import { Elysia } from 'elysia' // Base controller with error handling - for reference only // Controllers now directly use authMiddleware @@ -9,7 +9,7 @@ export const baseErrorHandler = new Elysia() }) .onError(({ error, set, code }) => { switch (code) { - case "HttpError": + case 'HttpError': return SetupOnErorr(error, set) } }) diff --git a/backend/src/controllers/like.controller.ts b/backend/src/controllers/like.controller.ts index 6de164f..fe845f9 100644 --- a/backend/src/controllers/like.controller.ts +++ b/backend/src/controllers/like.controller.ts @@ -1,12 +1,12 @@ -import { authMiddleware } from "@backend/middlewares/auth.middleware.ts" -import { ToggleLike } from "@backend/services/like.service.ts" -import { ToggleLikeBody } from "@backend/shared/like.model.ts" -import { Elysia } from "elysia" +import { authMiddleware } from '@backend/middlewares/auth.middleware.ts' +import { ToggleLike } from '@backend/services/like.service.ts' +import { ToggleLikeBody } from '@backend/shared/like.model.ts' +import { Elysia } from 'elysia' -export const likeController = new Elysia({ prefix: "/like" }) +export const likeController = new Elysia({ prefix: '/like' }) .use(authMiddleware) .post( - "/", + '/', async ({ body, user }) => await ToggleLike(body, user), { body: ToggleLikeBody, diff --git a/backend/src/controllers/upload.controller.ts b/backend/src/controllers/upload.controller.ts index 659279b..9e0f176 100644 --- a/backend/src/controllers/upload.controller.ts +++ b/backend/src/controllers/upload.controller.ts @@ -1,13 +1,13 @@ -import { authMiddleware } from "@backend/middlewares/auth.middleware.ts" -import { ImageUploadService } from "@backend/services/upload.service.ts" -import { UploadBody } from "@backend/shared/models.ts" -import { Elysia } from "elysia" -import { z } from "zod" +import { authMiddleware } from '@backend/middlewares/auth.middleware.ts' +import { ImageUploadService } from '@backend/services/upload.service.ts' +import { UploadBody } from '@backend/shared/models.ts' +import { Elysia } from 'elysia' +import { z } from 'zod' export const uploadController = new Elysia() .use(authMiddleware) .post( - "/upload", + '/upload', async ({ body: { file } }) => await ImageUploadService(file as File), { body: UploadBody, diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 749edc3..04be19a 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,11 +1,11 @@ -import { config } from "@backend/config.ts" -import * as schema from "@backend/db/schema/index.ts" -import { drizzle } from "drizzle-orm/postgres-js" -import postgres from "postgres" +import { config } from '@backend/config.ts' +import * as schema from '@backend/db/schema/index.ts' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' const client = postgres(config.DATABASE_URL) export const db = drizzle({ client, schema, - casing: "snake_case", + casing: 'snake_case', }) diff --git a/backend/src/db/schema/article.ts b/backend/src/db/schema/article.ts index 13facc6..5136915 100644 --- a/backend/src/db/schema/article.ts +++ b/backend/src/db/schema/article.ts @@ -1,52 +1,52 @@ -import { relations } from "drizzle-orm" +import { relations } from 'drizzle-orm' import { index, pgTable, text, uniqueIndex, -} from "drizzle-orm/pg-core" -import { user } from "./auth.ts" -import { baseColumns } from "./base.ts" +} from 'drizzle-orm/pg-core' +import { user } from './auth.ts' +import { baseColumns } from './base.ts' -export const article = pgTable("article", { +export const article = pgTable('article', { ...baseColumns, - title: text("title").notNull(), - preview_image: text("preview_image").notNull(), - preview_text: text("preview_text").notNull(), - content: text("content").notNull(), - slug: text("slug").notNull().unique(), - tags: text("tags").array().notNull().default([]), - author_id: text("author_id") + title: text('title').notNull(), + preview_image: text('preview_image').notNull(), + preview_text: text('preview_text').notNull(), + content: text('content').notNull(), + slug: text('slug').notNull().unique(), + tags: text('tags').array().notNull().default([]), + author_id: text('author_id') .notNull() - .references(() => user.id, { onDelete: "cascade" }), + .references(() => user.id, { onDelete: 'cascade' }), }) -export const comment = pgTable("comment", { +export const comment = pgTable('comment', { ...baseColumns, - content: text("content").notNull(), - author_id: text("author_id") + content: text('content').notNull(), + author_id: text('author_id') .notNull() - .references(() => user.id, { onDelete: "cascade" }), - article_id: text("article_id") + .references(() => user.id, { onDelete: 'cascade' }), + article_id: text('article_id') .notNull() - .references(() => article.id, { onDelete: "cascade" }), + .references(() => article.id, { onDelete: 'cascade' }), }) export const like = pgTable( - "like", + 'like', { ...baseColumns, - liker_id: text("author_id") + liker_id: text('author_id') .notNull() - .references(() => user.id, { onDelete: "cascade" }), - article_id: text("article_id") + .references(() => user.id, { onDelete: 'cascade' }), + article_id: text('article_id') .notNull() - .references(() => article.id, { onDelete: "cascade" }), + .references(() => article.id, { onDelete: 'cascade' }), }, t => [ - uniqueIndex("unique_like").on(t.article_id, t.liker_id), - index("article_id_idx").on(t.article_id), + uniqueIndex('unique_like').on(t.article_id, t.liker_id), + index('article_id_idx').on(t.article_id), ], ) diff --git a/backend/src/db/schema/auth.ts b/backend/src/db/schema/auth.ts index 786fdfb..9cc9af7 100644 --- a/backend/src/db/schema/auth.ts +++ b/backend/src/db/schema/auth.ts @@ -1,89 +1,89 @@ -import { relations } from "drizzle-orm" +import { relations } from 'drizzle-orm' import { boolean, index, pgTable, text, timestamp, -} from "drizzle-orm/pg-core" -import { v7 as uuidv7 } from "uuid" +} from 'drizzle-orm/pg-core' +import { v7 as uuidv7 } from 'uuid' import { article, comment, like, -} from "./article.ts" +} from './article.ts' -export const user = pgTable("user", { - id: text("id").primaryKey().$defaultFn(() => uuidv7()), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").default(false).notNull(), - username: text("username").notNull().unique(), - image: text("image"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") +export const user = pgTable('user', { + id: text('id').primaryKey().$defaultFn(() => uuidv7()), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').default(false).notNull(), + username: text('username').notNull().unique(), + image: text('image'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }) export const session = pgTable( - "session", + 'session', { - id: text("id").primaryKey().$defaultFn(() => uuidv7()), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") + id: text('id').primaryKey().$defaultFn(() => uuidv7()), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') .notNull() - .references(() => user.id, { onDelete: "cascade" }), + .references(() => user.id, { onDelete: 'cascade' }), }, - table => [index("session_userId_idx").on(table.userId)], + table => [index('session_userId_idx').on(table.userId)], ) export const account = pgTable( - "account", + 'account', { - id: text("id").primaryKey().$defaultFn(() => uuidv7()), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") + id: text('id').primaryKey().$defaultFn(() => uuidv7()), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - table => [index("account_userId_idx").on(table.userId)], + table => [index('account_userId_idx').on(table.userId)], ) export const verification = pgTable( - "verification", + 'verification', { - id: text("id").primaryKey().$defaultFn(() => uuidv7()), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") + id: text('id').primaryKey().$defaultFn(() => uuidv7()), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - table => [index("verification_identifier_idx").on(table.identifier)], + table => [index('verification_identifier_idx').on(table.identifier)], ) export const userRelations = relations(user, ({ many }) => ({ diff --git a/backend/src/db/schema/base.ts b/backend/src/db/schema/base.ts index 2bcc3bc..b6577c4 100644 --- a/backend/src/db/schema/base.ts +++ b/backend/src/db/schema/base.ts @@ -1,10 +1,10 @@ -import { text, timestamp } from "drizzle-orm/pg-core" -import { v7 as uuidv7 } from "uuid" +import { text, timestamp } from 'drizzle-orm/pg-core' +import { v7 as uuidv7 } from 'uuid' export const baseColumns = { - id: text("id").primaryKey().$defaultFn(() => uuidv7()), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") + id: text('id').primaryKey().$defaultFn(() => uuidv7()), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => new Date()) .notNull(), diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 387f581..96466b6 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -1,2 +1,2 @@ -export * from "./article.ts" -export * from "./auth.ts" +export * from './article.ts' +export * from './auth.ts' diff --git a/backend/src/index.ts b/backend/src/index.ts index 2187fc8..bf17bba 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,7 @@ -import { config } from "./config.ts" -import { app } from "./server.ts" +import { config } from './config.ts' +import { app } from './server.ts' -const signals = ["SIGINT", "SIGTERM"] +const signals = ['SIGINT', 'SIGTERM'] const banner = ` ------------------------------------- SERVER STARTED SUCCESSFULLY @@ -20,18 +20,18 @@ for (const signal of signals) { }) } -process.on("uncaughtException", (error) => { +process.on('uncaughtException', (error) => { console.error(error) }) -process.on("unhandledRejection", (error) => { +process.on('unhandledRejection', (error) => { console.error(error) }) app.listen( config.PORT, () => { - if (config.NODE_ENV === "development") { + if (config.NODE_ENV === 'development') { console.log(banner) } else { diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 33a56c2..2f7f63b 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,13 +1,13 @@ -import { config } from "@backend/config.ts" -import { db } from "@backend/db/index.ts" -import { betterAuth } from "better-auth" -import { drizzleAdapter } from "better-auth/adapters/drizzle" -import { nanoid } from "nanoid" +import { config } from '@backend/config.ts' +import { db } from '@backend/db/index.ts' +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { nanoid } from 'nanoid' export const auth = betterAuth({ database: drizzleAdapter( db, - { provider: "pg" }, + { provider: 'pg' }, ), socialProviders: { github: { @@ -22,10 +22,10 @@ export const auth = betterAuth({ create: { before: async (user) => { let baseName = user.name - ? user.name.toLowerCase().replace(/\s+/g, "") // "John Doe" -> "johndoe" - : user.email.split("@")[0] // fallback to email prefix + ? user.name.toLowerCase().replace(/\s+/g, '') // "John Doe" -> "johndoe" + : user.email.split('@')[0] // fallback to email prefix - baseName = baseName!.replace(/[^a-z0-9]/g, "") + baseName = baseName!.replace(/[^a-z0-9]/g, '') const uniqueSuffix = nanoid(4) // e.g., "ab12" const username = `${baseName}.${uniqueSuffix}` @@ -43,7 +43,7 @@ export const auth = betterAuth({ user: { additionalFields: { username: { - type: "string", + type: 'string', returned: true, }, }, diff --git a/backend/src/lib/cloudinary.ts b/backend/src/lib/cloudinary.ts index 4842af3..eb832df 100644 --- a/backend/src/lib/cloudinary.ts +++ b/backend/src/lib/cloudinary.ts @@ -1,20 +1,20 @@ -import { config } from "@backend/config.ts" -import { v2 as cloudinary } from "cloudinary" +import { config } from '@backend/config.ts' +import { v2 as cloudinary } from 'cloudinary' if (config.CLOUDINARY_URL) { // Use the URL directly if possible, or parse it for explicit config const url = config.CLOUDINARY_URL - if (url.startsWith("cloudinary://")) { - const withoutProtocol = url.replace("cloudinary://", "") - const [auth, cloudName] = withoutProtocol.split("@") + if (url.startsWith('cloudinary://')) { + const withoutProtocol = url.replace('cloudinary://', '') + const [auth, cloudName] = withoutProtocol.split('@') if (auth && cloudName) { - const [apiKey, apiSecret] = auth.split(":") + const [apiKey, apiSecret] = auth.split(':') cloudinary.config({ cloud_name: cloudName, api_key: apiKey, api_secret: apiSecret, - secure: false, - upload_preset: "ml_default", + secure: true, + upload_preset: 'ml_default', }) } } diff --git a/backend/src/lib/sanitize-html.ts b/backend/src/lib/sanitize-html.ts new file mode 100644 index 0000000..005763c --- /dev/null +++ b/backend/src/lib/sanitize-html.ts @@ -0,0 +1,40 @@ +import DOMPurify from 'isomorphic-dompurify' + +export const sanitizeHtml = (dirtyHtml: string) => { + return DOMPurify.sanitize(dirtyHtml, { + ALLOWED_TAGS: [ + 'p', + 'h2', + 'br', + 'hr', + 'strong', + 'em', + 's', + 'ul', + 'ol', + 'li', + 'a', + 'img', + 'pre', + 'code', + 'span', + ], + + ALLOWED_ATTR: [ + 'href', + 'target', + 'rel', + 'src', + 'alt', + 'title', + 'width', + 'height', + 'class', + ], + + ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|ftp):/i, + + // ADD_TAGS: ['iframe'], + // ADD_ATTR: ['allowfullscreen', 'frameborder', 'scrolling'], + }) +} diff --git a/backend/src/middlewares/auth.middleware.ts b/backend/src/middlewares/auth.middleware.ts index c14046a..4138359 100644 --- a/backend/src/middlewares/auth.middleware.ts +++ b/backend/src/middlewares/auth.middleware.ts @@ -1,11 +1,11 @@ -import type { User } from "better-auth" -import { auth } from "@backend/lib/auth.ts" -import { UnauthorizedError } from "@backend/services/error.service.ts" -import { Elysia } from "elysia" +import type { User } from 'better-auth' +import { auth } from '@backend/lib/auth.ts' +import { UnauthorizedError } from '@backend/services/error.service.ts' +import { Elysia } from 'elysia' export const authMiddleware = new Elysia() - .derive({ as: "global" }, () => { + .derive({ as: 'global' }, () => { return { user: null as User | null, } @@ -20,7 +20,23 @@ export const authMiddleware = new Elysia() user: session.user, } } - throw new UnauthorizedError("You are not authorized to access this resource. please sign in") + throw new UnauthorizedError( + 'You are not authorized to access this resource. please sign in', + ) + }, + }, + + isAuthOptional: { + async resolve({ headers }) { + const session = await auth.api.getSession({ headers }) + if (session) { + return { + user: session.user, + } + } + return { + user: null, + } }, }, }) diff --git a/backend/src/server.ts b/backend/src/server.ts index f0b25dd..235cd8d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,25 +1,25 @@ -import { logger } from "@bogeychan/elysia-logger" -import { cors } from "@elysiajs/cors" -import { openapi } from "@elysiajs/openapi" -import { serverTiming } from "@elysiajs/server-timing" -import { Elysia } from "elysia" -import { articleController } from "./controllers/article.controller.ts" -import { commentController } from "./controllers/comment.controller.ts" -import { likeController } from "./controllers/like.controller.ts" -import { uploadController } from "./controllers/upload.controller.ts" -import { auth } from "./lib/auth.ts" +import { logger } from '@bogeychan/elysia-logger' +import { cors } from '@elysiajs/cors' +import { openapi } from '@elysiajs/openapi' +import { serverTiming } from '@elysiajs/server-timing' +import { Elysia } from 'elysia' +import { articleController } from './controllers/article.controller.ts' +import { commentController } from './controllers/comment.controller.ts' +import { likeController } from './controllers/like.controller.ts' +import { uploadController } from './controllers/upload.controller.ts' +import { auth } from './lib/auth.ts' export const app = new Elysia({ - prefix: "/api/v1", + prefix: '/api/v1', }) .use(logger({ - level: "error", + level: 'error', })) .use(cors()) .use(serverTiming()) .use(openapi( { - provider: "scalar", + provider: 'scalar', }, )) .mount(auth.handler) @@ -27,9 +27,9 @@ export const app = new Elysia({ .use(articleController) .use(commentController) .use(likeController) - .get("/health", () => { + .get('/health', () => { return { - status: "ok", + status: 'ok', } }) diff --git a/backend/src/services/article.service.ts b/backend/src/services/article.service.ts index 24a6146..0b767b1 100644 --- a/backend/src/services/article.service.ts +++ b/backend/src/services/article.service.ts @@ -2,45 +2,53 @@ import type { CreatePostBodyT, GetPostsQueryT, UpdatePostBodyT, -} from "@backend/shared/article.model.ts" -import type { User } from "better-auth" -import { db } from "@backend/db/index.ts" -import { article, like } from "@backend/db/schema/article.ts" -import { user as userSchema } from "@backend/db/schema/auth.ts" +} from '@backend/shared/article.model.ts' +import type { User } from 'better-auth' +import { db } from '@backend/db/index.ts' +import { article, like } from '@backend/db/schema/article.ts' +import { user as userSchema } from '@backend/db/schema/auth.ts' +import { sanitizeHtml } from '@backend/lib/sanitize-html.ts' import { + and, count, desc, eq, getTableColumns, sql, -} from "drizzle-orm" -import { v7 as uuidv7 } from "uuid" +} from 'drizzle-orm' +import { v7 as uuidv7 } from 'uuid' import { ForbiddenError, InternalServerError, NotFoundError, -} from "./error.service.ts" +} from './error.service.ts' const GenerateSlug = (title: string) => { return title .toLowerCase() - .replace(/[^\w ]+/g, "") - .replace(/ +/g, "-") + .replace(/[^\w ]+/g, '') + .replace(/ +/g, '-') .concat(`-${uuidv7()}`) } export const CreatePost = async (body: CreatePostBodyT, user: User) => { try { - const [newArticle] = await db.insert(article).values({ - ...body, - slug: GenerateSlug(body.title), - author_id: user.id, - }).returning({ insertedId: article.id }) + const { content, ...rest } = body + const sanitizedContent = sanitizeHtml(content) + const [newArticle] = await db + .insert(article) + .values({ + ...rest, + content: sanitizedContent, + slug: GenerateSlug(body.title), + author_id: user.id, + }) + .returning({ insertedId: article.id }) if (!newArticle) { - throw new InternalServerError("Failed to create post") + throw new InternalServerError('Failed to create post') } const result = await db.query.article.findFirst({ - where: eq(article.id, newArticle!.insertedId), + where: eq(article.id, newArticle.insertedId), with: { author: { columns: { @@ -58,7 +66,7 @@ export const CreatePost = async (body: CreatePostBodyT, user: User) => { } } catch (error) { - throw new InternalServerError("Failed to create post", error) + throw new InternalServerError('Failed to create post', error) } } @@ -83,7 +91,9 @@ export const GetPosts = async (query: GetPostsQueryT) => { .offset(query.offset) .orderBy(desc(article.createdAt)) - const [total] = await db.select({ count: sql`count(*)` }).from(article) + const [total] = await db + .select({ count: sql`count(*)` }) + .from(article) return { data, @@ -93,11 +103,11 @@ export const GetPosts = async (query: GetPostsQueryT) => { } } catch (error) { - throw new InternalServerError("Failed to fetch posts", error) + throw new InternalServerError('Failed to fetch posts', error) } } -export const GetPostBySlug = async (slug: string) => { +export const GetPostBySlug = async (slug: string, user: User | null) => { try { const [post] = await db .select({ @@ -117,34 +127,56 @@ export const GetPostBySlug = async (slug: string) => { .groupBy(article.id, userSchema.id) if (!post) { - throw new NotFoundError("Article not found") + throw new NotFoundError('Article not found') + } + + let isLikedByUser = false + if (user) { + const [existingLike] = await db + .select() + .from(like) + .where(and( + eq(like.article_id, post.id), + eq(like.liker_id, user.id), + )) + if (existingLike) { + isLikedByUser = true + } } - return post + return { + ...post, + isLikedByUser, + } } catch (error) { if (error instanceof NotFoundError) { throw error } - throw new InternalServerError("Failed to fetch post", error) + throw new InternalServerError('Failed to fetch post', error) } } -export const UpdatePost = async (id: string, body: UpdatePostBodyT, user: User) => { +export const UpdatePost = async ( + id: string, + body: UpdatePostBodyT, + user: User, +) => { try { const post = await db.query.article.findFirst({ where: eq(article.id, id), }) if (!post) { - throw new NotFoundError("Article not found") + throw new NotFoundError('Article not found') } if (post.author_id !== user.id) { - throw new ForbiddenError("You are not authorized to update this post") + throw new ForbiddenError('You are not authorized to update this post') } - const [updatedPost] = await db.update(article) + const [updatedPost] = await db + .update(article) .set({ ...body, updatedAt: new Date(), @@ -158,7 +190,7 @@ export const UpdatePost = async (id: string, body: UpdatePostBodyT, user: User) if (error instanceof NotFoundError || error instanceof ForbiddenError) { throw error } - throw new InternalServerError("Failed to update post", error) + throw new InternalServerError('Failed to update post', error) } } @@ -169,21 +201,21 @@ export const DeletePost = async (id: string, user: User) => { }) if (!post) { - throw new NotFoundError("Article not found") + throw new NotFoundError('Article not found') } if (post.author_id !== user.id) { - throw new ForbiddenError("You are not authorized to delete this post") + throw new ForbiddenError('You are not authorized to delete this post') } await db.delete(article).where(eq(article.id, id)) - return { success: true, message: "Article deleted successfully" } + return { success: true, message: 'Article deleted successfully' } } catch (error) { if (error instanceof NotFoundError || error instanceof ForbiddenError) { throw error } - throw new InternalServerError("Failed to delete post", error) + throw new InternalServerError('Failed to delete post', error) } } diff --git a/backend/src/services/comment.service.ts b/backend/src/services/comment.service.ts index 70e5b1a..7fa7577 100644 --- a/backend/src/services/comment.service.ts +++ b/backend/src/services/comment.service.ts @@ -2,20 +2,20 @@ import type { CreateCommentBodyT, GetCommentsQueryT, UpdateCommentBodyT, -} from "@backend/shared/comment.model.ts" -import type { User } from "better-auth" -import { db } from "@backend/db/index.ts" -import { comment } from "@backend/db/schema/article.ts" +} from '@backend/shared/comment.model.ts' +import type { User } from 'better-auth' +import { db } from '@backend/db/index.ts' +import { comment } from '@backend/db/schema/article.ts' import { desc, eq, sql, -} from "drizzle-orm" +} from 'drizzle-orm' import { ForbiddenError, InternalServerError, NotFoundError, -} from "./error.service.ts" +} from './error.service.ts' export const CreateComment = async (body: CreateCommentBodyT, user: User) => { try { @@ -25,7 +25,7 @@ export const CreateComment = async (body: CreateCommentBodyT, user: User) => { }).returning({ insertedId: comment.id }) if (!newComment) { - throw new InternalServerError("Failed to create comment") + throw new InternalServerError('Failed to create comment') } const result = await db.query.comment.findFirst({ @@ -44,7 +44,7 @@ export const CreateComment = async (body: CreateCommentBodyT, user: User) => { return result } catch (error) { - throw new InternalServerError("Failed to create comment", error) + throw new InternalServerError('Failed to create comment', error) } } @@ -79,7 +79,7 @@ export const GetComments = async (articleId: string, query: GetCommentsQueryT) = } } catch (error) { - throw new InternalServerError("Failed to fetch comments", error) + throw new InternalServerError('Failed to fetch comments', error) } } @@ -90,11 +90,11 @@ export const UpdateComment = async (id: string, body: UpdateCommentBodyT, user: }) if (!existingComment) { - throw new NotFoundError("Comment not found") + throw new NotFoundError('Comment not found') } if (existingComment.author_id !== user.id) { - throw new ForbiddenError("You are not authorized to update this comment") + throw new ForbiddenError('You are not authorized to update this comment') } const [updatedComment] = await db.update(comment) @@ -111,7 +111,7 @@ export const UpdateComment = async (id: string, body: UpdateCommentBodyT, user: if (error instanceof NotFoundError || error instanceof ForbiddenError) { throw error } - throw new InternalServerError("Failed to update comment", error) + throw new InternalServerError('Failed to update comment', error) } } @@ -122,21 +122,21 @@ export const DeleteComment = async (id: string, user: User) => { }) if (!existingComment) { - throw new NotFoundError("Comment not found") + throw new NotFoundError('Comment not found') } if (existingComment.author_id !== user.id) { - throw new ForbiddenError("You are not authorized to delete this comment") + throw new ForbiddenError('You are not authorized to delete this comment') } await db.delete(comment).where(eq(comment.id, id)) - return { success: true, message: "Comment deleted successfully" } + return { success: true, message: 'Comment deleted successfully' } } catch (error) { if (error instanceof NotFoundError || error instanceof ForbiddenError) { throw error } - throw new InternalServerError("Failed to delete comment", error) + throw new InternalServerError('Failed to delete comment', error) } } diff --git a/backend/src/services/error.service.ts b/backend/src/services/error.service.ts index 0b07e2c..7561b9e 100644 --- a/backend/src/services/error.service.ts +++ b/backend/src/services/error.service.ts @@ -1,4 +1,4 @@ -import type { Context } from "elysia" +import type { Context } from 'elysia' export class HttpError extends Error { constructor( @@ -8,73 +8,73 @@ export class HttpError extends Error { public details?: any, ) { super(message) - this.name = "HttpError" + this.name = 'HttpError' } } // 4xx Client Errors export class BadRequestError extends HttpError { - constructor(message: string = "Bad Request", details?: any) { - super(400, "BAD_REQUEST", message, details) + constructor(message: string = 'Bad Request', details?: any) { + super(400, 'BAD_REQUEST', message, details) } } export class UnauthorizedError extends HttpError { - constructor(message: string = "Unauthorized", details?: any) { - super(401, "UNAUTHORIZED", message, details) + constructor(message: string = 'Unauthorized', details?: any) { + super(401, 'UNAUTHORIZED', message, details) } } export class ForbiddenError extends HttpError { - constructor(message: string = "Forbidden", details?: any) { - super(403, "FORBIDDEN", message, details) + constructor(message: string = 'Forbidden', details?: any) { + super(403, 'FORBIDDEN', message, details) } } export class NotFoundError extends HttpError { - constructor(message: string = "Resource Not Found", details?: any) { - super(404, "NOT_FOUND", message, details) + constructor(message: string = 'Resource Not Found', details?: any) { + super(404, 'NOT_FOUND', message, details) } } export class ConflictError extends HttpError { - constructor(message: string = "Conflict", details?: any) { - super(409, "CONFLICT", message, details) + constructor(message: string = 'Conflict', details?: any) { + super(409, 'CONFLICT', message, details) } } export class UnprocessableEntityError extends HttpError { - constructor(message: string = "Unprocessable Entity", details?: any) { - super(422, "UNPROCESSABLE_ENTITY", message, details) + constructor(message: string = 'Unprocessable Entity', details?: any) { + super(422, 'UNPROCESSABLE_ENTITY', message, details) } } export class TooManyRequestsError extends HttpError { - constructor(message: string = "Too Many Requests", details?: any) { - super(429, "TOO_MANY_REQUESTS", message, details) + constructor(message: string = 'Too Many Requests', details?: any) { + super(429, 'TOO_MANY_REQUESTS', message, details) } } // 5xx Server Errors export class InternalServerError extends HttpError { - constructor(message: string = "Internal Server Error", details?: any) { - super(500, "INTERNAL_SERVER_ERROR", message, details) + constructor(message: string = 'Internal Server Error', details?: any) { + super(500, 'INTERNAL_SERVER_ERROR', message, details) } } export class ServiceUnavailableError extends HttpError { - constructor(message: string = "Service Unavailable", details?: any) { - super(503, "SERVICE_UNAVAILABLE", message, details) + constructor(message: string = 'Service Unavailable', details?: any) { + super(503, 'SERVICE_UNAVAILABLE', message, details) } } export class BadGatewayError extends HttpError { - constructor(message: string = "Bad Gateway", details?: any) { - super(502, "BAD_GATEWAY", message, details) + constructor(message: string = 'Bad Gateway', details?: any) { + super(502, 'BAD_GATEWAY', message, details) } } -export const SetupOnErorr = (error: unknown, set: Context["set"]) => { +export const SetupOnErorr = (error: unknown, set: Context['set']) => { if (error instanceof HttpError) { set.status = error.statusCode return { @@ -88,7 +88,7 @@ export const SetupOnErorr = (error: unknown, set: Context["set"]) => { set.status = 500 return { success: false, - error: "INTERNAL_SERVER_ERROR", - message: "Internal Server Error", + error: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', } } diff --git a/backend/src/services/like.service.ts b/backend/src/services/like.service.ts index 96958f1..73ac788 100644 --- a/backend/src/services/like.service.ts +++ b/backend/src/services/like.service.ts @@ -1,9 +1,9 @@ -import type { ToggleLikeBodyT } from "@backend/shared/like.model.ts" -import type { User } from "better-auth" -import { db } from "@backend/db/index.ts" -import { article, like } from "@backend/db/schema/article.ts" -import { and, eq } from "drizzle-orm" -import { InternalServerError, NotFoundError } from "./error.service.ts" +import type { ToggleLikeBodyT } from '@backend/shared/like.model.ts' +import type { User } from 'better-auth' +import { db } from '@backend/db/index.ts' +import { article, like } from '@backend/db/schema/article.ts' +import { and, eq } from 'drizzle-orm' +import { InternalServerError, NotFoundError } from './error.service.ts' export const ToggleLike = async (body: ToggleLikeBodyT, user: User) => { try { @@ -12,7 +12,7 @@ export const ToggleLike = async (body: ToggleLikeBodyT, user: User) => { }) if (!existingArticle) { - throw new NotFoundError("Article not found") + throw new NotFoundError('Article not found') } const existingLike = await db.query.like.findFirst({ @@ -29,7 +29,7 @@ export const ToggleLike = async (body: ToggleLikeBodyT, user: User) => { eq(like.liker_id, user.id), ), ) - return { success: true, liked: false, message: "Article unliked successfully" } + return { success: true, liked: false, message: 'Article unliked successfully' } } await db.insert(like).values({ @@ -37,12 +37,12 @@ export const ToggleLike = async (body: ToggleLikeBodyT, user: User) => { liker_id: user.id, }) - return { success: true, liked: true, message: "Article liked successfully" } + return { success: true, liked: true, message: 'Article liked successfully' } } catch (error) { if (error instanceof NotFoundError) { throw error } - throw new InternalServerError("Failed to toggle like", error) + throw new InternalServerError('Failed to toggle like', error) } } diff --git a/backend/src/services/redis.ts b/backend/src/services/redis.ts index 5e4d617..a86240b 100644 --- a/backend/src/services/redis.ts +++ b/backend/src/services/redis.ts @@ -1,5 +1,5 @@ -import { Redis } from "ioredis" -import { config } from "../config.ts" +import { Redis } from 'ioredis' +import { config } from '../config.ts' export const redis = new Redis({ host: config.REDIS_HOST, diff --git a/backend/src/services/upload.service.ts b/backend/src/services/upload.service.ts index 42ed52e..59d8a6a 100644 --- a/backend/src/services/upload.service.ts +++ b/backend/src/services/upload.service.ts @@ -1,17 +1,17 @@ /* eslint-disable node/prefer-global/buffer */ -import { cloudinary } from "@backend/lib/cloudinary.ts" -import { InternalServerError } from "./error.service.ts" +import { cloudinary } from '@backend/lib/cloudinary.ts' +import { InternalServerError } from './error.service.ts' export const ImageUploadService = async (file: File) => { const arrayBuffer = await file.arrayBuffer() const buffer = Buffer.from(arrayBuffer) - const base64Data = `data:${file.type};base64,${buffer.toString("base64")}` + const base64Data = `data:${file.type};base64,${buffer.toString('base64')}` try { const result = await cloudinary.uploader.upload( base64Data, { - upload_preset: "ml_default", + upload_preset: 'ml_default', }, ) return { @@ -19,6 +19,6 @@ export const ImageUploadService = async (file: File) => { } } catch (error) { - throw new InternalServerError("Failed to upload image", error) + throw new InternalServerError('Failed to upload image', error) } } diff --git a/backend/src/shared/article.model.ts b/backend/src/shared/article.model.ts index d9a33e0..ec7e10f 100644 --- a/backend/src/shared/article.model.ts +++ b/backend/src/shared/article.model.ts @@ -1,7 +1,7 @@ -import { article } from "@backend/db/schema/article.ts" -import { user } from "@backend/db/schema/auth.ts" -import { createInsertSchema, createSelectSchema } from "drizzle-zod" -import { z } from "zod" +import { article } from '@backend/db/schema/article.ts' +import { user } from '@backend/db/schema/auth.ts' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import { z } from 'zod' export const AuthorSchema = createSelectSchema(user).pick({ id: true, @@ -20,11 +20,11 @@ export const ArticleSchema = createSelectSchema(article).extend({ export type ArticleT = z.infer const _createPost = createInsertSchema(article, { - title: z.string().min(1, "Title is required"), - preview_text: z.string().min(1, "Preview text is required"), - content: z.string().min(1, "Content is required"), - preview_image: z.string().url("Invalid image URL"), - tags: z.array(z.string()).min(3, "At least 3 tags are required"), + title: z.string().min(1, 'Title is required'), + preview_text: z.string().min(1, 'Preview text is required'), + content: z.string().min(1, 'Content is required'), + preview_image: z.string().url('Invalid image URL'), + tags: z.array(z.string()).min(3, 'At least 3 tags are required'), }) export const CreatePostBody = _createPost.pick({ @@ -43,11 +43,11 @@ export type UpdatePostBodyT = z.infer export const GetPostsQuery = z.object({ limit: z.string().optional().transform((val) => { - const parsed = Number.parseInt(val ?? "", 10) + const parsed = Number.parseInt(val ?? '', 10) return Number.isNaN(parsed) ? 20 : parsed }), offset: z.string().optional().transform((val) => { - const parsed = Number.parseInt(val ?? "", 10) + const parsed = Number.parseInt(val ?? '', 10) return Number.isNaN(parsed) ? 0 : parsed }), }) diff --git a/backend/src/shared/comment.model.ts b/backend/src/shared/comment.model.ts index 1713712..958725f 100644 --- a/backend/src/shared/comment.model.ts +++ b/backend/src/shared/comment.model.ts @@ -1,7 +1,7 @@ -import { comment } from "@backend/db/schema/article.ts" -import { createInsertSchema, createSelectSchema } from "drizzle-zod" -import { z } from "zod" -import { AuthorSchema } from "./article.model.ts" +import { comment } from '@backend/db/schema/article.ts' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import { z } from 'zod' +import { AuthorSchema } from './article.model.ts' export const CommentSchema = createSelectSchema(comment).extend({ author: AuthorSchema, @@ -10,8 +10,8 @@ export const CommentSchema = createSelectSchema(comment).extend({ export type CommentT = z.infer const _createComment = createInsertSchema(comment, { - content: z.string().min(1, "Content is required"), - article_id: z.uuid("Article id is required"), + content: z.string().min(1, 'Content is required'), + article_id: z.uuid('Article id is required'), }) export const CreateCommentBody = _createComment.pick({ @@ -29,11 +29,11 @@ export type UpdateCommentBodyT = z.infer export const GetCommentsQuery = z.object({ limit: z.string().optional().transform((val) => { - const parsed = Number.parseInt(val ?? "", 10) + const parsed = Number.parseInt(val ?? '', 10) return Number.isNaN(parsed) ? 20 : parsed }), offset: z.string().optional().transform((val) => { - const parsed = Number.parseInt(val ?? "", 10) + const parsed = Number.parseInt(val ?? '', 10) return Number.isNaN(parsed) ? 0 : parsed }), }) diff --git a/backend/src/shared/like.model.ts b/backend/src/shared/like.model.ts index 481b60b..de3bfc7 100644 --- a/backend/src/shared/like.model.ts +++ b/backend/src/shared/like.model.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import { z } from 'zod' export const ToggleLikeBody = z.object({ articleId: z.string(), diff --git a/backend/src/shared/models.ts b/backend/src/shared/models.ts index e0bc56f..17fca6c 100644 --- a/backend/src/shared/models.ts +++ b/backend/src/shared/models.ts @@ -1,16 +1,16 @@ -import { z } from "zod" +import { z } from 'zod' const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB -const ACCEPTED_FILE_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"] +const ACCEPTED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'] const fileSchema = z .instanceof(File) .refine((file) => { return !file || file.size <= MAX_UPLOAD_SIZE - }, "File size must be less than 3MB") + }, 'File size must be less than 3MB') .refine((file) => { return !file || ACCEPTED_FILE_TYPES.includes(file.type) - }, "File must be a valid image type (PNG, JPEG, JPG, WebP)") + }, 'File must be a valid image type (PNG, JPEG, JPG, WebP)') export const UploadBody = z.object({ file: fileSchema, diff --git a/bun.lock b/bun.lock index 9676e88..b4c5364 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "marklink-monorepo", "dependencies": { "elysia": "^1.4.22", + "marklink-monorepo": ".", }, }, "backend": { @@ -14,12 +15,15 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/openapi": "^1.4.14", "@elysiajs/server-timing": "^1.4.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", "better-auth": "^1.4.17", "cloudinary": "^2.9.0", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "env-var": "^7.5.0", "ioredis": "^5.9.2", + "isomorphic-dompurify": "^3.0.0-rc.2", "nanoid": "^5.1.6", "postgres": "^3.4.8", "uuid": "^13.0.0", @@ -41,7 +45,10 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@elysiajs/eden": "^1.4.6", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/noto-sans": "^5.2.10", + "@fontsource-variable/source-serif-4": "^5.2.9", "@hugeicons/core-free-icons": "^3.0.0", "@hugeicons/react": "^1.1.2", "@t3-oss/env-core": "^0.13.8", @@ -69,6 +76,7 @@ "better-auth": "^1.4.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.19", "lowlight": "^3.3.0", "next-themes": "^0.4.6", "nitro": "latest", @@ -328,8 +336,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + "@fontsource-variable/noto-sans": ["@fontsource-variable/noto-sans@5.2.10", "", {}, "sha512-wyFgKkFu7jki5kEL8qv7avjQ8rxHX0J/nhLWvbR9T0hOH1HRKZEvb9EW9lMjZfWHHfEzKkYf5J+NadwgCS7TXA=="], + "@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@3.1.1", "", {}, "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg=="], @@ -786,6 +800,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -1052,6 +1068,8 @@ "data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="], + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -1098,6 +1116,8 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -1428,6 +1448,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-dompurify": ["isomorphic-dompurify@3.0.0-rc.2", "", { "dependencies": { "dompurify": "^3.3.1", "jsdom": "^28.0.0" } }, "sha512-krCa8psVRnfeJxZnCk+USqMKvcDOkQCGbAeFXQwaJiIcRFOGp9GDa2h06QzVIdVbM+onpro2Vg4uLe2RHI1McA=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1532,6 +1554,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marklink-monorepo": ["marklink-monorepo@file:", { "dependencies": { "elysia": "^1.4.22" } }], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -2358,6 +2382,8 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "isomorphic-dompurify/jsdom": ["jsdom@28.0.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.11.0", "cssstyle": "^5.3.7", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA=="], + "jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "jsonc-eslint-parser/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], @@ -2498,6 +2524,16 @@ "eslint-plugin-jsdoc/espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="], + "isomorphic-dompurify/jsdom/@exodus/bytes": ["@exodus/bytes@1.11.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA=="], + + "isomorphic-dompurify/jsdom/data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "isomorphic-dompurify/jsdom/undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], + + "isomorphic-dompurify/jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "isomorphic-dompurify/jsdom/whatwg-url": ["whatwg-url@16.0.0", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], diff --git a/ui/package.json b/ui/package.json index 0a57851..cfa0f3e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,10 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@elysiajs/eden": "^1.4.6", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/noto-sans": "^5.2.10", + "@fontsource-variable/source-serif-4": "^5.2.9", "@hugeicons/core-free-icons": "^3.0.0", "@hugeicons/react": "^1.1.2", "@t3-oss/env-core": "^0.13.8", @@ -42,6 +45,7 @@ "better-auth": "^1.4.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.19", "lowlight": "^3.3.0", "next-themes": "^0.4.6", "nitro": "latest", diff --git a/ui/src/components/Editor/Editor.tsx b/ui/src/components/Editor/Editor.tsx index 629d657..03e1f29 100644 --- a/ui/src/components/Editor/Editor.tsx +++ b/ui/src/components/Editor/Editor.tsx @@ -1,6 +1,6 @@ import { EditorContent } from '@tiptap/react' -import { EditorBubbleMenu } from './EditorBubbleMenu' -import { EditorBlockMenu } from './EditorBlockMenu' +import { EditorBubbleMenu } from './editor-bubble-menu' +import { EditorBlockMenu } from './editor-block-menu' import type { Editor as EditorType } from '@tiptap/react' type EditorProps = { diff --git a/ui/src/components/Editor/EditorBlockMenu.tsx b/ui/src/components/Editor/editor-block-menu.tsx similarity index 98% rename from ui/src/components/Editor/EditorBlockMenu.tsx rename to ui/src/components/Editor/editor-block-menu.tsx index f4ea74e..dff577d 100644 --- a/ui/src/components/Editor/EditorBlockMenu.tsx +++ b/ui/src/components/Editor/editor-block-menu.tsx @@ -16,7 +16,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu' -import { EditorImageAdd } from './EditorImageAdd' +import { EditorImageAdd } from './editor-image-add' import type { Editor } from '@tiptap/react' type EditorBlockMenuProps = { diff --git a/ui/src/components/Editor/EditorBubbleMenu.tsx b/ui/src/components/Editor/editor-bubble-menu.tsx similarity index 98% rename from ui/src/components/Editor/EditorBubbleMenu.tsx rename to ui/src/components/Editor/editor-bubble-menu.tsx index 5f9e7cf..0e72a5d 100644 --- a/ui/src/components/Editor/EditorBubbleMenu.tsx +++ b/ui/src/components/Editor/editor-bubble-menu.tsx @@ -83,7 +83,7 @@ export const EditorBubbleMenu = ({ editor }: EditorBubbleMenuProps) => { setUrl(e.target.value)} diff --git a/ui/src/components/Editor/EditorImageAdd.tsx b/ui/src/components/Editor/editor-image-add.tsx similarity index 100% rename from ui/src/components/Editor/EditorImageAdd.tsx rename to ui/src/components/Editor/editor-image-add.tsx diff --git a/ui/src/components/Editor/use-editor.tsx b/ui/src/components/Editor/use-editor.tsx index 3d1f21e..e84d1e3 100644 --- a/ui/src/components/Editor/use-editor.tsx +++ b/ui/src/components/Editor/use-editor.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Placeholder from '@tiptap/extension-placeholder' @@ -13,15 +14,18 @@ type UseArticleEditorProps = { content?: string editable?: boolean onUpdate?: (params: { editor: Editor }) => void + ssr?: boolean } export const useArticleEditor = ({ content = '', editable = true, + ssr = false, onUpdate, }: UseArticleEditorProps = {}): Editor | null => { - return useEditor({ - editable, + const editor = useEditor({ + immediatelyRender: !ssr, + editable: editable, content, onUpdate, extensions: [ @@ -34,7 +38,7 @@ export const useArticleEditor = ({ codeBlock: false, }), Placeholder.configure({ - placeholder: 'Tell your story...', + placeholder: 'Write your article...', includeChildren: false, }), Image, @@ -58,4 +62,13 @@ export const useArticleEditor = ({ }, }, }) + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (editor) { + editor.setEditable(editable) + } + }, [editor, editable]) + + return editor } diff --git a/ui/src/components/article/article-more-button.tsx b/ui/src/components/article/article-more-button.tsx new file mode 100644 index 0000000..4691438 --- /dev/null +++ b/ui/src/components/article/article-more-button.tsx @@ -0,0 +1,95 @@ +import { HugeiconsIcon } from '@hugeicons/react' +import { + Delete02Icon, + Edit02Icon, + Flag03Icon, + MoreHorizontalFreeIcons, + UserAdd02Icon, +} from '@hugeicons/core-free-icons' +import { useNavigate, useParams } from '@tanstack/react-router' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu' + +interface ArticleMoreButtonProps { + articleID: string + authorID: string + userID?: string | null +} + +function ArticleMoreButton({ + articleID, + authorID, + userID, +}: ArticleMoreButtonProps) { + const isAuthor = userID && userID === authorID + const navigate = useNavigate() + const { slug } = useParams({ from: '/_main/article/$slug' }) + + const handleFollowAuthor = () => { + console.log('Follow author:', authorID) + } + + const handleReport = () => { + console.log('Report article:', articleID) + } + + const handleEdit = () => { + navigate({ to: '/article/edit/$slug', params: { slug } }) + } + + const handleDelete = () => { + console.log('Delete article:', articleID) + } + + if (!userID) { + return null + } + + return ( + + + + + + + + Follow Author + + + + Report Article + + {isAuthor && ( + <> + + + + Edit + + + + Delete + + + )} + + + ) +} + +export default ArticleMoreButton diff --git a/ui/src/components/article/article-share-button.tsx b/ui/src/components/article/article-share-button.tsx new file mode 100644 index 0000000..ed9e96a --- /dev/null +++ b/ui/src/components/article/article-share-button.tsx @@ -0,0 +1,108 @@ +import { HugeiconsIcon } from '@hugeicons/react' +import { + Copy01Icon, + Linkedin01Icon, + RedditIcon, + Share01Icon, + XingIcon, +} from '@hugeicons/core-free-icons' +import { toast } from 'sonner' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu' + +interface ArticleShareButtonProps { + slug: string + articleTitle: string +} + +function ArticleShareButton({ + slug, + articleTitle, +}: ArticleShareButtonProps) { + const shareUrl = + typeof window !== 'undefined' + ? `${window.location.origin}/article/${slug}` + : `/article/${slug}` + + const encodedUrl = encodeURIComponent(shareUrl) + const encodedTitle = encodeURIComponent(articleTitle) + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(shareUrl) + toast.success('Link copied to clipboard') + } catch { + toast.error('Failed to copy link') + } + } + + const handleShareFacebook = () => { + const url = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}` + window.open(url, '_blank', 'width=600,height=400') + } + + const handleShareX = () => { + const url = `https://x.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}` + window.open(url, '_blank', 'width=600,height=400') + } + + const handleShareReddit = () => { + const url = `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}` + window.open(url, '_blank', 'width=600,height=400') + } + + const handleShareLinkedIn = () => { + const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}` + window.open(url, '_blank', 'width=600,height=400') + } + + return ( + + + + + + + + Copy Link + + + + + + Share to Facebook + + + + Share to X + + + + Share to Reddit + + + + Share to LinkedIn + + + + ) +} + +export default ArticleShareButton diff --git a/ui/src/components/article/article-view.tsx b/ui/src/components/article/article-view.tsx new file mode 100644 index 0000000..3de4c6c --- /dev/null +++ b/ui/src/components/article/article-view.tsx @@ -0,0 +1,84 @@ +import { useCallback, useRef } from 'react' +import ContentBar from './content-bar' +import CommentView from './comment-view' +import { SafeHtmlRenderer } from './safe-html-renderer' +import type { User } from 'better-auth' +import type { SingleArticleResponse } from '@/lib/types' +import { formatSmartTime } from '@/lib/dyajs' + +interface ArticleViewProps { + article: SingleArticleResponse + author: User | null | undefined +} + +const ArticleView = ({ article, author }: ArticleViewProps) => { + const commentViewRef = useRef(null) + + const scrollToComments = useCallback(() => { + commentViewRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + }, []) + + return ( +
+
+

+ {article.title} +

+ {article.author && ( +
+ {article.author.name} +

{article.author.name}

+ · +

+ {formatSmartTime(article.createdAt)} +

+
+ )} + + {article.preview_text} + + + + + +
+ +
+
+
+ ) +} + +export default ArticleView diff --git a/ui/src/components/article/comment-view.tsx b/ui/src/components/article/comment-view.tsx new file mode 100644 index 0000000..f04cb42 --- /dev/null +++ b/ui/src/components/article/comment-view.tsx @@ -0,0 +1,9 @@ +interface CommentViewProps { + article_id: string +} + +const CommentView = ({ article_id }: CommentViewProps) => { + return
{article_id}
+} + +export default CommentView diff --git a/ui/src/components/article/content-bar.tsx b/ui/src/components/article/content-bar.tsx new file mode 100644 index 0000000..9a9e5a9 --- /dev/null +++ b/ui/src/components/article/content-bar.tsx @@ -0,0 +1,78 @@ +import { HugeiconsIcon } from '@hugeicons/react' +import { Comment02Icon, FavouriteIcon } from '@hugeicons/core-free-icons' +import { Button } from '../ui/button' +import ArticleMoreButton from './article-more-button' +import ArticleShareButton from './article-share-button' +import { useToggleArticleLike } from '@/data/queries/article' +import { cn } from '@/lib/utils' + +interface ContentBarProps { + likesCount: number + isLiked: boolean + articleID: string + slug: string + authorID: string + articleTitle: string + userID?: string | null + onCommentClick?: () => void +} + +const ContentBar = ({ + likesCount, + isLiked, + articleID, + slug, + authorID, + articleTitle, + userID, + onCommentClick, +}: ContentBarProps) => { + const { mutate: toggleLike } = useToggleArticleLike() + + return ( +
+
+
+
+ +

{likesCount}

+
+ +
+
+ + +
+
+
+ ) +} + +export default ContentBar diff --git a/ui/src/components/article/safe-html-renderer.tsx b/ui/src/components/article/safe-html-renderer.tsx new file mode 100644 index 0000000..745aeac --- /dev/null +++ b/ui/src/components/article/safe-html-renderer.tsx @@ -0,0 +1,56 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import hljs from 'highlight.js' +import 'highlight.js/styles/atom-one-dark.css' +import DOMPurify from 'dompurify' +import { cn } from '@/lib/utils' + +type SafeHtmlRendererProps = { + /** + * HTML content to render. This content should already be sanitized upstream + * as a best practice; client-side sanitization here is an additional safety measure. + */ + htmlContent: string + className?: string +} + +export const SafeHtmlRenderer = ({ + htmlContent, + className, +}: SafeHtmlRendererProps) => { + const containerRef = useRef(null) + const contentVersionRef = useRef(0) + const lastHighlightedVersionRef = useRef(-1) + + // Client-side sanitization as a last line of defense + const safeHtml = useMemo(() => { + return DOMPurify.sanitize(htmlContent) + }, [htmlContent]) + + useLayoutEffect(() => { + contentVersionRef.current += 1 + }) + + useLayoutEffect(() => { + if ( + containerRef.current && + lastHighlightedVersionRef.current !== contentVersionRef.current + ) { + containerRef.current.querySelectorAll('pre code').forEach((block) => { + hljs.highlightElement(block as HTMLElement) + }) + lastHighlightedVersionRef.current = contentVersionRef.current + } + }) + + return ( +
+ ) +} diff --git a/ui/src/components/home/AvaterBtn.tsx b/ui/src/components/home/avater-btn.tsx similarity index 100% rename from ui/src/components/home/AvaterBtn.tsx rename to ui/src/components/home/avater-btn.tsx diff --git a/ui/src/components/home/GetStartedBtn.tsx b/ui/src/components/home/get-started-btn.tsx similarity index 100% rename from ui/src/components/home/GetStartedBtn.tsx rename to ui/src/components/home/get-started-btn.tsx diff --git a/ui/src/components/home/HeroSection.tsx b/ui/src/components/home/hero-section.tsx similarity index 100% rename from ui/src/components/home/HeroSection.tsx rename to ui/src/components/home/hero-section.tsx diff --git a/ui/src/components/home/HomeComponent.tsx b/ui/src/components/home/home-component.tsx similarity index 94% rename from ui/src/components/home/HomeComponent.tsx rename to ui/src/components/home/home-component.tsx index a7ed500..30a5a8f 100644 --- a/ui/src/components/home/HomeComponent.tsx +++ b/ui/src/components/home/home-component.tsx @@ -23,7 +23,7 @@ export function HomeComponent() { className="text-5xl lg:text-7xl font-bold tracking-tight text-foreground leading-[1.1]" > - Share Your Story
+ Share Your Article
Connect the World

MarkLink is the platform for writers, thinkers, and creators. Write - beautiful stories, build your audience, and discover great content. + beautiful articles, build your audience, and discover great content.

- Our Story + Our Article Write {User ? : } diff --git a/ui/src/components/shared/TagInput.tsx b/ui/src/components/shared/tag-input.tsx similarity index 100% rename from ui/src/components/shared/TagInput.tsx rename to ui/src/components/shared/tag-input.tsx diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 4735b47..d104cc4 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cn } from '@ui/lib/utils' import type { VariantProps } from 'class-variance-authority' const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", { variants: { variant: { diff --git a/ui/src/components/write/edit-component.tsx b/ui/src/components/write/edit-component.tsx new file mode 100644 index 0000000..707f625 --- /dev/null +++ b/ui/src/components/write/edit-component.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import EditorComponent from '../Editor/editor' +import { useArticleEditor } from '../Editor/use-editor' +import { TitileInput } from './title-input' +import { EditNavbar } from './edit-navbar' +import type { Article } from '@/lib/types' + +interface EditComponentProps { + article: Article +} + +export function EditComponent({ article }: EditComponentProps) { + const [title, setTitle] = React.useState(article.title) + const [content, setContent] = React.useState(article.content) + const editor = useArticleEditor({ + content: article.content, + onUpdate: ({ editor: editorInstance }) => { + setContent(editorInstance.getHTML()) + }, + }) + + if (!editor) { + return null + } + + return ( +
+ +
+ +
+ +
+
+
+ ) +} diff --git a/ui/src/components/write/edit-navbar.tsx b/ui/src/components/write/edit-navbar.tsx new file mode 100644 index 0000000..6002142 --- /dev/null +++ b/ui/src/components/write/edit-navbar.tsx @@ -0,0 +1,40 @@ +import { Logo } from '@ui/components/layout/logo' +import { UpdateButton } from './update-button' + +interface EditNavbarProps { + articleId: string + title: string + content: string + slug: string + existingData: { + title: string + preview_image: string + preview_text: string + tags: Array + } +} + +export function EditNavbar({ + articleId, + title, + content, + slug, + existingData, +}: EditNavbarProps) { + return ( +
+
+
+ + +
+
+
+ ) +} diff --git a/ui/src/components/write/ImageUpload.tsx b/ui/src/components/write/image-upload.tsx similarity index 95% rename from ui/src/components/write/ImageUpload.tsx rename to ui/src/components/write/image-upload.tsx index 90121e9..5184a52 100644 --- a/ui/src/components/write/ImageUpload.tsx +++ b/ui/src/components/write/image-upload.tsx @@ -69,8 +69,8 @@ export function ImageUpload({ ) : (

- Include a high-quality image in your story to make it more inviting - to readers. + Include a high-quality image in your article to make it more + inviting to readers.

)} diff --git a/ui/src/components/write/PublishButton.tsx b/ui/src/components/write/publish-button.tsx similarity index 80% rename from ui/src/components/write/PublishButton.tsx rename to ui/src/components/write/publish-button.tsx index 253362b..9da0925 100644 --- a/ui/src/components/write/PublishButton.tsx +++ b/ui/src/components/write/publish-button.tsx @@ -2,15 +2,13 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { z } from 'zod' import { Button } from '@ui/components/ui/button' import { Dialog, DialogContent, DialogTrigger } from '@ui/components/ui/dialog' -import { Input } from '@ui/components/ui/input' -import { TagInput } from '@ui/components/shared/TagInput' +import { TagInput } from '@ui/components/shared/tag-input' import { useUploadImage } from '@ui/hooks/use-upload-image' import { Textarea } from '@ui/components/ui/textarea' -import { useMutation } from '@tanstack/react-query' -import { api } from '@ui/lib/api' +import { usePublishArticle } from '@ui/data/queries/article' import { useRouter } from '@tanstack/react-router' import { toast } from 'sonner' -import { ImageUpload } from './ImageUpload' +import { ImageUpload } from './image-upload' const MAX_PREVIEW_LENGTH = 150 const MAX_TAGS = 5 @@ -110,19 +108,8 @@ export function PublishButton({ title, content }: PublishButtonProps) { [title, content], ) - const { mutate: publishStory, isPending: isPublishing } = useMutation({ - mutationFn: async (data: PublishData) => { - const { data: res, error } = await api.api.v1.article.post(data) - if (error) throw error - return res - }, - onError: () => { - toast.error('Failed to publish story') - }, - onSuccess: (mydata) => { - router.navigate({ to: `/article/${mydata.slug}` }) - }, - }) + const { mutate: publishArticle, isPending: isPublishing } = + usePublishArticle() return ( @@ -133,7 +120,7 @@ export function PublishButton({ title, content }: PublishButtonProps) {
{/* Left Column - Preview */}
-

Story Preview

+

Article Preview

- + Title + +