├── .prettierrc ├── server ├── constants.ts ├── lib │ └── slugify.ts ├── response.ts ├── guards │ ├── require-blog-access.ts │ └── require-auth.ts ├── decorators │ └── gql-context.ts ├── services │ ├── post.service.ts │ └── blog.service.ts ├── prisma.ts ├── markdown │ ├── excerpt-plugin.ts │ └── index.ts ├── auth.ts ├── passport.ts └── resolvers │ ├── blog.resolver.ts │ └── post.resolver.ts ├── src ├── components │ ├── TagsInput.tsx │ ├── Container.tsx │ ├── Button.tsx │ ├── layouts │ │ ├── BaseLayout.tsx │ │ ├── AppLayout.tsx │ │ └── BlogLayout.tsx │ ├── LikeButton.tsx │ ├── PostList.tsx │ ├── dashboard │ │ └── BlogSidebar.tsx │ └── PostEditor.tsx ├── css │ ├── tailwind.css │ ├── post.css │ ├── nprogress.css │ ├── prism.css │ ├── main.css │ ├── codemirror-monokai.css │ └── codemirror.css ├── graphql │ ├── deletePost.graphql │ ├── getMyBlogs.graphql │ ├── setLastActiveBlog.graphql │ ├── getBlogForDashboard.graphql │ ├── likePost.graphql │ ├── createBlog.graphql │ ├── getPostForEdit.graphql │ ├── updateBlog.graphql │ ├── updatePost.graphql │ ├── getPostsForDashboard.graphql │ └── createPost.graphql ├── lib │ ├── editor.ts │ └── urql-client.ts ├── pages │ ├── api │ │ ├── auth │ │ │ ├── github │ │ │ │ ├── callback.ts │ │ │ │ └── index.ts │ │ │ └── logout.ts │ │ ├── graphql.ts │ │ └── feed │ │ │ └── [blog].ts │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── dashboard │ │ ├── [blog] │ │ │ ├── new-post.tsx │ │ │ ├── edit-post │ │ │ │ └── [post].tsx │ │ │ ├── index.tsx │ │ │ └── settings.tsx │ │ └── index.tsx │ ├── login.tsx │ ├── about.tsx │ ├── [blog] │ │ ├── tags │ │ │ └── [tag].tsx │ │ ├── subscribe.tsx │ │ ├── index.tsx │ │ └── [post].tsx │ ├── privacy.tsx │ ├── index.tsx │ ├── blogs.tsx │ ├── new-blog.tsx │ └── terms.tsx └── generated │ └── graphql.tsx ├── .gitignore ├── next-env.d.ts ├── postcss.config.js ├── babel.config.js ├── prisma ├── migrations │ ├── 20210430091340_add_post_deleted_at │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20210427165515_add_last_active_blog │ │ └── migration.sql │ └── 20210427141947_init │ │ └── migration.sql └── schema.prisma ├── .editorconfig ├── .vscode └── settings.json ├── .env.example ├── types.d.ts ├── graphql-codegen.yml ├── tailwind.config.js ├── README.md ├── scripts └── with-env.js ├── .github └── workflows │ └── node.js.yml ├── tsconfig.json ├── next.config.js └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | "@egoist/prettier-config" -------------------------------------------------------------------------------- /server/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_COOKIE_NAME = `app.auth` 2 | -------------------------------------------------------------------------------- /src/components/TagsInput.tsx: -------------------------------------------------------------------------------- 1 | export const TagsInput = () => {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | .next 5 | out/ 6 | .env 7 | -------------------------------------------------------------------------------- /src/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'tailwindcss', 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/graphql/deletePost.graphql: -------------------------------------------------------------------------------- 1 | mutation deletePost($id: Int!) { 2 | deletePost(id: $id) 3 | } 4 | -------------------------------------------------------------------------------- /src/graphql/getMyBlogs.graphql: -------------------------------------------------------------------------------- 1 | query getMyBlogs { 2 | blogs { 3 | id 4 | slug 5 | name 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/graphql/setLastActiveBlog.graphql: -------------------------------------------------------------------------------- 1 | mutation setLastActiveBlog($id: Int!) { 2 | setLastActiveBlog(id: $id) 3 | } 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['next/babel'], 3 | plugins: [ 4 | 'superjson-next', // 👈 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20210430091340_add_post_deleted_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "posts" ADD COLUMN "deletedAt" TIMESTAMP(3); 3 | -------------------------------------------------------------------------------- /server/lib/slugify.ts: -------------------------------------------------------------------------------- 1 | import limax from 'limax' 2 | 3 | export const slugify = (str: string) => 4 | limax(str, { 5 | tone: false, 6 | }) 7 | -------------------------------------------------------------------------------- /src/graphql/getBlogForDashboard.graphql: -------------------------------------------------------------------------------- 1 | query getBlogForDashboard($slug: String!) { 2 | blog(slug: $slug) { 3 | id 4 | name 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/graphql/likePost.graphql: -------------------------------------------------------------------------------- 1 | mutation likePost($postId: Int!) { 2 | likePost(postId: $postId) { 3 | likesCount 4 | isLiked 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/graphql/createBlog.graphql: -------------------------------------------------------------------------------- 1 | mutation createBlog($name: String!, $slug: String!) { 2 | createBlog(name: $name, slug: $slug) { 3 | id 4 | slug 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /src/components/Container.tsx: -------------------------------------------------------------------------------- 1 | export const Container: React.FC = ({ children }) => { 2 | return
{children}
3 | } 4 | -------------------------------------------------------------------------------- /server/response.ts: -------------------------------------------------------------------------------- 1 | import { ServerResponse } from 'http' 2 | 3 | export function redirect(res: ServerResponse, url: string) { 4 | res.writeHead(302, { Location: url }) 5 | res.end() 6 | } 7 | -------------------------------------------------------------------------------- /src/graphql/getPostForEdit.graphql: -------------------------------------------------------------------------------- 1 | query getPostForEdit($id: Int!) { 2 | post(id: $id) { 3 | id 4 | title 5 | content 6 | slug 7 | cover 8 | tags { 9 | id 10 | name 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/graphql/updateBlog.graphql: -------------------------------------------------------------------------------- 1 | mutation updateBlog( 2 | $id: Int! 3 | $name: String! 4 | $slug: String! 5 | $introduction: String! 6 | ) { 7 | updateBlog(id: $id, name: $name, slug: $slug, introduction: $introduction) { 8 | id 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/.git/objects/**": true, 4 | "**/.git/subtree-cache/**": true, 5 | "**/.hg/store/**": true 6 | }, 7 | "prisma.plugin.nextjs.addTypesOnSave": true, 8 | "prisma.plugin.nextjs.hasPrompted": true 9 | } 10 | -------------------------------------------------------------------------------- /prisma/migrations/20210427165515_add_last_active_blog/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ADD COLUMN "lastActiveBlogId" INTEGER; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "users" ADD FOREIGN KEY ("lastActiveBlogId") REFERENCES "blogs"("id") ON DELETE SET NULL ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /src/lib/editor.ts: -------------------------------------------------------------------------------- 1 | export const loadEditor = async () => { 2 | const CodeMirror = await import(/* webpackChunkName: "editor" */ 'codemirror') 3 | await import( 4 | /* webpackChunkName: "editor" */ 'codemirror/mode/markdown/markdown' 5 | ) 6 | 7 | return { 8 | CodeMirror, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/api/auth/github/callback.ts: -------------------------------------------------------------------------------- 1 | import { handleSuccessfulLogin, passport } from '@server/passport' 2 | import connect from 'next-connect' 3 | 4 | export default connect().use( 5 | passport.initialize(), 6 | passport.authenticate('github', { failureRedirect: '/login' }), 7 | handleSuccessfulLogin, 8 | ) 9 | -------------------------------------------------------------------------------- /server/guards/require-blog-access.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-micro' 2 | import { Blog } from '@prisma/client' 3 | 4 | export async function requireBlogAccess(user: { id: number }, blog: Blog) { 5 | if (user.id !== blog.userId) { 6 | throw new ApolloError(`Admin permission required!`) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/api/auth/github/index.ts: -------------------------------------------------------------------------------- 1 | import connect from 'next-connect' 2 | import { passport } from '@server/passport' 3 | 4 | const handler = connect() 5 | 6 | handler.use( 7 | passport.initialize(), 8 | passport.authenticate('github', { 9 | scope: ['email', 'user:profile'], 10 | }), 11 | ) 12 | 13 | export default handler 14 | -------------------------------------------------------------------------------- /server/decorators/gql-context.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { createParamDecorator } from 'type-graphql' 3 | 4 | export type TGqlContext = { 5 | req: NextApiRequest 6 | res: NextApiResponse 7 | } 8 | 9 | export function GqlContext() { 10 | return createParamDecorator(({ context }) => context) 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_APP_NAME=Blogify 2 | 3 | DB_USER=postgres 4 | DB_PASS=pass 5 | DB_HOST=localhost 6 | DB_PORT=5432 7 | DB_NAME=blogify 8 | DB_URL=postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public 9 | 10 | REDIS_PORT=6379 11 | 12 | GOOGLE_CLIENT_ID=xxx 13 | GOOGLE_CLIENT_SECRET=xxx 14 | 15 | GITHUB_CLIENT_ID=xxx 16 | GITHUB_CLIENT_SECRET=xxx 17 | -------------------------------------------------------------------------------- /src/graphql/updatePost.graphql: -------------------------------------------------------------------------------- 1 | mutation updatePost( 2 | $id: Int! 3 | $title: String! 4 | $content: String! 5 | $slug: String! 6 | $tags: String! 7 | $cover: String! 8 | ) { 9 | updatePost( 10 | id: $id 11 | title: $title 12 | content: $content 13 | tags: $tags 14 | slug: $slug 15 | cover: $cover 16 | ) { 17 | id 18 | slug 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/graphql/getPostsForDashboard.graphql: -------------------------------------------------------------------------------- 1 | query getPostsForDashboard( 2 | $blogSlug: String! 3 | $page: Int! 4 | $limit: Int 5 | $tagSlug: String 6 | ) { 7 | posts(blogSlug: $blogSlug, page: $page, limit: $limit, tagSlug: $tagSlug) { 8 | data { 9 | id 10 | slug 11 | title 12 | date 13 | } 14 | hasOlder 15 | hasNewer 16 | total 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/graphql/createPost.graphql: -------------------------------------------------------------------------------- 1 | mutation createPost( 2 | $title: String! 3 | $content: String! 4 | $slug: String! 5 | $tags: String! 6 | $blogSlug: String! 7 | $cover: String! 8 | ) { 9 | createPost( 10 | title: $title 11 | content: $content 12 | slug: $slug 13 | tags: $tags 14 | blogSlug: $blogSlug 15 | cover: $cover 16 | ) { 17 | id 18 | slug 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | // Additional environment variables 4 | HASH_KEY: string 5 | NEXT_PUBLIC_APP_NAME: string 6 | NEXT_PUBLIC_APP_URL: string 7 | } 8 | 9 | interface Global { 10 | prisma: any 11 | } 12 | } 13 | 14 | type $TsFixMe = any 15 | 16 | declare module 'codemirror/mode/markdown/markdown' 17 | 18 | declare module 'prismjs/components/index' 19 | -------------------------------------------------------------------------------- /graphql-codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 'http://localhost:3000/api/graphql' 3 | documents: 'src/**/*.graphql' 4 | generates: 5 | src/generated/graphql.tsx: 6 | plugins: 7 | - 'typescript' 8 | - 'typescript-operations' 9 | - 'typescript-urql' 10 | ./graphql.schema.json: 11 | plugins: 12 | - 'introspection' 13 | config: 14 | withComponents: false 15 | withHooks: true 16 | documentMode: documentNode 17 | -------------------------------------------------------------------------------- /src/pages/api/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next' 2 | import { serialize } from 'cookie' 3 | import { AUTH_COOKIE_NAME } from '@server/constants' 4 | 5 | const handler: NextApiHandler = (req, res) => { 6 | res.setHeader('Set-Cookie', [ 7 | serialize(AUTH_COOKIE_NAME, '', { 8 | maxAge: 0, 9 | path: '/', 10 | httpOnly: true, 11 | sameSite: 'lax', 12 | }), 13 | ]) 14 | res.redirect('/') 15 | } 16 | 17 | export default handler 18 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'jit', 3 | purge: ['./src/**/*.tsx'], 4 | theme: { 5 | extend: { 6 | colors: { 7 | bg: 'var(--bg)', 8 | 'bg-darker': 'var(--bg-darker)', 9 | border: 'var(--border-color)', 10 | accent: 'var(--accent-color)', 11 | 'button-border': 'var(--button-border-color)', 12 | 'button-border-hover': 'var(--button-border-hover-color)', 13 | }, 14 | }, 15 | }, 16 | variants: {}, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /server/services/post.service.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@server/prisma' 2 | 3 | export const postService = { 4 | async isLikedBy(postId: number, userId: number) { 5 | const likes = await prisma.post 6 | .findUnique({ 7 | select: { 8 | id: true, 9 | }, 10 | where: { 11 | id: postId, 12 | }, 13 | }) 14 | .likes({ 15 | where: { 16 | userId, 17 | }, 18 | }) 19 | 20 | return likes.length > 0 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /server/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Prisma } from '@prisma/client' 2 | 3 | let prisma: PrismaClient 4 | 5 | const options: Prisma.PrismaClientOptions = { 6 | log: 7 | process.env.NODE_ENV === 'development' 8 | ? ['query', 'info', 'warn', 'error'] 9 | : ['warn', 'error'], 10 | } 11 | 12 | if (process.env.NODE_ENV === 'production') { 13 | prisma = new PrismaClient(options) 14 | } else { 15 | if (!global.prisma) { 16 | global.prisma = new PrismaClient(options) 17 | } 18 | prisma = global.prisma 19 | } 20 | 21 | export { prisma } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blogify 2 | 3 | A blogging platform for minimalists and developers. 4 | 5 | https://blogify.dev 6 | 7 | ## Development 8 | 9 | Generate a Prisma client: 10 | 11 | ```bash 12 | yarn db-generate 13 | ``` 14 | 15 | Run database migration first: 16 | 17 | ```bash 18 | yarn migrate-deploy 19 | ``` 20 | 21 | Start Server: 22 | 23 | ```bash 24 | yarn dev 25 | ``` 26 | 27 | ## Deployment 28 | 29 | ```bash 30 | yarn db-generate 31 | yarn migrate-deploy 32 | yarn start 33 | ``` 34 | 35 | ## License 36 | 37 | MIT © [EGOIST](https://github.com/sponsors/egoist) 38 | -------------------------------------------------------------------------------- /scripts/with-env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run some commands with `.env` file loaded to `process.env` 3 | */ 4 | const spawn = require('cross-spawn') 5 | const dotenv = require('dotenv') 6 | const expand = require('dotenv-expand') 7 | 8 | const env = dotenv.config() 9 | expand(env) 10 | 11 | const args = process.argv.slice(2) 12 | if (args.length === 0) throw new Error(`missing script name`) 13 | 14 | console.log(`Running yarn ${args[0]}`) 15 | const cmd = spawn.sync(`yarn`, args, { 16 | env: process.env, 17 | stdio: 'inherit', 18 | }) 19 | 20 | if (cmd.error) { 21 | console.error(cmd.error) 22 | process.exitCode = 1 23 | } 24 | -------------------------------------------------------------------------------- /server/guards/require-auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from 'apollo-server-micro' 2 | import { IncomingMessage } from 'http' 3 | import { NextApiRequest } from 'next' 4 | import { getServerSession } from '@server/auth' 5 | 6 | export async function requireAuth(req: NextApiRequest | IncomingMessage) { 7 | const { user } = await getServerSession(req) 8 | 9 | if (!user) { 10 | throw new AuthenticationError(`User not found`) 11 | } 12 | 13 | return user 14 | } 15 | 16 | export async function optionalAuth(req: NextApiRequest | IncomingMessage) { 17 | const { user } = await getServerSession(req) 18 | 19 | return user 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Link from 'next/link' 3 | import React from 'react' 4 | 5 | const NotFound = () => { 6 | return ( 7 | <> 8 | 9 | 404 Not Found 10 | 11 |
12 |
13 |

14 | 404 - Not Found 15 |

16 |
17 | 18 | Return Home 19 | 20 |
21 |
22 |
23 | 24 | ) 25 | } 26 | 27 | export default NotFound 28 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: yarn 28 | - run: echo skip 29 | -------------------------------------------------------------------------------- /server/markdown/excerpt-plugin.ts: -------------------------------------------------------------------------------- 1 | import type Markdown from 'markdown-it' 2 | 3 | export const excerptPlugin = (md: Markdown, { paragraphOnly = true } = {}) => { 4 | md.renderer.rules.paragraph_close = (...args) => { 5 | const [tokens, idx, options, env, self] = args 6 | 7 | const { __excerpted } = env 8 | 9 | if (!__excerpted) { 10 | env.__excerpted = true 11 | let startIndex = 0 12 | if (paragraphOnly) { 13 | for (const [index, token] of tokens.entries()) { 14 | if (token.type === 'paragraph_open') { 15 | startIndex = index 16 | break 17 | } 18 | } 19 | } 20 | 21 | env.excerpt = self.render(tokens.slice(startIndex, idx + 1), options, env) 22 | } 23 | 24 | return self.renderToken(tokens, idx, options) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strictPropertyInitialization": false, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "baseUrl": ".", 18 | "paths": { 19 | "@server/*": ["server/*"], 20 | "@/*": ["src/*"] 21 | }, 22 | "experimentalDecorators": true, 23 | "emitDecoratorMetadata": true 24 | }, 25 | "exclude": ["node_modules"], 26 | "include": ["next-env.d.ts", "types.d.ts", "**/*.ts", "**/*.tsx"] 27 | } 28 | -------------------------------------------------------------------------------- /server/markdown/index.ts: -------------------------------------------------------------------------------- 1 | import Markdown from 'markdown-it' 2 | import Prism from 'prismjs' 3 | import loadLanguages from 'prismjs/components/index' 4 | import removeMarkdown from 'remove-markdown' 5 | 6 | // This is used to render markdown to html to display in browser 7 | export const renderMarkdown = (content: string) => { 8 | const md = new Markdown({ 9 | html: false, 10 | highlight: (code, lang) => { 11 | lang = lang || 'markup' 12 | loadLanguages([lang]) 13 | const grammer = Prism.languages[lang] 14 | return Prism.highlight(code, grammer, lang) 15 | }, 16 | }) 17 | 18 | const env = {} 19 | const html = md.render(content, env) 20 | return { 21 | html, 22 | env, 23 | } 24 | } 25 | 26 | export const getExcerpt = (content: string) => { 27 | const [excerpt] = content.split(/(|\n\n)/) 28 | 29 | return removeMarkdown(excerpt) 30 | } 31 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack(config, { dev, isServer }) { 3 | // Use ts-loader for decorators support 4 | for (const rule of config.module.rules) { 5 | if (rule.test && rule.test.test('foo.ts')) { 6 | rule.use = [].concat(rule.use, { 7 | loader: 'ts-loader', 8 | options: { 9 | transpileOnly: true, 10 | }, 11 | }) 12 | } 13 | } 14 | 15 | // Replace React with Preact in client production build 16 | if (!dev && !isServer) { 17 | Object.assign(config.resolve.alias, { 18 | react: 'preact/compat', 19 | 'react-dom/test-utils': 'preact/test-utils', 20 | 'react-dom': 'preact/compat', 21 | }) 22 | } 23 | 24 | return config 25 | }, 26 | 27 | async rewrites() { 28 | return [ 29 | { 30 | source: '/:blog/atom.xml', 31 | destination: '/api/feed/:blog', 32 | }, 33 | ] 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /server/auth.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'cookie' 2 | import { IncomingMessage } from 'http' 3 | import { NextApiRequest } from 'next' 4 | import { AUTH_COOKIE_NAME } from './constants' 5 | import { prisma } from './prisma' 6 | 7 | export type UserSession = { 8 | id: number 9 | name: string 10 | avatar?: string | null 11 | } 12 | 13 | export const getServerSession = async ( 14 | req: NextApiRequest | IncomingMessage, 15 | ): Promise<{ user: UserSession | null }> => { 16 | const token = parse(req.headers.cookie || '')[AUTH_COOKIE_NAME] 17 | 18 | if (!token) return { user: null } 19 | 20 | const session = await prisma.session.findUnique({ 21 | where: { 22 | token, 23 | }, 24 | include: { 25 | user: true, 26 | }, 27 | }) 28 | 29 | if (!session) { 30 | return { user: null } 31 | } 32 | 33 | return { 34 | user: { 35 | id: session.user.id, 36 | name: session.user.name, 37 | avatar: session.user.avatar, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@snackbar/core/dist/snackbar.css' 2 | import '../css/nprogress.css' 3 | import '../css/prism.css' 4 | import '../css/codemirror.css' 5 | import '../css/codemirror-monokai.css' 6 | import '../css/tailwind.css' 7 | import '../css/main.css' 8 | import '../css/post.css' 9 | import { Provider as UrqlProvider } from 'urql' 10 | import React from 'react' 11 | import { useRouter } from 'next/router' 12 | import nprogress from 'nprogress' 13 | import { getUrqlClient } from '@/lib/urql-client' 14 | 15 | const App = ({ Component, pageProps }: any) => { 16 | const urqlClient = getUrqlClient() 17 | const router = useRouter() 18 | 19 | React.useEffect(() => { 20 | router.events.on('routeChangeStart', () => { 21 | nprogress.start() 22 | }) 23 | router.events.on('routeChangeComplete', () => { 24 | nprogress.done() 25 | }) 26 | router.events.on('routeChangeError', () => { 27 | nprogress.done() 28 | }) 29 | }, []) 30 | 31 | return ( 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | const isProd = process.env.NODE_ENV === 'production' 4 | 5 | class MyDocument extends Document { 6 | static async getInitialProps(ctx: any) { 7 | const initialProps = await Document.getInitialProps(ctx) 8 | return { ...initialProps } 9 | } 10 | 11 | render() { 12 | return ( 13 | 14 | 15 | 16 | 20 | 21 | 22 |
23 | 24 | {isProd && ( 25 | 30 | )} 31 | 32 | 33 | ) 34 | } 35 | } 36 | 37 | export default MyDocument 38 | -------------------------------------------------------------------------------- /src/pages/dashboard/[blog]/new-post.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession, UserSession } from '@server/auth' 2 | import { GetServerSideProps } from 'next' 3 | import React from 'react' 4 | import { blogService } from '@server/services/blog.service' 5 | import { PostEditor } from '@/components/PostEditor' 6 | import { BlogInfo } from '@/components/layouts/BlogLayout' 7 | 8 | type PageProps = { 9 | user: UserSession 10 | blog: BlogInfo 11 | } 12 | 13 | export const getServerSideProps: GetServerSideProps = async ( 14 | ctx, 15 | ) => { 16 | const { user } = await getServerSession(ctx.req) 17 | if (!user) { 18 | return { 19 | redirect: { 20 | destination: '/login', 21 | permanent: false, 22 | }, 23 | } 24 | } 25 | 26 | const blog = await blogService.getBlogBySlug(ctx.query.blog as string) 27 | 28 | if (!blog) { 29 | return { notFound: true } 30 | } 31 | 32 | return { 33 | props: { 34 | user, 35 | blog, 36 | }, 37 | } 38 | } 39 | 40 | const NewPost: React.FC = ({ user, blog }) => { 41 | return 42 | } 43 | 44 | export default NewPost 45 | -------------------------------------------------------------------------------- /src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { AppLayout } from '@/components/layouts/AppLayout' 2 | 3 | const Login = () => { 4 | return ( 5 | 6 | 23 | 24 | ) 25 | } 26 | 27 | export default Login 28 | -------------------------------------------------------------------------------- /src/pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { ApolloServer } from 'apollo-server-micro' 3 | import { NextApiHandler } from 'next' 4 | import { buildSchema } from 'type-graphql' 5 | import { BlogResolver } from '@server/resolvers/blog.resolver' 6 | import { PostResolver } from '@server/resolvers/post.resolver' 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | } 13 | 14 | let handler: any 15 | 16 | const isProd = process.env.NODE_ENV === 'production' 17 | 18 | const apiHandler: NextApiHandler = async (req, res) => { 19 | if (handler && isProd) { 20 | return handler(req, res) 21 | } 22 | 23 | const schema = await buildSchema({ 24 | resolvers: [BlogResolver, PostResolver], 25 | }) 26 | 27 | const apolloServer = new ApolloServer({ 28 | schema, 29 | playground: !isProd, 30 | tracing: !isProd, 31 | context({ req, res }) { 32 | return { 33 | req, 34 | res, 35 | user: req.user, 36 | } 37 | }, 38 | }) 39 | 40 | handler = apolloServer.createHandler({ 41 | path: `/api/graphql`, 42 | }) 43 | 44 | return handler(req, res) 45 | } 46 | 47 | export default apiHandler 48 | -------------------------------------------------------------------------------- /src/pages/dashboard/[blog]/edit-post/[post].tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession, UserSession } from '@server/auth' 2 | import { GetServerSideProps } from 'next' 3 | import React from 'react' 4 | import { blogService } from '@server/services/blog.service' 5 | import { PostEditor } from '@/components/PostEditor' 6 | import { BlogInfo } from '@/components/layouts/BlogLayout' 7 | 8 | type PageProps = { 9 | user: UserSession 10 | blog: BlogInfo 11 | postId: number 12 | } 13 | 14 | export const getServerSideProps: GetServerSideProps = async ( 15 | ctx, 16 | ) => { 17 | const { user } = await getServerSession(ctx.req) 18 | if (!user) { 19 | return { 20 | redirect: { 21 | destination: '/login', 22 | permanent: false, 23 | }, 24 | } 25 | } 26 | 27 | const blog = await blogService.getBlogBySlug(ctx.query.blog as string) 28 | 29 | if (!blog) { 30 | return { notFound: true } 31 | } 32 | 33 | const postId = parseInt(ctx.query.post as string, 10) 34 | 35 | return { 36 | props: { 37 | user, 38 | blog, 39 | postId, 40 | }, 41 | } 42 | } 43 | 44 | const EditPost: React.FC = ({ user, blog, postId }) => { 45 | return 46 | } 47 | 48 | export default EditPost 49 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from '@server/auth' 2 | import { prisma } from '@server/prisma' 3 | import { GetServerSideProps } from 'next' 4 | 5 | export const getServerSideProps: GetServerSideProps = async (ctx) => { 6 | const { user } = await getServerSession(ctx.req) 7 | if (!user) { 8 | return { 9 | redirect: { 10 | destination: '/login', 11 | permanent: false, 12 | }, 13 | } 14 | } 15 | let { lastActiveBlog } = 16 | (await prisma.user.findUnique({ 17 | where: { 18 | id: user.id, 19 | }, 20 | include: { 21 | lastActiveBlog: true, 22 | }, 23 | })) || {} 24 | 25 | if (!lastActiveBlog) { 26 | const lastestBlog = await prisma.blog.findFirst({ 27 | where: { 28 | userId: user.id, 29 | }, 30 | orderBy: { 31 | createdAt: 'desc', 32 | }, 33 | }) 34 | if (!lastestBlog) { 35 | return { 36 | redirect: { 37 | destination: '/new-blog', 38 | permanent: false, 39 | }, 40 | } 41 | } 42 | lastActiveBlog = lastestBlog 43 | } 44 | 45 | return { 46 | redirect: { 47 | destination: `/dashboard/${lastActiveBlog.slug}`, 48 | permanent: false, 49 | }, 50 | } 51 | } 52 | 53 | export default function () {} 54 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | export const Button: React.FC<{ 4 | type?: 'button' | 'submit' 5 | size?: string 6 | variant?: string 7 | isLoading?: boolean 8 | disabled?: boolean 9 | }> = ({ type, children, size, variant, isLoading, disabled }) => { 10 | return ( 11 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/urql-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createClient, 3 | dedupExchange, 4 | cacheExchange, 5 | fetchExchange, 6 | ssrExchange, 7 | Client, 8 | } from '@urql/core' 9 | import React from 'react' 10 | 11 | const isServerSide = !process.browser 12 | 13 | let urqlClient: Client | undefined 14 | 15 | const createUrqlClient = (initialState?: any) => { 16 | // The `ssrExchange` must be initialized with `isClient` and `initialState` 17 | const ssr = ssrExchange({ 18 | isClient: !isServerSide, 19 | initialState: isServerSide ? undefined : initialState, 20 | }) 21 | return createClient({ 22 | url: `/api/graphql`, 23 | fetchOptions: { 24 | credentials: 'same-origin', 25 | }, 26 | exchanges: [ 27 | dedupExchange, 28 | cacheExchange, 29 | ssr, // Add `ssr` in front of the `fetchExchange` 30 | fetchExchange, 31 | ], 32 | }) 33 | } 34 | 35 | const initializeUrqlClient = (initialState?: any) => { 36 | const client = urqlClient || createUrqlClient(initialState) 37 | 38 | // For SSG and SSR always create a new Apollo Client 39 | if (isServerSide) return client 40 | // Create the Urql Client only once in the client 41 | if (!urqlClient) urqlClient = client 42 | 43 | return client 44 | } 45 | 46 | export const getUrqlClient = (initialState?: any) => { 47 | const store = React.useMemo(() => initializeUrqlClient(initialState), [ 48 | initialState, 49 | ]) 50 | return store 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import { AppLayout } from '@/components/layouts/AppLayout' 2 | import { getServerSession, UserSession } from '@server/auth' 3 | import { GetServerSideProps } from 'next' 4 | 5 | type PageProps = { 6 | user: UserSession | null 7 | } 8 | 9 | export const getServerSideProps: GetServerSideProps = async ( 10 | ctx, 11 | ) => { 12 | const { user } = await getServerSession(ctx.req) 13 | 14 | return { 15 | props: { 16 | user, 17 | }, 18 | } 19 | } 20 | 21 | const About: React.FC = ({ user }) => { 22 | return ( 23 | 24 |

About

25 |
26 |

Blogify is a blogging platform for minimalists and developers.

27 |

Yet Another Blogging Platform?

28 |

29 | There're a lot places where you can host a blog, for free, but Blogify 30 | is designed to be simple instead of bloated by features. 31 |

32 |

Your privacy is guaranteed

33 |

Nobody resells your private data or track your usage.

34 |

No advertising

35 |

Enjoy the ad-free experience!

36 |

Free, open source and self-hosted

37 |

38 | Blogify is free to use, the source code is also available on GitHub. 39 |

40 |
41 |
42 | ) 43 | } 44 | 45 | export default About 46 | -------------------------------------------------------------------------------- /src/pages/api/feed/[blog].ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@server/prisma' 2 | import { blogService } from '@server/services/blog.service' 3 | import { NextApiHandler } from 'next' 4 | import { Feed } from 'feed' 5 | import { renderMarkdown } from '@server/markdown' 6 | 7 | const handler: NextApiHandler = async (req, res) => { 8 | const blogSlug = req.query.blog as string 9 | const blog = await prisma.blog.findUnique({ 10 | where: { 11 | slug: blogSlug, 12 | }, 13 | include: { 14 | user: true, 15 | }, 16 | }) 17 | if (!blog) { 18 | res.status(404) 19 | res.end('404') 20 | return 21 | } 22 | const posts = await prisma.post.findMany({ 23 | where: { 24 | blog: { 25 | id: blog.id, 26 | }, 27 | }, 28 | take: 30, 29 | orderBy: { 30 | createdAt: 'desc', 31 | }, 32 | }) 33 | const url = `https://blogify.dev/${blog.slug}` 34 | const feed = new Feed({ 35 | id: url, 36 | title: blog.name, 37 | author: { 38 | name: blog.user.name, 39 | email: '', 40 | link: url, 41 | }, 42 | copyright: `all rights reserved`, 43 | }) 44 | for (const post of posts) { 45 | const { html } = renderMarkdown(post.content) 46 | feed.addItem({ 47 | title: post.title, 48 | description: post.excerpt, 49 | content: html, 50 | link: `${url}/${post.slug}`, 51 | date: post.createdAt, 52 | }) 53 | } 54 | res.setHeader('Content-Type', `application/xml`) 55 | res.end(feed.atom1()) 56 | } 57 | 58 | export default handler 59 | -------------------------------------------------------------------------------- /src/css/post.css: -------------------------------------------------------------------------------- 1 | .post-cover { 2 | @apply mx-auto; 3 | @apply mb-8; 4 | } 5 | 6 | .rich-content { 7 | @apply leading-7; 8 | @apply text-xl; 9 | } 10 | 11 | .rich-content > *:first-child { 12 | @apply mt-0; 13 | } 14 | 15 | .rich-content > *:last-child { 16 | @apply mb-0; 17 | } 18 | 19 | .rich-content a { 20 | color: var(--link-color); 21 | @apply underline; 22 | } 23 | 24 | .rich-content p { 25 | @apply my-6; 26 | } 27 | 28 | .rich-content ul { 29 | @apply list-disc; 30 | padding-left: 20px; 31 | } 32 | 33 | .rich-content ol { 34 | @apply list-decimal; 35 | padding-left: 20px; 36 | } 37 | 38 | .rich-content ul li:not(:last-child), 39 | .rich-content ol li:not(:last-child) { 40 | @apply mb-2; 41 | } 42 | 43 | .rich-content ul li ul, 44 | .rich-content ol li ol { 45 | @apply mt-1; 46 | } 47 | 48 | .rich-content blockquote { 49 | @apply border-l-4; 50 | @apply border-button-border; 51 | @apply pl-5; 52 | @apply py-2; 53 | @apply my-5; 54 | @apply text-lg; 55 | } 56 | 57 | .rich-content blockquote > *:first-child { 58 | @apply mt-0; 59 | } 60 | 61 | .rich-content blockquote > *:last-child { 62 | @apply mb-0; 63 | } 64 | 65 | .rich-content pre { 66 | background-color: var(--code-block-bg); 67 | @apply p-5; 68 | @apply my-8; 69 | } 70 | 71 | .rich-content img { 72 | max-width: 100%; 73 | } 74 | 75 | .rich-content h2 { 76 | @apply text-2xl; 77 | @apply my-8; 78 | } 79 | 80 | .rich-content h3 { 81 | @apply text-xl; 82 | } 83 | 84 | .rich-content h4 { 85 | @apply text-lg; 86 | } 87 | 88 | .rich-content h5 { 89 | @apply text-base; 90 | @apply font-bold; 91 | } 92 | 93 | .rich-content :not(pre) code { 94 | padding: 3px 5px; 95 | border-radius: 4px; 96 | font-size: 13px; 97 | background: black; 98 | } 99 | -------------------------------------------------------------------------------- /src/components/layouts/BaseLayout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Head from 'next/head' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | import React from 'react' 6 | 7 | type NavItem = { 8 | text: string 9 | href: string 10 | } 11 | 12 | export const BaseLayout: React.FC<{ 13 | nav: NavItem[] 14 | title: string 15 | headerTitle: string 16 | headerTitleHref?: string 17 | footer: React.ReactElement 18 | }> = ({ nav, title, children, headerTitle, headerTitleHref, footer }) => { 19 | const router = useRouter() 20 | return ( 21 | <> 22 | 23 | {title} 24 | 25 |
26 |
27 |

28 | 29 | {headerTitle} 30 | 31 |

32 |
33 | {nav.map((link) => { 34 | return ( 35 | 36 | 41 | {link.text} 42 | 43 | 44 | ) 45 | })} 46 |
47 |
48 |
49 | 50 |
51 |
{children}
52 |
53 | 54 |
55 |
{footer}
56 |
57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | import { useLikePostMutation } from '@/generated/graphql' 2 | import clsx from 'clsx' 3 | import { useRouter } from 'next/router' 4 | import React from 'react' 5 | 6 | export const LikeButton: React.FC<{ 7 | count: number 8 | postId: number 9 | hasLogin: boolean 10 | isLiked?: boolean 11 | }> = ({ count, postId, hasLogin, isLiked }) => { 12 | const [, likePostMutation] = useLikePostMutation() 13 | const [actualCount, setActualCount] = React.useState(count) 14 | const [actualIsLiked, setActualIsLiked] = React.useState( 15 | isLiked, 16 | ) 17 | const router = useRouter() 18 | 19 | const likePost = async () => { 20 | if (!hasLogin) { 21 | return router.push({ 22 | pathname: '/login', 23 | query: { 24 | return: router.asPath, 25 | }, 26 | }) 27 | } 28 | const { data } = await likePostMutation({ 29 | postId, 30 | }) 31 | if (data) { 32 | setActualCount(data.likePost.likesCount) 33 | setActualIsLiked(data.likePost.isLiked) 34 | } 35 | } 36 | 37 | return ( 38 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/[blog]/tags/[tag].tsx: -------------------------------------------------------------------------------- 1 | import { BlogInfo, BlogLayout } from '@/components/layouts/BlogLayout' 2 | import { PostList } from '@/components/PostList' 3 | import { getServerSession, UserSession } from '@server/auth' 4 | 5 | import { prisma } from '@server/prisma' 6 | import { blogService } from '@server/services/blog.service' 7 | import { GetServerSideProps } from 'next' 8 | 9 | type PageProps = { 10 | blog: BlogInfo 11 | tagName: string 12 | posts: { 13 | data: Array<{ 14 | title: string 15 | slug: string 16 | date: string 17 | excerpt: string 18 | cover?: string | null 19 | coverAlt?: string | null 20 | tags: Array<{ 21 | name: string 22 | slug: string 23 | }> 24 | }> 25 | hasOlder: boolean 26 | hasNewer: boolean 27 | } 28 | } 29 | 30 | export const getServerSideProps: GetServerSideProps = async ( 31 | ctx, 32 | ) => { 33 | const blogSlug = ctx.query.blog as string 34 | const blog = await blogService.getBlogBySlug(blogSlug) 35 | if (!blog) { 36 | return { notFound: true } 37 | } 38 | const tagSlug = ctx.query.tag as string 39 | const tag = await prisma.tag.findFirst({ 40 | where: { 41 | blogId: blog.id, 42 | slug: tagSlug, 43 | }, 44 | }) 45 | if (!tag) { 46 | return { notFound: true } 47 | } 48 | const page = parseInt((ctx.query.page as string) || '1') 49 | const posts = await blogService.getPosts(blog.id, { tag: tagSlug, page }) 50 | return { 51 | props: { 52 | blog, 53 | tagName: tag.name, 54 | posts, 55 | }, 56 | } 57 | } 58 | 59 | const TagsPage: React.FC = ({ blog, tagName, posts }) => { 60 | return ( 61 | 62 |

Posts in: #{tagName}

63 | 64 |
65 | ) 66 | } 67 | 68 | export default TagsPage 69 | -------------------------------------------------------------------------------- /src/pages/privacy.tsx: -------------------------------------------------------------------------------- 1 | import { AppLayout } from '@/components/layouts/AppLayout' 2 | import { getServerSession, UserSession } from '@server/auth' 3 | import { GetServerSideProps } from 'next' 4 | 5 | type PageProps = { 6 | user: UserSession | null 7 | } 8 | 9 | export const getServerSideProps: GetServerSideProps = async ( 10 | ctx, 11 | ) => { 12 | const { user } = await getServerSession(ctx.req) 13 | 14 | return { 15 | props: { 16 | user, 17 | }, 18 | } 19 | } 20 | 21 | const Privacy: React.FC = ({ user }) => { 22 | return ( 23 | 24 |

Privacy Policy

25 |
26 |

What information Blogify collects and Why

27 |

28 | Blogify doesn't collect or store any personal information besides your 29 | GitHub public profile. 30 |

31 |

The Use of Cookies

32 |

33 | Cookies are necessary for the Site to function and cannot be switched 34 | off in our systems. For example, we use cookies to authenticate you. 35 | When you log on to our websites, authentication cookies are set which 36 | let us know who you are during a browsing session. We have to load 37 | essential cookies for legitimate interests pursued by us in delivering 38 | our Site's essential functionality to you. 39 |

40 |

Third Party Vendors

41 |

Server

42 |

43 | Our website is hosted by Hetzner, a well-known Internet hosting 44 | company located at Germany, Europe. 45 |

46 |

Storage

47 |

48 | Images and other kinds of static files uploaded by users are stored on 49 | Amazon S3. 50 |

51 |
52 |
53 | ) 54 | } 55 | 56 | export default Privacy 57 | -------------------------------------------------------------------------------- /src/css/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | --peg-color: transparent; 5 | --progress-spinner-color: var(--link-color); 6 | } 7 | 8 | #nprogress .bar { 9 | background: var(--peg-color); 10 | 11 | position: fixed; 12 | z-index: 1031; 13 | top: 0; 14 | left: 0; 15 | 16 | width: 100%; 17 | height: 2px; 18 | } 19 | 20 | /* Fancy blur effect */ 21 | #nprogress .peg { 22 | display: block; 23 | position: absolute; 24 | right: 0px; 25 | width: 100px; 26 | height: 100%; 27 | box-shadow: 0 0 10px var(--peg-color), 0 0 5px var(--peg-color); 28 | opacity: 1; 29 | 30 | -webkit-transform: rotate(3deg) translate(0px, -4px); 31 | -ms-transform: rotate(3deg) translate(0px, -4px); 32 | transform: rotate(3deg) translate(0px, -4px); 33 | } 34 | 35 | /* Remove these to get rid of the spinner */ 36 | #nprogress .spinner { 37 | display: block; 38 | position: fixed; 39 | z-index: 1031; 40 | top: 15px; 41 | right: 15px; 42 | } 43 | 44 | #nprogress .spinner-icon { 45 | width: 18px; 46 | height: 18px; 47 | box-sizing: border-box; 48 | 49 | border: solid 2px transparent; 50 | border-top-color: var(--progress-spinner-color); 51 | border-left-color: var(--progress-spinner-color); 52 | border-radius: 50%; 53 | 54 | -webkit-animation: nprogress-spinner 400ms linear infinite; 55 | animation: nprogress-spinner 400ms linear infinite; 56 | } 57 | 58 | .nprogress-custom-parent { 59 | overflow: hidden; 60 | position: relative; 61 | } 62 | 63 | .nprogress-custom-parent #nprogress .spinner, 64 | .nprogress-custom-parent #nprogress .bar { 65 | position: absolute; 66 | } 67 | 68 | @-webkit-keyframes nprogress-spinner { 69 | 0% { 70 | -webkit-transform: rotate(0deg); 71 | } 72 | 100% { 73 | -webkit-transform: rotate(360deg); 74 | } 75 | } 76 | @keyframes nprogress-spinner { 77 | 0% { 78 | transform: rotate(0deg); 79 | } 80 | 100% { 81 | transform: rotate(360deg); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/pages/[blog]/subscribe.tsx: -------------------------------------------------------------------------------- 1 | import { BlogInfo, BlogLayout } from '@/components/layouts/BlogLayout' 2 | import { blogService } from '@server/services/blog.service' 3 | import { GetServerSideProps } from 'next' 4 | 5 | type PageProps = { 6 | blog: BlogInfo 7 | } 8 | 9 | export const getServerSideProps: GetServerSideProps = async ( 10 | ctx, 11 | ) => { 12 | const blog = await blogService.getBlogBySlug(ctx.query.blog as string) 13 | if (!blog) { 14 | return { 15 | notFound: true, 16 | } 17 | } 18 | return { 19 | props: { 20 | blog, 21 | }, 22 | } 23 | } 24 | 25 | const Archives: React.FC = ({ blog }) => { 26 | return ( 27 | 28 |
29 |

Subscribe via RSS

30 |

31 | This blog delivers updates via{' '} 32 | 37 | RSS 38 | 39 | . To use it, you need a reader. 40 |

41 | 42 |
    43 |
  1. 44 | Register at one of the services or install an app. Good list of 45 | hosted RSS readers and apps can be found{' '} 46 | 51 | here 52 | 53 | . 54 |
  2. 55 |
  3. 56 | Add this url to the sources:{' '} 57 | 58 | {encodeURI(`https://blogify.dev/${blog.name}/atom.xml`)} 59 | 60 |
  4. 61 |
  5. Enjoy!
  6. 62 |
63 |
64 |
65 | ) 66 | } 67 | 68 | export default Archives 69 | -------------------------------------------------------------------------------- /src/pages/[blog]/index.tsx: -------------------------------------------------------------------------------- 1 | import { BlogInfo, BlogLayout } from '@/components/layouts/BlogLayout' 2 | import { PostList } from '@/components/PostList' 3 | import { getServerSession, UserSession } from '@server/auth' 4 | import { renderMarkdown } from '@server/markdown' 5 | import { blogService } from '@server/services/blog.service' 6 | import clsx from 'clsx' 7 | import { GetServerSideProps } from 'next' 8 | import React from 'react' 9 | 10 | type PageProps = { 11 | user: UserSession | null 12 | blog: BlogInfo 13 | introduction: string 14 | posts: { 15 | data: Array<{ 16 | title: string 17 | date: string 18 | slug: string 19 | cover: string | null 20 | coverAlt: string | null 21 | excerpt: string 22 | tags: Array<{ 23 | name: string 24 | slug: string 25 | }> 26 | }> 27 | hasOlder: boolean 28 | hasNewer: boolean 29 | } 30 | } 31 | 32 | export const getServerSideProps: GetServerSideProps = async ( 33 | ctx, 34 | ) => { 35 | const { user } = await getServerSession(ctx.req) 36 | const blog = await blogService.getBlogBySlug(ctx.query.blog as string) 37 | if (!blog) { 38 | return { notFound: true } 39 | } 40 | const page = parseInt((ctx.query.page as string) || '1') 41 | const posts = await blogService.getPosts(blog.id, { page }) 42 | const introduction = renderMarkdown(blog.introduction) 43 | return { 44 | props: { 45 | user, 46 | blog, 47 | introduction: introduction.html, 48 | posts, 49 | }, 50 | } 51 | } 52 | 53 | const Blog: React.FC = ({ blog, introduction, posts }) => { 54 | return ( 55 | 56 | {introduction && ( 57 |
61 | )} 62 | 63 |
64 | ) 65 | } 66 | 67 | export default Blog 68 | -------------------------------------------------------------------------------- /src/components/layouts/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import React from 'react' 3 | import Head from 'next/head' 4 | import clsx from 'clsx' 5 | import { useRouter } from 'next/router' 6 | 7 | export const AppLayout: React.FC<{ 8 | title?: string 9 | renderSidebar?: () => React.ReactElement 10 | mainTitle?: string | false 11 | }> = ({ children, title, renderSidebar, mainTitle }) => { 12 | const router = useRouter() 13 | const nav = [ 14 | { 15 | text: 'Logout', 16 | href: '/api/auth/logout', 17 | }, 18 | ] 19 | return ( 20 | <> 21 | 22 | {title} 23 | 24 |
25 |
26 |

27 | 28 | Blogify 29 | 30 |

31 |
32 | {nav.map((link) => { 33 | return ( 34 | 35 | 41 | {link.text} 42 | 43 | 44 | ) 45 | })} 46 |
47 |
48 |
49 | 50 | {renderSidebar && renderSidebar()} 51 | 52 |
53 | {title && mainTitle !== false && ( 54 |
55 |

56 | {mainTitle || title} 57 |

58 |
59 | )} 60 |
{children}
61 |
62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | import { GetServerSideProps } from 'next' 4 | import { getServerSession, UserSession } from '@server/auth' 5 | 6 | type PageProps = { 7 | user: UserSession | null 8 | } 9 | 10 | export const getServerSideProps: GetServerSideProps = async ( 11 | ctx, 12 | ) => { 13 | const { user } = await getServerSession(ctx.req) 14 | 15 | if (user) { 16 | return { 17 | redirect: { 18 | destination: `/dashboard`, 19 | permanent: false, 20 | }, 21 | } 22 | } 23 | 24 | return { 25 | props: { 26 | user, 27 | }, 28 | } 29 | } 30 | 31 | const Home: React.FC = ({ user }) => { 32 | return ( 33 | <> 34 | 35 | Blogify 36 | 37 |
38 |
39 |

Blogify

40 |

Your minimalistic blogging platform

41 | 55 |
56 |
57 | 58 | ) 59 | } 60 | 61 | export default Home 62 | -------------------------------------------------------------------------------- /src/components/layouts/BlogLayout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Link from 'next/link' 3 | import React from 'react' 4 | 5 | export type BlogInfo = { 6 | id: number 7 | slug: string 8 | name: string 9 | user: { 10 | id: number 11 | avatar?: string | null 12 | } 13 | } 14 | 15 | export const BlogLayout: React.FC<{ 16 | blog: BlogInfo 17 | title?: string 18 | }> = ({ blog, children, title }) => { 19 | const avatar = blog.user.avatar 20 | 21 | return ( 22 |
23 | 24 | {title} 25 | 31 | {avatar && } 32 | 33 |
34 |
35 | {avatar && ( 36 | 37 | 43 | 44 | )} 45 |
46 |

47 | 48 | {blog.name} 49 | 50 |

51 |
52 |
53 |
54 | 55 | 56 | Subscribe 57 | 58 | 59 |
60 |
61 |
{children}
62 | 69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/css/prism.css: -------------------------------------------------------------------------------- 1 | /** 2 | * prism.js Dark theme for JavaScript, CSS and HTML 3 | * Based on the slides of the talk “/Reg(exp){2}lained/” 4 | * @author Lea Verou 5 | */ 6 | 7 | code[class*='language-'], 8 | pre[class*='language-'] { 9 | color: white; 10 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 11 | font-size: 1em; 12 | text-align: left; 13 | white-space: pre; 14 | word-spacing: normal; 15 | word-break: normal; 16 | word-wrap: normal; 17 | line-height: 1.5; 18 | 19 | -moz-tab-size: 4; 20 | -o-tab-size: 4; 21 | tab-size: 4; 22 | 23 | -webkit-hyphens: none; 24 | -moz-hyphens: none; 25 | -ms-hyphens: none; 26 | hyphens: none; 27 | } 28 | 29 | /* Code blocks */ 30 | pre { 31 | padding: 1em; 32 | margin: 0.5em 0; 33 | overflow: auto; 34 | } 35 | 36 | /* Inline code */ 37 | :not(pre) > code[class*='language-'] { 38 | padding: 0.15em 0.2em 0.05em; 39 | border-radius: 0.3em; 40 | border: 0.13em solid hsl(30, 20%, 40%); 41 | box-shadow: 1px 1px 0.3em -0.1em black inset; 42 | white-space: normal; 43 | } 44 | 45 | .token.comment, 46 | .token.prolog, 47 | .token.doctype, 48 | .token.cdata { 49 | color: hsl(30, 20%, 50%); 50 | } 51 | 52 | .token.punctuation { 53 | opacity: 0.7; 54 | } 55 | 56 | .token.namespace { 57 | opacity: 0.7; 58 | } 59 | 60 | .token.property, 61 | .token.tag, 62 | .token.boolean, 63 | .token.number, 64 | .token.constant, 65 | .token.symbol { 66 | color: hsl(350, 40%, 70%); 67 | } 68 | 69 | .token.selector, 70 | .token.attr-name, 71 | .token.string, 72 | .token.char, 73 | .token.builtin, 74 | .token.inserted { 75 | color: hsl(75, 70%, 60%); 76 | } 77 | 78 | .token.operator, 79 | .token.entity, 80 | .token.url, 81 | .language-css .token.string, 82 | .style .token.string, 83 | .token.variable { 84 | color: hsl(40, 90%, 60%); 85 | } 86 | 87 | .token.atrule, 88 | .token.attr-value, 89 | .token.keyword { 90 | color: hsl(350, 40%, 70%); 91 | } 92 | 93 | .token.regex, 94 | .token.important { 95 | color: #e90; 96 | } 97 | 98 | .token.important, 99 | .token.bold { 100 | font-weight: bold; 101 | } 102 | .token.italic { 103 | font-style: italic; 104 | } 105 | 106 | .token.entity { 107 | cursor: help; 108 | } 109 | 110 | .token.deleted { 111 | color: red; 112 | } 113 | -------------------------------------------------------------------------------- /src/pages/blogs.tsx: -------------------------------------------------------------------------------- 1 | import { AppLayout } from '@/components/layouts/AppLayout' 2 | import { getServerSession, UserSession } from '@server/auth' 3 | import { blogService } from '@server/services/blog.service' 4 | import { GetServerSideProps } from 'next' 5 | import Link from 'next/link' 6 | 7 | type PageProps = { 8 | user: UserSession 9 | blogs: Array<{ 10 | slug: string 11 | name: string 12 | createdAt: string 13 | }> 14 | } 15 | 16 | export const getServerSideProps: GetServerSideProps = async ( 17 | ctx, 18 | ) => { 19 | const { user } = await getServerSession(ctx.req) 20 | if (!user) { 21 | return { 22 | redirect: { 23 | destination: '/login', 24 | permanent: false, 25 | }, 26 | } 27 | } 28 | const blogs = await blogService.getBlogsByUser(user.id) 29 | if (blogs.length === 0) { 30 | return { 31 | redirect: { 32 | destination: '/new-blog', 33 | permanent: false, 34 | }, 35 | } 36 | } 37 | return { 38 | props: { 39 | user, 40 | blogs: blogs.map((blog) => ({ 41 | slug: blog.slug, 42 | name: blog.name, 43 | createdAt: blog.createdAt.toISOString(), 44 | })), 45 | }, 46 | } 47 | } 48 | 49 | const Blogs: React.FC = ({ user, blogs }) => { 50 | return ( 51 | 52 |
53 | 54 | Create A New Blog → 55 | 56 |
57 |
58 | {blogs.map((blog) => { 59 | return ( 60 |
64 |

65 | 66 | {blog.name} 67 | 68 |

69 |
70 | 71 | View 72 | 73 | 74 | New Post 75 | 76 | 77 | Settings 78 | 79 |
80 |
81 | ) 82 | })} 83 |
84 |
85 | ) 86 | } 87 | 88 | export default Blogs 89 | -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #222; 3 | --bg-darker: rgb(29, 29, 29); 4 | --button-border-color: #444; 5 | --button-border-hover-color: rgb(104, 104, 104); 6 | 7 | --link-color: #8cc2dd; 8 | --link-visited-color: #8b6fcb; 9 | 10 | --input-bg: #272822; 11 | 12 | --accent-color: theme('colors.gray.50'); 13 | 14 | --code-block-bg: #191c20; 15 | 16 | --border-color: #444; 17 | } 18 | 19 | *, 20 | *:before, 21 | *:after { 22 | border-color: var(--border-color); 23 | } 24 | 25 | body { 26 | background-color: var(--bg); 27 | color: #aaa; 28 | font-family: Inter, Helvetica, sans-serif; 29 | } 30 | 31 | input { 32 | border-radius: 0; 33 | } 34 | 35 | .container { 36 | @apply max-w-3xl mx-auto px-5; 37 | } 38 | 39 | .link { 40 | color: var(--link-color); 41 | } 42 | 43 | .link.hint-visited:visited { 44 | color: var(--link-visited-color); 45 | } 46 | 47 | .button { 48 | background-color: var(--input-bg); 49 | border-color: var(--button-border-color); 50 | @apply border px-2 text-sm h-7 inline-flex items-center rounded-lg; 51 | @apply focus:outline-none; 52 | @apply focus:ring-2; 53 | } 54 | 55 | .button:hover { 56 | border-color: var(--button-border-hover-color); 57 | } 58 | 59 | .button.is-large { 60 | @apply px-3; 61 | @apply h-9; 62 | @apply text-base; 63 | } 64 | 65 | .input, 66 | .input-addon { 67 | background-color: var(--input-bg); 68 | border-color: var(--border-color); 69 | @apply rounded-lg; 70 | @apply text-base; 71 | @apply border px-3 text-sm h-10; 72 | } 73 | 74 | .input-addon { 75 | @apply rounded-tr-none; 76 | @apply rounded-br-none; 77 | } 78 | 79 | .input.with-addon { 80 | @apply rounded-tl-none; 81 | @apply rounded-bl-none; 82 | } 83 | 84 | .input:focus { 85 | @apply outline-none; 86 | } 87 | 88 | .input:focus { 89 | border-color: var(--button-border-hover-color); 90 | } 91 | 92 | .input-addon { 93 | border-right: none; 94 | @apply inline-flex; 95 | @apply items-center; 96 | @apply justify-center; 97 | } 98 | 99 | /** Button variants */ 100 | .button.is-primary { 101 | color: white; 102 | @apply bg-blue-500; 103 | @apply border-blue-500; 104 | } 105 | 106 | .textarea { 107 | @apply h-auto; 108 | @apply resize-none; 109 | @apply py-3; 110 | @apply px-3; 111 | @apply rounded-lg; 112 | } 113 | 114 | .main { 115 | @apply my-10; 116 | } 117 | 118 | .label { 119 | @apply flex; 120 | @apply mb-2; 121 | @apply text-sm; 122 | } 123 | 124 | .form-error { 125 | @apply text-red-500; 126 | @apply mt-2; 127 | @apply text-xs; 128 | } 129 | 130 | .page-title { 131 | @apply text-3xl; 132 | @apply pb-4; 133 | @apply mb-8; 134 | @apply text-gray-100; 135 | } 136 | -------------------------------------------------------------------------------- /src/css/codemirror-monokai.css: -------------------------------------------------------------------------------- 1 | /* Based on Sublime Text's Monokai theme */ 2 | 3 | .cm-s-monokai.CodeMirror { 4 | background: var(--input-bg); 5 | color: #f8f8f2; 6 | } 7 | .cm-s-monokai div.CodeMirror-selected { 8 | background: #49483e; 9 | } 10 | .cm-s-monokai .CodeMirror-line::selection, 11 | .cm-s-monokai .CodeMirror-line > span::selection, 12 | .cm-s-monokai .CodeMirror-line > span > span::selection { 13 | background: rgba(73, 72, 62, 0.99); 14 | } 15 | .cm-s-monokai .CodeMirror-line::-moz-selection, 16 | .cm-s-monokai .CodeMirror-line > span::-moz-selection, 17 | .cm-s-monokai .CodeMirror-line > span > span::-moz-selection { 18 | background: rgba(73, 72, 62, 0.99); 19 | } 20 | .cm-s-monokai .CodeMirror-gutters { 21 | background: #272822; 22 | border-right: 0px; 23 | } 24 | .cm-s-monokai .CodeMirror-guttermarker { 25 | color: white; 26 | } 27 | .cm-s-monokai .CodeMirror-guttermarker-subtle { 28 | color: #d0d0d0; 29 | } 30 | .cm-s-monokai .CodeMirror-linenumber { 31 | color: #d0d0d0; 32 | } 33 | .cm-s-monokai .CodeMirror-cursor { 34 | border-left: 1px solid #f8f8f0; 35 | } 36 | 37 | .cm-s-monokai span.cm-comment { 38 | color: #75715e; 39 | } 40 | .cm-s-monokai span.cm-atom { 41 | color: #ae81ff; 42 | } 43 | .cm-s-monokai span.cm-number { 44 | color: #ae81ff; 45 | } 46 | 47 | .cm-s-monokai span.cm-comment.cm-attribute { 48 | color: #97b757; 49 | } 50 | .cm-s-monokai span.cm-comment.cm-def { 51 | color: #bc9262; 52 | } 53 | .cm-s-monokai span.cm-comment.cm-tag { 54 | color: #bc6283; 55 | } 56 | .cm-s-monokai span.cm-comment.cm-type { 57 | color: #5998a6; 58 | } 59 | 60 | .cm-s-monokai span.cm-property, 61 | .cm-s-monokai span.cm-attribute { 62 | color: #a6e22e; 63 | } 64 | .cm-s-monokai span.cm-keyword { 65 | color: #f92672; 66 | } 67 | .cm-s-monokai span.cm-builtin { 68 | color: #66d9ef; 69 | } 70 | .cm-s-monokai span.cm-string { 71 | color: #e6db74; 72 | } 73 | 74 | .cm-s-monokai span.cm-variable { 75 | color: #f8f8f2; 76 | } 77 | .cm-s-monokai span.cm-variable-2 { 78 | color: #9effff; 79 | } 80 | .cm-s-monokai span.cm-variable-3, 81 | .cm-s-monokai span.cm-type { 82 | color: #66d9ef; 83 | } 84 | .cm-s-monokai span.cm-def { 85 | color: #fd971f; 86 | } 87 | .cm-s-monokai span.cm-bracket { 88 | color: #f8f8f2; 89 | } 90 | .cm-s-monokai span.cm-tag { 91 | color: #f92672; 92 | } 93 | .cm-s-monokai span.cm-header { 94 | color: #ae81ff; 95 | } 96 | .cm-s-monokai span.cm-link { 97 | color: #ae81ff; 98 | } 99 | .cm-s-monokai span.cm-error { 100 | background: #f92672; 101 | color: #f8f8f0; 102 | } 103 | 104 | .cm-s-monokai .CodeMirror-activeline-background { 105 | background: #373831; 106 | } 107 | .cm-s-monokai .CodeMirror-matchingbracket { 108 | text-decoration: underline; 109 | color: white !important; 110 | } 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next", 5 | "build": "next build", 6 | "start": "next start --port $PORT", 7 | "with-env": "node scripts/with-env", 8 | "db-push": "prisma db push --preview-feature", 9 | "db-generate": "prisma generate", 10 | "migrate-deploy": "prisma migrate deploy", 11 | "gql-generate": "graphql-codegen --config graphql-codegen.yml" 12 | }, 13 | "husky": { 14 | "hooks": { 15 | "pre-commit": "lint-staged" 16 | } 17 | }, 18 | "lint-staged": { 19 | "*.{ts,tsx,json,md,css}": [ 20 | "prettier --write" 21 | ] 22 | }, 23 | "dependencies": { 24 | "@hapi/iron": "^6.0.0", 25 | "@prisma/client": "^2.21.2", 26 | "@snackbar/core": "^1.7.0", 27 | "@urql/core": "^1.16.0", 28 | "apollo-server-micro": "^2.19.0", 29 | "axios": "^0.21.0", 30 | "class-validator": "^0.12.2", 31 | "clsx": "^1.1.1", 32 | "codemirror": "^5.58.3", 33 | "cookie": "^0.4.1", 34 | "dayjs": "^1.9.6", 35 | "feed": "^4.2.2", 36 | "formik": "^2.2.5", 37 | "graphql": "^15.4.0", 38 | "limax": "^2.1.0", 39 | "markdown-it": "^12.0.2", 40 | "nanoid": "^3.1.20", 41 | "next": "^10.2.0", 42 | "next-connect": "^0.10.1", 43 | "nprogress": "^0.2.0", 44 | "passport": "^0.4.1", 45 | "passport-github": "^1.1.0", 46 | "passport-google-oauth20": "^2.0.0", 47 | "prismjs": "^1.22.0", 48 | "react": "^17.0.1", 49 | "react-dom": "^17.0.1", 50 | "reflect-metadata": "^0.1.13", 51 | "remove-markdown": "^0.3.0", 52 | "superjson": "^1.7.4", 53 | "type-graphql": "^1.1.1", 54 | "urql": "^1.11.4", 55 | "yup": "^0.32.1" 56 | }, 57 | "devDependencies": { 58 | "@egoist/prettier-config": "^0.1.0", 59 | "@graphql-codegen/cli": "1.19.4", 60 | "@graphql-codegen/introspection": "1.18.1", 61 | "@graphql-codegen/typescript": "1.19.0", 62 | "@graphql-codegen/typescript-operations": "1.17.12", 63 | "@graphql-codegen/typescript-urql": "^2.0.3", 64 | "@types/codemirror": "^0.0.102", 65 | "@types/cookie": "^0.4.0", 66 | "@types/markdown-it": "^10.0.3", 67 | "@types/node": "^14.14.10", 68 | "@types/nprogress": "^0.2.0", 69 | "@types/passport": "^1.0.4", 70 | "@types/passport-github": "^1.1.5", 71 | "@types/passport-google-oauth20": "^2.0.4", 72 | "@types/prismjs": "^1.16.2", 73 | "@types/react": "^17.0.0", 74 | "@types/react-dom": "^17.0.0", 75 | "@types/remove-markdown": "^0.3.0", 76 | "autoprefixer": "^10.2.5", 77 | "babel-plugin-superjson-next": "^0.2.3", 78 | "cross-env": "^7.0.3", 79 | "dotenv": "^8.2.0", 80 | "dotenv-expand": "^5.1.0", 81 | "husky": "^4.3.0", 82 | "lint-staged": "^10.5.3", 83 | "postcss": "^8.1.10", 84 | "preact": "^10.5.7", 85 | "prettier": "^2.2.1", 86 | "prisma": "^2.21.2", 87 | "tailwindcss": "^2.1.2", 88 | "ts-loader": "^8.0.10", 89 | "typescript": "^4.1.2" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/PostList.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { useRouter } from 'next/router' 3 | import React from 'react' 4 | 5 | export const PostList: React.FC<{ 6 | posts: { 7 | data: Array<{ 8 | title: string 9 | slug: string 10 | date: string 11 | excerpt: string 12 | cover?: string | null 13 | coverAlt?: string | null 14 | tags: Array<{ 15 | name: string 16 | slug: string 17 | }> 18 | }> 19 | hasOlder: boolean 20 | hasNewer: boolean 21 | } 22 | blogSlug: string 23 | }> = ({ posts, blogSlug }) => { 24 | const router = useRouter() 25 | const page = parseInt((router.query.page as string) || '1') 26 | return ( 27 |
28 |
29 | {posts.data.map((post) => { 30 | const postLink = `/${blogSlug}/${post.slug}` 31 | return ( 32 |
36 |
37 |

38 | 39 | 40 | {post.title} 41 | 42 | 43 |

44 |
{post.date}
45 | {post.excerpt && ( 46 |
47 | {post.excerpt.length > 200 48 | ? `${post.excerpt.slice(0, 200)}...` 49 | : post.excerpt} 50 |
51 | )} 52 |
53 |
54 | {post.cover && ( 55 | 56 | 57 | {post.coverAlt 62 | 63 | 64 | )} 65 |
66 |
67 | ) 68 | })} 69 |
70 |
71 | {posts.hasOlder && ( 72 | 81 | « See Older 82 | 83 | )} 84 | {posts.hasNewer && ( 85 | 94 | See Newer » 95 | 96 | )} 97 |
98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /server/services/blog.service.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@server/prisma' 2 | import dayjs from 'dayjs' 3 | 4 | export const blogService = { 5 | async getBlogsByUser(userId: number) { 6 | const blogs = await prisma.blog.findMany({ 7 | where: { 8 | userId, 9 | }, 10 | orderBy: { 11 | createdAt: 'desc', 12 | }, 13 | }) 14 | return blogs 15 | }, 16 | 17 | async hasBlog(userId: number) { 18 | const blog = await prisma.blog.findFirst({ 19 | where: { 20 | userId, 21 | }, 22 | }) 23 | return Boolean(blog) 24 | }, 25 | 26 | async getBlogBySlug(slug: string) { 27 | const blog = await prisma.blog.findUnique({ 28 | where: { 29 | slug, 30 | }, 31 | select: { 32 | id: true, 33 | name: true, 34 | slug: true, 35 | introduction: true, 36 | user: { 37 | select: { 38 | id: true, 39 | avatar: true, 40 | }, 41 | }, 42 | }, 43 | }) 44 | 45 | return blog 46 | }, 47 | 48 | async getPosts( 49 | blogId: number, 50 | { tag, limit, page }: { tag?: string; limit?: number; page?: number } = {}, 51 | ) { 52 | limit = limit || 20 53 | page = page || 1 54 | const posts = await prisma.post.findMany({ 55 | where: { 56 | blogId, 57 | deletedAt: null, 58 | tags: tag 59 | ? { 60 | some: { 61 | slug: tag, 62 | }, 63 | } 64 | : undefined, 65 | }, 66 | take: limit + 1, 67 | skip: (page - 1) * limit, 68 | orderBy: { 69 | createdAt: 'desc', 70 | }, 71 | include: { 72 | tags: { 73 | select: { 74 | name: true, 75 | slug: true, 76 | }, 77 | }, 78 | }, 79 | }) 80 | 81 | return { 82 | data: posts.slice(0, limit).map((post) => { 83 | return { 84 | title: post.title, 85 | excerpt: post.excerpt, 86 | date: dayjs(post.createdAt).format('MMM DD, YYYY'), 87 | slug: post.slug, 88 | cover: post.cover, 89 | coverAlt: post.coverAlt, 90 | tags: post.tags, 91 | } 92 | }), 93 | hasOlder: posts.length > limit, 94 | hasNewer: page > 1, 95 | } 96 | }, 97 | 98 | async getAllPosts(blogId: number, tagSlug?: string) { 99 | const posts = await prisma.post.findMany({ 100 | where: { 101 | blogId, 102 | deletedAt: null, 103 | tags: tagSlug 104 | ? { 105 | some: { 106 | slug: tagSlug, 107 | }, 108 | } 109 | : undefined, 110 | }, 111 | orderBy: { 112 | createdAt: 'desc', 113 | }, 114 | }) 115 | 116 | return posts.slice(0, 10).map((post) => { 117 | return { 118 | title: post.title, 119 | date: dayjs(post.createdAt).format('MMM DD, YYYY'), 120 | slug: post.slug, 121 | } 122 | }) 123 | }, 124 | 125 | async getPostBySlug(blogId: number, slug: string) { 126 | const post = await prisma.post.findFirst({ 127 | where: { 128 | blogId, 129 | slug, 130 | deletedAt: null, 131 | }, 132 | include: { 133 | tags: true, 134 | blog: { 135 | select: { 136 | userId: true, 137 | }, 138 | }, 139 | }, 140 | }) 141 | return post 142 | }, 143 | } 144 | -------------------------------------------------------------------------------- /src/pages/dashboard/[blog]/index.tsx: -------------------------------------------------------------------------------- 1 | import { BlogSidebar } from '@/components/dashboard/BlogSidebar' 2 | import { AppLayout } from '@/components/layouts/AppLayout' 3 | import { 4 | useDeletePostMutation, 5 | useGetBlogForDashboardQuery, 6 | useGetPostsForDashboardQuery, 7 | } from '@/generated/graphql' 8 | import { GetServerSideProps } from 'next' 9 | import Link from 'next/link' 10 | import { useRouter } from 'next/router' 11 | import React from 'react' 12 | 13 | // export const getServerSideProps: GetServerSideProps = (ctx) => { 14 | // return {props:{}} 15 | // } 16 | 17 | export default function BlogDashboard() { 18 | const router = useRouter() 19 | const blogSlug = router.query.blog as string 20 | const page = parseInt((router.query.page as string | undefined) || '1', 10) 21 | const [postsResult, refetchPostsResult] = useGetPostsForDashboardQuery({ 22 | variables: { 23 | blogSlug, 24 | page, 25 | }, 26 | requestPolicy: 'cache-and-network', 27 | }) 28 | 29 | const posts = postsResult.data?.posts 30 | const isEmpty = posts?.data.length === 0 && page === 1 31 | 32 | const [blogResult] = useGetBlogForDashboardQuery({ 33 | variables: { 34 | slug: blogSlug, 35 | }, 36 | requestPolicy: 'cache-and-network', 37 | }) 38 | 39 | const blogName = blogResult?.data?.blog.name 40 | 41 | const [, deletePostMutation] = useDeletePostMutation() 42 | 43 | const deletePost = async (id: number) => { 44 | if (!confirm('Are you sure you want to delete it?')) return 45 | 46 | await deletePostMutation({ id }) 47 | refetchPostsResult() 48 | } 49 | 50 | return ( 51 | } 55 | > 56 |
57 | {posts && !isEmpty && ( 58 |
59 | {posts.total} Post{posts.total > 1 ? 's' : ''} 60 |
61 | )} 62 |
63 | 64 | 65 | New Post 66 | 67 | 68 |
69 |
70 | {isEmpty &&
You have have any posts yet.
} 71 | {posts && ( 72 |
73 | {posts.data.map((post) => { 74 | return ( 75 |
76 |
77 |

78 | 79 | 80 | {post.title} 81 | 82 | 83 |

84 | {post.date} 85 |
86 |
87 | 88 | Preview 89 | 90 | 96 |
97 |
98 | ) 99 | })} 100 |
101 | )} 102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DB_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | createdAt DateTime @default(now()) @map("created_at") 16 | updatedAt DateTime @updatedAt @map("updated_at") 17 | name String 18 | email String @unique 19 | googleUserId String? @unique @map("google_user_id") 20 | githubUserId String? @unique @map("github_user_id") 21 | avatar String? 22 | blogs Blog[] @relation("user_blogs") 23 | likes Like[] @relation("user_likes") 24 | sessions Session[] 25 | lastActiveBlog Blog? @relation(name: "user_last_active_blog", fields: [lastActiveBlogId], references: [id]) 26 | lastActiveBlogId Int? 27 | 28 | @@map("users") 29 | } 30 | 31 | model Session { 32 | id Int @id @default(autoincrement()) 33 | createdAt DateTime @default(now()) @map("created_at") 34 | updatedAt DateTime @updatedAt @map("updated_at") 35 | token String @unique 36 | expireIn DateTime? 37 | user User @relation(fields: [userId], references: [id]) 38 | userId Int @map("user_id") 39 | 40 | @@map("sessions") 41 | } 42 | 43 | model Blog { 44 | id Int @id @default(autoincrement()) 45 | createdAt DateTime @default(now()) @map("created_at") 46 | updatedAt DateTime @updatedAt @map("updated_at") 47 | user User @relation("user_blogs", fields: [userId], references: [id]) 48 | userId Int @map("user_id") 49 | name String 50 | slug String @unique 51 | introduction String 52 | posts Post[] @relation("blog_posts") 53 | tags Tag[] @relation("blog_tags") 54 | lastActiveForUsers User[] @relation("user_last_active_blog") 55 | 56 | @@map("blogs") 57 | } 58 | 59 | model Post { 60 | id Int @id @default(autoincrement()) 61 | createdAt DateTime @default(now()) @map("created_at") 62 | updatedAt DateTime @updatedAt @map("updated_at") 63 | title String 64 | content String 65 | excerpt String 66 | slug String 67 | draft Boolean? 68 | blog Blog @relation("blog_posts", fields: [blogId], references: [id]) 69 | blogId Int @map("blog_id") 70 | likes Like[] @relation("post_likes") 71 | likesCount Int @default(0) @map("likes_count") 72 | cover String? 73 | coverAlt String? @map("cover_alt") 74 | tags Tag[] @relation("post_tags") 75 | deletedAt DateTime? 76 | 77 | @@map("posts") 78 | } 79 | 80 | model Like { 81 | id Int @id @default(autoincrement()) 82 | createdAt DateTime @default(now()) @map("created_at") 83 | post Post @relation("post_likes", fields: [postId], references: [id]) 84 | user User @relation("user_likes", fields: [userId], references: [id]) 85 | postId Int @map("post_id") 86 | userId Int @map("user_id") 87 | 88 | @@map("likes") 89 | } 90 | 91 | model Tag { 92 | id Int @id @default(autoincrement()) 93 | createdAt DateTime @default(now()) @map("created_at") 94 | name String 95 | slug String 96 | blog Blog @relation("blog_tags", fields: [blogId], references: [id]) 97 | blogId Int @map("blog_id") 98 | posts Post[] @relation("post_tags") 99 | 100 | @@map("tags") 101 | } 102 | -------------------------------------------------------------------------------- /src/pages/[blog]/[post].tsx: -------------------------------------------------------------------------------- 1 | import { BlogInfo, BlogLayout } from '@/components/layouts/BlogLayout' 2 | import { getServerSession, UserSession } from '@server/auth' 3 | import { blogService } from '@server/services/blog.service' 4 | import { GetServerSideProps } from 'next' 5 | import dayjs from 'dayjs' 6 | import { renderMarkdown } from '@server/markdown' 7 | import React from 'react' 8 | import Link from 'next/link' 9 | import { LikeButton } from '@/components/LikeButton' 10 | import { postService } from '@server/services/post.service' 11 | import Head from 'next/head' 12 | 13 | type PageProps = { 14 | user: UserSession | null 15 | blog: BlogInfo 16 | isLiked: boolean | null 17 | post: { 18 | id: number 19 | title: string 20 | content: string 21 | createdAt: Date 22 | excerpt: string 23 | slug: string 24 | likesCount: number 25 | cover: string | null 26 | coverAlt: string | null 27 | tags: Array<{ 28 | id: number 29 | name: string 30 | slug: string 31 | }> 32 | } 33 | } 34 | 35 | export const getServerSideProps: GetServerSideProps = async ( 36 | ctx, 37 | ) => { 38 | const { user } = await getServerSession(ctx.req) 39 | 40 | const blog = await blogService.getBlogBySlug(ctx.query.blog as string) 41 | 42 | const post = 43 | blog && (await blogService.getPostBySlug(blog.id, ctx.query.post as string)) 44 | 45 | if (!blog || !post) { 46 | return { notFound: true } 47 | } 48 | 49 | const isLiked = 50 | (user && (await postService.isLikedBy(post.id, user.id))) || null 51 | const { html } = renderMarkdown(post.content) 52 | return { 53 | props: { 54 | user, 55 | blog, 56 | isLiked, 57 | post: { 58 | ...post, 59 | content: html, 60 | }, 61 | }, 62 | } 63 | } 64 | 65 | const Post: React.FC = ({ user, blog, post, isLiked }) => { 66 | return ( 67 | <> 68 | 69 | 70 | 71 | {post.cover && } 72 | 73 | 74 | 75 | 76 |
77 |

{post.title}

78 |
79 | 80 | {dayjs(post.createdAt).format('MMM DD, YYYY')} 81 | 82 |
83 |
84 |
85 | {post.cover && ( 86 | cover image 87 | )} 88 |
92 |
93 |
94 |
95 | {post.tags.map((tag, index: number) => { 96 | return ( 97 | 98 | 99 | #{tag.name} 100 | 101 | {index !== post.tags.length - 1 && ','} 102 | 103 | ) 104 | })} 105 |
106 |
107 | 108 |
109 | 115 |
116 |
117 | 118 | ) 119 | } 120 | 121 | export default Post 122 | -------------------------------------------------------------------------------- /prisma/migrations/20210427141947_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "users" ( 3 | "id" SERIAL NOT NULL, 4 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updated_at" TIMESTAMP(3) NOT NULL, 6 | "name" TEXT NOT NULL, 7 | "email" TEXT NOT NULL, 8 | "google_user_id" TEXT, 9 | "github_user_id" TEXT, 10 | "avatar" TEXT, 11 | 12 | PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "sessions" ( 17 | "id" SERIAL NOT NULL, 18 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | "updated_at" TIMESTAMP(3) NOT NULL, 20 | "token" TEXT NOT NULL, 21 | "expireIn" TIMESTAMP(3), 22 | "user_id" INTEGER NOT NULL, 23 | 24 | PRIMARY KEY ("id") 25 | ); 26 | 27 | -- CreateTable 28 | CREATE TABLE "blogs" ( 29 | "id" SERIAL NOT NULL, 30 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | "updated_at" TIMESTAMP(3) NOT NULL, 32 | "user_id" INTEGER NOT NULL, 33 | "name" TEXT NOT NULL, 34 | "slug" TEXT NOT NULL, 35 | "introduction" TEXT NOT NULL, 36 | 37 | PRIMARY KEY ("id") 38 | ); 39 | 40 | -- CreateTable 41 | CREATE TABLE "posts" ( 42 | "id" SERIAL NOT NULL, 43 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 44 | "updated_at" TIMESTAMP(3) NOT NULL, 45 | "title" TEXT NOT NULL, 46 | "content" TEXT NOT NULL, 47 | "excerpt" TEXT NOT NULL, 48 | "slug" TEXT NOT NULL, 49 | "draft" BOOLEAN, 50 | "blog_id" INTEGER NOT NULL, 51 | "likes_count" INTEGER NOT NULL DEFAULT 0, 52 | "cover" TEXT, 53 | "cover_alt" TEXT, 54 | 55 | PRIMARY KEY ("id") 56 | ); 57 | 58 | -- CreateTable 59 | CREATE TABLE "likes" ( 60 | "id" SERIAL NOT NULL, 61 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 62 | "post_id" INTEGER NOT NULL, 63 | "user_id" INTEGER NOT NULL, 64 | 65 | PRIMARY KEY ("id") 66 | ); 67 | 68 | -- CreateTable 69 | CREATE TABLE "tags" ( 70 | "id" SERIAL NOT NULL, 71 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 72 | "name" TEXT NOT NULL, 73 | "slug" TEXT NOT NULL, 74 | "blog_id" INTEGER NOT NULL, 75 | 76 | PRIMARY KEY ("id") 77 | ); 78 | 79 | -- CreateTable 80 | CREATE TABLE "_post_tags" ( 81 | "A" INTEGER NOT NULL, 82 | "B" INTEGER NOT NULL 83 | ); 84 | 85 | -- CreateIndex 86 | CREATE UNIQUE INDEX "users.email_unique" ON "users"("email"); 87 | 88 | -- CreateIndex 89 | CREATE UNIQUE INDEX "users.google_user_id_unique" ON "users"("google_user_id"); 90 | 91 | -- CreateIndex 92 | CREATE UNIQUE INDEX "users.github_user_id_unique" ON "users"("github_user_id"); 93 | 94 | -- CreateIndex 95 | CREATE UNIQUE INDEX "sessions.token_unique" ON "sessions"("token"); 96 | 97 | -- CreateIndex 98 | CREATE UNIQUE INDEX "blogs.slug_unique" ON "blogs"("slug"); 99 | 100 | -- CreateIndex 101 | CREATE UNIQUE INDEX "_post_tags_AB_unique" ON "_post_tags"("A", "B"); 102 | 103 | -- CreateIndex 104 | CREATE INDEX "_post_tags_B_index" ON "_post_tags"("B"); 105 | 106 | -- AddForeignKey 107 | ALTER TABLE "sessions" ADD FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 108 | 109 | -- AddForeignKey 110 | ALTER TABLE "blogs" ADD FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 111 | 112 | -- AddForeignKey 113 | ALTER TABLE "posts" ADD FOREIGN KEY ("blog_id") REFERENCES "blogs"("id") ON DELETE CASCADE ON UPDATE CASCADE; 114 | 115 | -- AddForeignKey 116 | ALTER TABLE "likes" ADD FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; 117 | 118 | -- AddForeignKey 119 | ALTER TABLE "likes" ADD FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 120 | 121 | -- AddForeignKey 122 | ALTER TABLE "tags" ADD FOREIGN KEY ("blog_id") REFERENCES "blogs"("id") ON DELETE CASCADE ON UPDATE CASCADE; 123 | 124 | -- AddForeignKey 125 | ALTER TABLE "_post_tags" ADD FOREIGN KEY ("A") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE "_post_tags" ADD FOREIGN KEY ("B") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE; 129 | -------------------------------------------------------------------------------- /src/pages/new-blog.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/Button' 2 | import { AppLayout } from '@/components/layouts/AppLayout' 3 | import { getServerSession, UserSession } from '@server/auth' 4 | import { blogService } from '@server/services/blog.service' 5 | import { useFormik } from 'formik' 6 | import { GetServerSideProps } from 'next' 7 | import React from 'react' 8 | import { useRouter } from 'next/router' 9 | import { useCreateBlogMutation } from '@/generated/graphql' 10 | import { createSnackbar } from '@snackbar/core' 11 | import * as Yup from 'yup' 12 | 13 | type PageProps = { 14 | hasBlog: boolean 15 | user: UserSession 16 | } 17 | 18 | export const getServerSideProps: GetServerSideProps = async ( 19 | ctx, 20 | ) => { 21 | const { user } = await getServerSession(ctx.req) 22 | if (!user) { 23 | return { 24 | redirect: { 25 | destination: '/login', 26 | permanent: false, 27 | }, 28 | } 29 | } 30 | const hasBlog = await blogService.hasBlog(user.id) 31 | return { 32 | props: { 33 | user, 34 | hasBlog, 35 | }, 36 | } 37 | } 38 | 39 | const NewBlog: React.FC = ({ hasBlog, user }) => { 40 | const router = useRouter() 41 | const [, createBlogMutation] = useCreateBlogMutation() 42 | const form = useFormik({ 43 | initialValues: { 44 | name: '', 45 | slug: '', 46 | }, 47 | validationSchema: Yup.object().shape({ 48 | name: Yup.string().min(2).max(20).required(), 49 | slug: Yup.string() 50 | .min(2) 51 | .max(20) 52 | .matches(/^[a-zA-Z0-9_-]+$/, { 53 | message: `Only alphabet, numbers, dash and underscore are allowed`, 54 | }) 55 | .required(), 56 | }), 57 | async onSubmit(values) { 58 | const { data, error } = await createBlogMutation({ 59 | ...values, 60 | }) 61 | if (data) { 62 | router.push(`/dashboard/${data.createBlog.slug}`) 63 | } else if (error) { 64 | const field = 65 | error.graphQLErrors && 66 | error.graphQLErrors[0] && 67 | error.graphQLErrors[0].extensions && 68 | error.graphQLErrors[0].extensions['field'] 69 | const message = error.message.replace('[GraphQL] ', '') 70 | if (field) { 71 | form.setFieldError(field, message) 72 | } else { 73 | createSnackbar(message, { 74 | timeout: 3000, 75 | theme: { 76 | actionColor: 'red', 77 | }, 78 | }) 79 | } 80 | } 81 | }, 82 | }) 83 | 84 | return ( 85 | 86 | {!hasBlog && ( 87 |

88 | 👋≧◉ᴥ◉≦ Create Your First Blog! 89 |

90 | )} 91 |
92 |
93 | 94 | 101 |
102 | {form.errors.name && form.touched.name && ( 103 | {form.errors.name} 104 | )} 105 | 106 |
107 | 108 |
109 | blogify.dev/ 110 | 117 |
118 |
119 | {form.errors.slug && ( 120 | {form.errors.slug} 121 | )} 122 | 123 |
124 | 127 |
128 |
129 |
130 | ) 131 | } 132 | 133 | export default NewBlog 134 | -------------------------------------------------------------------------------- /server/passport.ts: -------------------------------------------------------------------------------- 1 | import passport, { Profile } from 'passport' 2 | import { Strategy as GitHubStrategy } from 'passport-github' 3 | import { AUTH_COOKIE_NAME } from './constants' 4 | import { IncomingMessage, ServerResponse } from 'http' 5 | import { serialize } from 'cookie' 6 | import { redirect } from './response' 7 | import { prisma } from './prisma' 8 | import { nanoid } from 'nanoid' 9 | 10 | passport.serializeUser((user, done) => { 11 | done(null, user.id) 12 | }) 13 | 14 | passport.deserializeUser((id, done) => { 15 | prisma.user 16 | .findUnique({ 17 | where: { 18 | id, 19 | }, 20 | }) 21 | .then((user) => { 22 | done(null, user) 23 | }) 24 | .catch((error) => { 25 | console.log(`Error: ${error}`) 26 | }) 27 | }) 28 | 29 | // passport.use( 30 | // new GoogleStrategy( 31 | // { 32 | // clientID: process.env.GOOGLE_CLIENT_ID!, 33 | // clientSecret: process.env.GOOGLE_CLIENT_SECRET, 34 | // callbackURL: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google/callback`, 35 | // }, 36 | // async (accessToken, refreshToken, profile, cb) => { 37 | // const user = await getUserByProviderProfile(profile, 'google') 38 | // cb(null, user) 39 | // }, 40 | // ), 41 | // ) 42 | 43 | passport.use( 44 | new GitHubStrategy( 45 | { 46 | clientID: process.env.GITHUB_CLIENT_ID!, 47 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 48 | callbackURL: `${ 49 | process.env.NODE_ENV === 'production' ? 'https://blogify.dev' : '' 50 | }/api/auth/github/callback`, 51 | }, 52 | async (accessToken, refreshToken, profile, cb) => { 53 | try { 54 | const user = await getUserByProviderProfile(profile, 'github') 55 | cb(null, user) 56 | } catch (error) { 57 | cb(error) 58 | } 59 | }, 60 | ), 61 | ) 62 | 63 | async function getUserByProviderProfile( 64 | profile: Profile, 65 | provider: 'github' | 'google', 66 | ) { 67 | let email = profile.emails && profile.emails[0].value 68 | const avatar = profile.photos && profile.photos[0].value 69 | 70 | if (!email) { 71 | // Some users might not have a public email 72 | email = `${provider}_${profile.id}@mail-holder.blogify.dev` 73 | } 74 | 75 | const providerKey = `${provider}UserId` 76 | 77 | // Find one by provider user id 78 | let existing = await prisma.user.findUnique({ 79 | where: { 80 | [providerKey]: profile.id, 81 | }, 82 | }) 83 | // Otherwise find one with the same email and link them 84 | if (!existing) { 85 | existing = await prisma.user.findUnique({ 86 | where: { 87 | email, 88 | }, 89 | }) 90 | if (existing) { 91 | await prisma.user.update({ 92 | where: { 93 | id: existing.id, 94 | }, 95 | data: { 96 | [providerKey]: profile.id, 97 | }, 98 | }) 99 | } 100 | } 101 | 102 | if (!existing) { 103 | existing = await prisma.user.create({ 104 | data: { 105 | email, 106 | name: profile.displayName || profile.username || 'My Name', 107 | [providerKey]: profile.id, 108 | avatar, 109 | }, 110 | }) 111 | } 112 | 113 | if (avatar && existing.avatar !== avatar) { 114 | await prisma.user.update({ 115 | where: { 116 | id: existing.id, 117 | }, 118 | data: { 119 | avatar, 120 | }, 121 | }) 122 | } 123 | 124 | return existing 125 | } 126 | 127 | export { passport } 128 | 129 | export async function handleSuccessfulLogin( 130 | req: IncomingMessage, 131 | res: ServerResponse, 132 | ) { 133 | const { id } = (req as $TsFixMe).user 134 | const session = await prisma.session.create({ 135 | data: { 136 | token: nanoid(), 137 | user: { 138 | connect: { 139 | id, 140 | }, 141 | }, 142 | }, 143 | }) 144 | const maxAge = 60 * 60 * 24 * 90 // 3 month 145 | const authCookie = serialize(AUTH_COOKIE_NAME, session.token, { 146 | path: '/', 147 | httpOnly: true, 148 | sameSite: 'lax', 149 | secure: process.env.NODE_ENV === 'production', 150 | maxAge, 151 | }) 152 | res.setHeader('Set-Cookie', [authCookie]) 153 | redirect(res, '/') 154 | } 155 | -------------------------------------------------------------------------------- /server/resolvers/blog.resolver.ts: -------------------------------------------------------------------------------- 1 | import { GqlContext } from '@server/decorators/gql-context' 2 | import type { TGqlContext } from '@server/decorators/gql-context' 3 | import { requireAuth } from '@server/guards/require-auth' 4 | import { prisma } from '@server/prisma' 5 | import { 6 | Arg, 7 | Args, 8 | ArgsType, 9 | Field, 10 | FieldResolver, 11 | ID, 12 | Int, 13 | Mutation, 14 | ObjectType, 15 | Query, 16 | Resolver, 17 | Root, 18 | } from 'type-graphql' 19 | import { MaxLength, MinLength } from 'class-validator' 20 | import { requireBlogAccess } from '@server/guards/require-blog-access' 21 | import { ApolloError } from 'apollo-server-micro' 22 | import { blogService } from '@server/services/blog.service' 23 | 24 | @ArgsType() 25 | class CreateBlogArgs { 26 | @Field() 27 | @MinLength(2) 28 | @MaxLength(20) 29 | name: string 30 | 31 | @Field() 32 | @MinLength(2) 33 | @MaxLength(20) 34 | slug: string 35 | } 36 | 37 | @ArgsType() 38 | class UpdateBlogArgs { 39 | @Field((type) => Int) 40 | id: number 41 | 42 | @Field() 43 | @MinLength(2) 44 | @MaxLength(20) 45 | slug: string 46 | 47 | @Field() 48 | @MinLength(2) 49 | @MaxLength(20) 50 | name: string 51 | 52 | @Field() 53 | introduction: string 54 | } 55 | 56 | @ObjectType() 57 | class Blog { 58 | @Field((type) => Int) 59 | id: number 60 | 61 | @Field() 62 | name: string 63 | 64 | @Field() 65 | introduction: string 66 | 67 | @Field() 68 | slug: string 69 | } 70 | 71 | const checkIfBlogSlugIsUsed = async (slug: string) => { 72 | const existing = await prisma.blog.findUnique({ 73 | where: { 74 | slug, 75 | }, 76 | }) 77 | if (existing) { 78 | throw new ApolloError(`${slug} is already used`, `blog_slug_used`, { 79 | field: 'slug', 80 | }) 81 | } 82 | } 83 | 84 | @Resolver((of) => Blog) 85 | export class BlogResolver { 86 | @Query((returns) => [Blog], { 87 | description: `Get blogs for current user`, 88 | }) 89 | async blogs(@GqlContext() ctx: TGqlContext) { 90 | const user = await requireAuth(ctx.req) 91 | const blogs = await blogService.getBlogsByUser(user.id) 92 | return blogs 93 | } 94 | 95 | @Query((returns) => Blog) 96 | async blog(@Arg('slug') slug: string) { 97 | const blog = await prisma.blog.findUnique({ 98 | where: { 99 | slug, 100 | }, 101 | }) 102 | if (!blog) { 103 | throw new ApolloError(`Blog not found`) 104 | } 105 | return blog 106 | } 107 | 108 | @Mutation((returns) => Blog) 109 | async createBlog( 110 | @GqlContext() ctx: TGqlContext, 111 | @Args() args: CreateBlogArgs, 112 | ) { 113 | const user = await requireAuth(ctx.req) 114 | await checkIfBlogSlugIsUsed(args.slug) 115 | const blog = await prisma.blog.create({ 116 | data: { 117 | user: { 118 | connect: { 119 | id: user.id, 120 | }, 121 | }, 122 | name: args.name, 123 | slug: args.slug, 124 | introduction: `I just started blogging on Blogify, it's awesome!`, 125 | }, 126 | }) 127 | return blog 128 | } 129 | 130 | @Mutation((returns) => Blog) 131 | async updateBlog( 132 | @GqlContext() ctx: TGqlContext, 133 | @Args() args: UpdateBlogArgs, 134 | ) { 135 | const user = await requireAuth(ctx.req) 136 | const blog = await prisma.blog.findUnique({ 137 | where: { 138 | id: args.id, 139 | }, 140 | }) 141 | if (!blog) { 142 | throw new ApolloError(`Blog not found`) 143 | } 144 | requireBlogAccess(user, blog) 145 | if (blog.slug !== args.slug) { 146 | await checkIfBlogSlugIsUsed(args.slug) 147 | } 148 | const updatedBlog = await prisma.blog.update({ 149 | where: { 150 | id: args.id, 151 | }, 152 | data: { 153 | slug: args.slug, 154 | name: args.name, 155 | introduction: args.introduction, 156 | }, 157 | }) 158 | return updatedBlog 159 | } 160 | 161 | @Mutation((returns) => Boolean) 162 | async setLastActiveBlog( 163 | @GqlContext() ctx: TGqlContext, 164 | @Arg('id', (type) => Int) id: number, 165 | ) { 166 | const user = await requireAuth(ctx.req) 167 | await prisma.user.update({ 168 | where: { 169 | id: user.id, 170 | }, 171 | data: { 172 | lastActiveBlogId: id, 173 | }, 174 | }) 175 | return true 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/pages/dashboard/[blog]/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/Button' 2 | import { AppLayout } from '@/components/layouts/AppLayout' 3 | import { useUpdateBlogMutation } from '@/generated/graphql' 4 | import { getServerSession, UserSession } from '@server/auth' 5 | import { blogService } from '@server/services/blog.service' 6 | import { useFormik } from 'formik' 7 | import { GetServerSideProps } from 'next' 8 | import Link from 'next/link' 9 | import * as Yup from 'yup' 10 | import React from 'react' 11 | import { createSnackbar } from '@snackbar/core' 12 | import { useRouter } from 'next/router' 13 | import { BlogInfo } from '@/components/layouts/BlogLayout' 14 | import { BlogSidebar } from '@/components/dashboard/BlogSidebar' 15 | 16 | type PageProps = { 17 | user: UserSession 18 | blog: BlogInfo & { 19 | introduction: string 20 | id: number 21 | } 22 | } 23 | 24 | export const getServerSideProps: GetServerSideProps = async ( 25 | ctx, 26 | ) => { 27 | const { user } = await getServerSession(ctx.req) 28 | if (!user) { 29 | return { 30 | redirect: { 31 | destination: '/login', 32 | permanent: false, 33 | }, 34 | } 35 | } 36 | const blog = await blogService.getBlogBySlug(ctx.query.blog as string) 37 | if (!blog) { 38 | return { notFound: true } 39 | } 40 | return { 41 | props: { 42 | user, 43 | blog, 44 | }, 45 | } 46 | } 47 | 48 | const BlogSettings: React.FC = ({ user, blog }) => { 49 | const title = `Settings for "${blog.name}"` 50 | 51 | const router = useRouter() 52 | const [, updateBlogMutation] = useUpdateBlogMutation() 53 | 54 | const form = useFormik({ 55 | initialValues: { 56 | name: blog.name, 57 | slug: blog.slug, 58 | introduction: blog.introduction, 59 | }, 60 | validationSchema: Yup.object().shape({ 61 | name: Yup.string().min(2).max(20).required(), 62 | slug: Yup.string() 63 | .min(2) 64 | .max(20) 65 | .matches(/^[a-zA-Z0-9_-]+$/, { 66 | message: `Only alphabet, numbers, dash and underscore are allowed`, 67 | }) 68 | .required(), 69 | introduction: Yup.string().max(1000), 70 | }), 71 | async onSubmit(values) { 72 | const { data, error } = await updateBlogMutation({ 73 | id: blog.id, 74 | slug: values.slug, 75 | name: values.name, 76 | introduction: values.introduction, 77 | }) 78 | if (data) { 79 | createSnackbar(`Changes have been saved`, { 80 | timeout: 3000, 81 | }) 82 | router.replace(`/dashboard/${values.slug}/settings`) 83 | } else if (error) { 84 | const field = 85 | error.graphQLErrors && 86 | error.graphQLErrors[0] && 87 | error.graphQLErrors[0].extensions && 88 | error.graphQLErrors[0].extensions['field'] 89 | const message = error.message.replace('[GraphQL] ', '') 90 | if (field) { 91 | form.setFieldError(field, message) 92 | } else { 93 | createSnackbar(message, { 94 | timeout: 3000, 95 | theme: { 96 | actionColor: 'red', 97 | }, 98 | }) 99 | } 100 | } 101 | }, 102 | }) 103 | 104 | return ( 105 | } 109 | > 110 |
111 |
112 | 113 | 120 |
121 | {form.errors.name && form.touched.name && ( 122 | {form.errors.name} 123 | )} 124 | 125 |
126 | 127 |
128 | blogify.dev/ 129 | 136 |
137 |
138 | {form.errors.slug && form.touched.slug && ( 139 | {form.errors.slug} 140 | )} 141 | 142 |
143 | 144 | 152 |
153 | {form.errors.introduction && form.touched.introduction && ( 154 | {form.errors.introduction} 155 | )} 156 | 157 |
158 | 161 |
162 |
163 |
164 | ) 165 | } 166 | 167 | export default BlogSettings 168 | -------------------------------------------------------------------------------- /src/pages/terms.tsx: -------------------------------------------------------------------------------- 1 | import { AppLayout } from '@/components/layouts/AppLayout' 2 | import { getServerSession, UserSession } from '@server/auth' 3 | import { GetServerSideProps } from 'next' 4 | 5 | type PageProps = { 6 | user: UserSession | null 7 | } 8 | 9 | export const getServerSideProps: GetServerSideProps = async ( 10 | ctx, 11 | ) => { 12 | const { user } = await getServerSession(ctx.req) 13 | 14 | return { 15 | props: { 16 | user, 17 | }, 18 | } 19 | } 20 | 21 | const Terms: React.FC = ({ user }) => { 22 | return ( 23 | 24 |

Terms of Service

25 |
26 |

Terms

27 |

28 | By accessing this web site, you are agreeing to be bound by these web 29 | site Terms and Conditions of Use, our Privacy Policy, all applicable 30 | laws and regulations, and agree that you are responsible for 31 | compliance with any applicable local laws. If you do not agree with 32 | any of these terms, you are prohibited from using or accessing this 33 | site. The materials contained in this web site are protected by 34 | applicable copyright and trade mark law. 35 |

36 |

Limitations

37 |

38 | In no event shall Blogify or its suppliers be liable for any damages 39 | (including, without limitation, damages for loss of data or profit, or 40 | due to business interruption,) arising out of the use or inability to 41 | use the materials on Blogify's Internet site, even if Blogify or an 42 | authorized representative has been notified orally or in writing of 43 | the possibility of such damage. Because some jurisdictions do not 44 | allow limitations on implied warranties, or limitations of liability 45 | for consequential or incidental damages, these limitations may not 46 | apply to you. 47 |

48 |

Links

49 |

50 | Blogify has not reviewed all of the sites linked to its Internet web 51 | site and is not responsible for the contents of any such linked site. 52 | The inclusion of any link does not imply endorsement by Blogify of the 53 | site. Use of any such linked web site is at the user's own risk. 54 |

55 |

Copyright / Takedown

56 |

57 | Users agree and certify that they have rights to share all content 58 | that they post on Blogify — including, but not limited to, information 59 | posted in articles, discussions, and comments. This rule applies to 60 | prose, code snippets, collections of links, etc. Regardless of 61 | citation, users may not post copy and pasted content that does not 62 | belong to them. Users assume all risk for the content they post, 63 | including someone else's reliance on its accuracy, claims relating to 64 | intellectual property, or other legal rights. If you believe that a 65 | user has plagiarized content, misrepresented their identity, 66 | misappropriated work, or otherwise run afoul of DMCA regulations, 67 | please email. Blogify may remove any content users post for any 68 | reason. 69 |

70 |

Site Terms of Use Modifications

71 |

72 | Blogify may revise these terms of use for its web site at any time 73 | without notice. By using this web site you are agreeing to be bound by 74 | the then current version of these Terms and Conditions of Use. 75 |

76 |

Reserved Names

77 |

78 | Blogify has the right to maintain a list of reserved names which will 79 | not be made publicly available. These reserved names may be set aside 80 | for purposes of proactive trademark protection, avoiding user 81 | confusion, security measures, or any other reason (or no reason). 82 |

83 |

84 | Additionally, Blogify reserves the right to change any already-claimed 85 | name at its sole discretion. In such cases, Blogify will make 86 | reasonable effort to find a suitable alternative and assist with any 87 | transition-related concerns. 88 |

89 |

Content Policy

90 |

91 | The following policy applies to articles, and all other works shared 92 | on the Blogify platform: 93 |

94 |
    95 |
  • 96 | Users must make a good-faith effort to share content that is 97 | on-topic, of high-quality, and is not designed primarily for the 98 | purposes of promotion or creating backlinks. 99 |
  • 100 |
  • 101 | Posts must contain substantial content — they may not merely 102 | reference an external link that contains the full post. 103 |
  • 104 |
  • 105 | If a post contains affiliate links, that fact must be clearly 106 | disclosed. For instance, with language such as: “This post includes 107 | affiliate links; I may receive compensation if you purchase products 108 | or services from the different links provided in this article.” 109 |
  • 110 |
111 |

112 | Blogify reserves the right to remove any content that it deems to be 113 | in violation of this policy at its sole discretion. Additionally, 114 | Blogify reserves the right to restrict any user’s ability to 115 | participate on the platform at its sole discretion. 116 |

117 | 118 |

Governing Law

119 |

120 | Any claim relating to Blogify's web site shall be governed by the laws 121 | of the State of New York without regard to its conflict of law 122 | provisions. 123 |

124 |

General Terms and Conditions applicable to Use of a Web Site.

125 |
126 |
127 | ) 128 | } 129 | 130 | export default Terms 131 | -------------------------------------------------------------------------------- /src/components/dashboard/BlogSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useRouter } from 'next/router' 3 | import { 4 | useGetMyBlogsQuery, 5 | useSetLastActiveBlogMutation, 6 | } from '@/generated/graphql' 7 | import Link from 'next/link' 8 | import clsx from 'clsx' 9 | 10 | const BlogSelect = () => { 11 | const router = useRouter() 12 | const [myBlogsResult] = useGetMyBlogsQuery({ 13 | requestPolicy: 'cache-and-network', 14 | }) 15 | const [open, setOpen] = React.useState(false) 16 | const elRef = React.useRef(null) 17 | 18 | const currentBlog = myBlogsResult.data?.blogs.find( 19 | (blog) => blog.slug === router.query.blog, 20 | ) 21 | const [, setLastActiveBlog] = useSetLastActiveBlogMutation() 22 | 23 | React.useEffect(() => { 24 | const handleClick = (e: any) => { 25 | if (elRef.current && !elRef.current.contains(e.target)) { 26 | setOpen(false) 27 | } 28 | } 29 | document.addEventListener('click', handleClick) 30 | return () => document.removeEventListener('click', handleClick) 31 | }, []) 32 | 33 | React.useEffect(() => { 34 | if (currentBlog?.id) { 35 | setLastActiveBlog({ id: currentBlog.id }) 36 | } 37 | }, [currentBlog?.id]) 38 | 39 | return ( 40 |
41 | 62 | {open && ( 63 |
64 | {myBlogsResult.data && ( 65 | 115 | )} 116 |
117 | )}{' '} 118 |
119 | ) 120 | } 121 | 122 | export const BlogSidebar = () => { 123 | const router = useRouter() 124 | const blogSlug = router.query.blog as string 125 | const nav = [ 126 | { 127 | text: 'Posts', 128 | link: `/dashboard/${blogSlug}`, 129 | }, 130 | { 131 | text: 'Settings', 132 | link: `/dashboard/${blogSlug}/settings`, 133 | }, 134 | ] 135 | return ( 136 | 181 | ) 182 | } 183 | -------------------------------------------------------------------------------- /src/components/PostEditor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useCreatePostMutation, 3 | useGetPostForEditQuery, 4 | useUpdatePostMutation, 5 | } from '@/generated/graphql' 6 | import { loadEditor } from '@/lib/editor' 7 | import { useFormik } from 'formik' 8 | import { useRouter } from 'next/router' 9 | import * as Yup from 'yup' 10 | import React from 'react' 11 | import { Button } from './Button' 12 | import { BlogInfo, BlogLayout } from './layouts/BlogLayout' 13 | import { AppLayout } from './layouts/AppLayout' 14 | import { BlogSidebar } from './dashboard/BlogSidebar' 15 | 16 | export const PostEditor: React.FC<{ 17 | user: any 18 | postId?: number 19 | blog: BlogInfo 20 | }> = ({ user, blog, postId }) => { 21 | const router = useRouter() 22 | const textarea = React.useRef(null) 23 | const [ 24 | editor, 25 | setEditor, 26 | ] = React.useState(null) 27 | 28 | const [initialPostResult] = useGetPostForEditQuery({ 29 | variables: { 30 | id: postId!, 31 | }, 32 | pause: !postId, 33 | }) 34 | const initialPost = initialPostResult.data?.post 35 | 36 | const [, createPostMutation] = useCreatePostMutation() 37 | const [, updatePostMutation] = useUpdatePostMutation() 38 | const initEditor = async () => { 39 | const { CodeMirror } = await loadEditor() 40 | if (!textarea.current) return 41 | const editor = CodeMirror.fromTextArea(textarea.current, { 42 | lineNumbers: false, 43 | mode: 'markdown', 44 | theme: 'monokai', 45 | lineWrapping: true, 46 | indentWithTabs: false, 47 | }) 48 | editor.on('change', () => { 49 | form.setFieldValue('content', editor.getValue()) 50 | }) 51 | setEditor(editor) 52 | } 53 | 54 | React.useEffect(() => { 55 | initEditor() 56 | return () => { 57 | editor && editor.toTextArea() 58 | } 59 | }, []) 60 | 61 | React.useEffect(() => { 62 | if (!initialPost || !editor) return 63 | 64 | form.setValues({ 65 | title: initialPost.title, 66 | content: '', 67 | cover: initialPost.cover || '', 68 | slug: initialPost.slug || '', 69 | tags: initialPost.tags.map((tag) => tag.name).join(', '), 70 | }) 71 | editor.setValue(initialPost.content) 72 | }, [initialPost, editor]) 73 | 74 | const form = useFormik({ 75 | initialValues: { 76 | title: '', 77 | content: '', 78 | tags: '', 79 | slug: '', 80 | cover: '', 81 | }, 82 | validationSchema: Yup.object().shape({ 83 | title: Yup.string().required(), 84 | content: Yup.string().required(), 85 | tags: Yup.string().test({ 86 | name: 'tags-limit', 87 | message: 'too many ${path}', 88 | test: (value) => value == null || value?.split(',').length <= 10, 89 | }), 90 | slug: Yup.string() 91 | .min(2) 92 | .max(100) 93 | .test({ 94 | name: 'allowed-chars', 95 | message: `Only letters, numbers, dash and underscore are allowed`, 96 | test: (value) => value == null || /^[a-z0-9_-]+$/i.test(value), 97 | }), 98 | }), 99 | async onSubmit(values) { 100 | const dashboardLink = `/dashboard/${blog.slug}` 101 | if (postId) { 102 | const { data } = await updatePostMutation({ 103 | id: postId, 104 | title: values.title, 105 | content: values.content, 106 | tags: values.tags, 107 | slug: values.slug, 108 | cover: values.cover, 109 | }) 110 | if (data) { 111 | router.push(dashboardLink) 112 | } 113 | } else { 114 | const { data } = await createPostMutation({ 115 | blogSlug: router.query.blog as string, 116 | title: values.title, 117 | content: values.content, 118 | tags: values.tags, 119 | slug: values.slug, 120 | cover: values.cover, 121 | }) 122 | if (data) { 123 | router.push(dashboardLink) 124 | } 125 | } 126 | }, 127 | }) 128 | 129 | return ( 130 | } 133 | > 134 |
135 |
136 | 144 | {form.errors.title && form.touched.title && ( 145 |
{form.errors.title}
146 | )} 147 |
148 |
149 | 154 | {!editor &&
Loading..
} 155 | {editor && form.errors.content && form.touched.content && ( 156 |
{form.errors.content}
157 | )} 158 |
159 |
160 | 161 |
162 | We'll generate the permalink for you if you leave it blank 163 |
164 |
165 | blogify.dev/{blog.slug}/ 166 | 174 |
175 | {form.errors.slug && form.touched.slug && ( 176 |
{form.errors.slug}
177 | )} 178 |
179 |
180 | 181 |
182 | 190 |
191 | {form.errors.cover && form.touched.cover && ( 192 |
{form.errors.cover}
193 | )} 194 |
195 |
196 | 197 |
198 | Allows up to 10 tags, separated by comma 199 |
200 | 207 | {form.errors.tags && form.touched.tags && ( 208 |
{form.errors.tags}
209 | )} 210 |
211 | {editor && ( 212 |
213 | 220 |
221 | )} 222 |
223 |
224 | ) 225 | } 226 | -------------------------------------------------------------------------------- /src/css/codemirror.css: -------------------------------------------------------------------------------- 1 | /* BASICS */ 2 | 3 | .CodeMirror { 4 | /* Set height, width, borders, and global font properties here */ 5 | font-family: inherit; 6 | height: auto; 7 | color: black; 8 | direction: ltr; 9 | border: 1px solid var(--border-color); 10 | @apply rounded-lg; 11 | } 12 | 13 | .CodeMirror.CodeMirror-focused { 14 | border-color: var(--button-border-hover-color); 15 | } 16 | 17 | .CodeMirror-scroll { 18 | min-height: 300px; 19 | padding: 10px; 20 | } 21 | 22 | /* PADDING */ 23 | 24 | .CodeMirror-lines { 25 | padding: 4px 0; /* Vertical padding around content */ 26 | } 27 | .CodeMirror pre.CodeMirror-line, 28 | .CodeMirror pre.CodeMirror-line-like { 29 | padding: 0 4px; /* Horizontal padding of content */ 30 | } 31 | 32 | .CodeMirror-scrollbar-filler, 33 | .CodeMirror-gutter-filler { 34 | background-color: white; /* The little square between H and V scrollbars */ 35 | } 36 | 37 | /* GUTTER */ 38 | 39 | .CodeMirror-gutters { 40 | border-right: 1px solid #ddd; 41 | background-color: #f7f7f7; 42 | white-space: nowrap; 43 | } 44 | .CodeMirror-linenumbers { 45 | } 46 | .CodeMirror-linenumber { 47 | padding: 0 3px 0 5px; 48 | min-width: 20px; 49 | text-align: right; 50 | color: #999; 51 | white-space: nowrap; 52 | } 53 | 54 | .CodeMirror-guttermarker { 55 | color: black; 56 | } 57 | .CodeMirror-guttermarker-subtle { 58 | color: #999; 59 | } 60 | 61 | /* CURSOR */ 62 | 63 | .CodeMirror-cursor { 64 | border-left: 1px solid black; 65 | border-right: none; 66 | width: 0; 67 | } 68 | /* Shown when moving in bi-directional text */ 69 | .CodeMirror div.CodeMirror-secondarycursor { 70 | border-left: 1px solid silver; 71 | } 72 | .cm-fat-cursor .CodeMirror-cursor { 73 | width: auto; 74 | border: 0 !important; 75 | background: #7e7; 76 | } 77 | .cm-fat-cursor div.CodeMirror-cursors { 78 | z-index: 1; 79 | } 80 | .cm-fat-cursor-mark { 81 | background-color: rgba(20, 255, 20, 0.5); 82 | -webkit-animation: blink 1.06s steps(1) infinite; 83 | -moz-animation: blink 1.06s steps(1) infinite; 84 | animation: blink 1.06s steps(1) infinite; 85 | } 86 | .cm-animate-fat-cursor { 87 | width: auto; 88 | border: 0; 89 | -webkit-animation: blink 1.06s steps(1) infinite; 90 | -moz-animation: blink 1.06s steps(1) infinite; 91 | animation: blink 1.06s steps(1) infinite; 92 | background-color: #7e7; 93 | } 94 | @-moz-keyframes blink { 95 | 0% { 96 | } 97 | 50% { 98 | background-color: transparent; 99 | } 100 | 100% { 101 | } 102 | } 103 | @-webkit-keyframes blink { 104 | 0% { 105 | } 106 | 50% { 107 | background-color: transparent; 108 | } 109 | 100% { 110 | } 111 | } 112 | @keyframes blink { 113 | 0% { 114 | } 115 | 50% { 116 | background-color: transparent; 117 | } 118 | 100% { 119 | } 120 | } 121 | 122 | /* Can style cursor different in overwrite (non-insert) mode */ 123 | .CodeMirror-overwrite .CodeMirror-cursor { 124 | } 125 | 126 | .cm-tab { 127 | display: inline-block; 128 | text-decoration: inherit; 129 | } 130 | 131 | .CodeMirror-rulers { 132 | position: absolute; 133 | left: 0; 134 | right: 0; 135 | top: -50px; 136 | bottom: 0; 137 | overflow: hidden; 138 | } 139 | .CodeMirror-ruler { 140 | border-left: 1px solid #ccc; 141 | top: 0; 142 | bottom: 0; 143 | position: absolute; 144 | } 145 | 146 | /* DEFAULT THEME */ 147 | 148 | .cm-s-default .cm-header { 149 | color: blue; 150 | } 151 | .cm-s-default .cm-quote { 152 | color: #090; 153 | } 154 | .cm-negative { 155 | color: #d44; 156 | } 157 | .cm-positive { 158 | color: #292; 159 | } 160 | .cm-header, 161 | .cm-strong { 162 | font-weight: bold; 163 | } 164 | .cm-em { 165 | font-style: italic; 166 | } 167 | .cm-link { 168 | text-decoration: underline; 169 | } 170 | .cm-strikethrough { 171 | text-decoration: line-through; 172 | } 173 | 174 | .cm-s-default .cm-keyword { 175 | color: #708; 176 | } 177 | .cm-s-default .cm-atom { 178 | color: #219; 179 | } 180 | .cm-s-default .cm-number { 181 | color: #164; 182 | } 183 | .cm-s-default .cm-def { 184 | color: #00f; 185 | } 186 | .cm-s-default .cm-variable, 187 | .cm-s-default .cm-punctuation, 188 | .cm-s-default .cm-property, 189 | .cm-s-default .cm-operator { 190 | } 191 | .cm-s-default .cm-variable-2 { 192 | color: #05a; 193 | } 194 | .cm-s-default .cm-variable-3, 195 | .cm-s-default .cm-type { 196 | color: #085; 197 | } 198 | .cm-s-default .cm-comment { 199 | color: #a50; 200 | } 201 | .cm-s-default .cm-string { 202 | color: #a11; 203 | } 204 | .cm-s-default .cm-string-2 { 205 | color: #f50; 206 | } 207 | .cm-s-default .cm-meta { 208 | color: #555; 209 | } 210 | .cm-s-default .cm-qualifier { 211 | color: #555; 212 | } 213 | .cm-s-default .cm-builtin { 214 | color: #30a; 215 | } 216 | .cm-s-default .cm-bracket { 217 | color: #997; 218 | } 219 | .cm-s-default .cm-tag { 220 | color: #170; 221 | } 222 | .cm-s-default .cm-attribute { 223 | color: #00c; 224 | } 225 | .cm-s-default .cm-hr { 226 | color: #999; 227 | } 228 | .cm-s-default .cm-link { 229 | color: #00c; 230 | } 231 | 232 | .cm-s-default .cm-error { 233 | color: #f00; 234 | } 235 | .cm-invalidchar { 236 | color: #f00; 237 | } 238 | 239 | .CodeMirror-composing { 240 | border-bottom: 2px solid; 241 | } 242 | 243 | /* Default styles for common addons */ 244 | 245 | div.CodeMirror span.CodeMirror-matchingbracket { 246 | color: #0b0; 247 | } 248 | div.CodeMirror span.CodeMirror-nonmatchingbracket { 249 | color: #a22; 250 | } 251 | .CodeMirror-matchingtag { 252 | background: rgba(255, 150, 0, 0.3); 253 | } 254 | .CodeMirror-activeline-background { 255 | background: #e8f2ff; 256 | } 257 | 258 | /* STOP */ 259 | 260 | /* The rest of this file contains styles related to the mechanics of 261 | the editor. You probably shouldn't touch them. */ 262 | 263 | .CodeMirror { 264 | position: relative; 265 | overflow: hidden; 266 | background: white; 267 | } 268 | 269 | .CodeMirror-scroll { 270 | overflow: scroll !important; /* Things will break if this is overridden */ 271 | /* 50px is the magic margin used to hide the element's real scrollbars */ 272 | /* See overflow: hidden in .CodeMirror */ 273 | margin-bottom: -50px; 274 | margin-right: -50px; 275 | padding-bottom: 50px; 276 | height: 100%; 277 | outline: none; /* Prevent dragging from highlighting the element */ 278 | position: relative; 279 | } 280 | .CodeMirror-sizer { 281 | position: relative; 282 | border-right: 50px solid transparent; 283 | } 284 | 285 | /* The fake, visible scrollbars. Used to force redraw during scrolling 286 | before actual scrolling happens, thus preventing shaking and 287 | flickering artifacts. */ 288 | .CodeMirror-vscrollbar, 289 | .CodeMirror-hscrollbar, 290 | .CodeMirror-scrollbar-filler, 291 | .CodeMirror-gutter-filler { 292 | position: absolute; 293 | z-index: 6; 294 | display: none; 295 | outline: none; 296 | } 297 | .CodeMirror-vscrollbar { 298 | right: 0; 299 | top: 0; 300 | overflow-x: hidden; 301 | overflow-y: scroll; 302 | } 303 | .CodeMirror-hscrollbar { 304 | bottom: 0; 305 | left: 0; 306 | overflow-y: hidden; 307 | overflow-x: scroll; 308 | } 309 | .CodeMirror-scrollbar-filler { 310 | right: 0; 311 | bottom: 0; 312 | } 313 | .CodeMirror-gutter-filler { 314 | left: 0; 315 | bottom: 0; 316 | } 317 | 318 | .CodeMirror-gutters { 319 | position: absolute; 320 | left: 0; 321 | top: 0; 322 | min-height: 100%; 323 | z-index: 3; 324 | } 325 | .CodeMirror-gutter { 326 | white-space: normal; 327 | height: 100%; 328 | display: inline-block; 329 | vertical-align: top; 330 | margin-bottom: -50px; 331 | } 332 | .CodeMirror-gutter-wrapper { 333 | position: absolute; 334 | z-index: 4; 335 | background: none !important; 336 | border: none !important; 337 | } 338 | .CodeMirror-gutter-background { 339 | position: absolute; 340 | top: 0; 341 | bottom: 0; 342 | z-index: 4; 343 | } 344 | .CodeMirror-gutter-elt { 345 | position: absolute; 346 | cursor: default; 347 | z-index: 4; 348 | } 349 | .CodeMirror-gutter-wrapper ::selection { 350 | background-color: transparent; 351 | } 352 | .CodeMirror-gutter-wrapper ::-moz-selection { 353 | background-color: transparent; 354 | } 355 | 356 | .CodeMirror-lines { 357 | cursor: text; 358 | min-height: 1px; /* prevents collapsing before first draw */ 359 | } 360 | .CodeMirror pre.CodeMirror-line, 361 | .CodeMirror pre.CodeMirror-line-like { 362 | /* Reset some styles that the rest of the page might have set */ 363 | -moz-border-radius: 0; 364 | -webkit-border-radius: 0; 365 | border-radius: 0; 366 | border-width: 0; 367 | background: transparent; 368 | font-family: inherit; 369 | font-size: inherit; 370 | margin: 0; 371 | white-space: pre; 372 | word-wrap: normal; 373 | line-height: inherit; 374 | color: inherit; 375 | z-index: 2; 376 | position: relative; 377 | overflow: visible; 378 | -webkit-tap-highlight-color: transparent; 379 | -webkit-font-variant-ligatures: contextual; 380 | font-variant-ligatures: contextual; 381 | } 382 | .CodeMirror-wrap pre.CodeMirror-line, 383 | .CodeMirror-wrap pre.CodeMirror-line-like { 384 | word-wrap: break-word; 385 | white-space: pre-wrap; 386 | word-break: normal; 387 | } 388 | 389 | .CodeMirror-linebackground { 390 | position: absolute; 391 | left: 0; 392 | right: 0; 393 | top: 0; 394 | bottom: 0; 395 | z-index: 0; 396 | } 397 | 398 | .CodeMirror-linewidget { 399 | position: relative; 400 | z-index: 2; 401 | padding: 0.1px; /* Force widget margins to stay inside of the container */ 402 | } 403 | 404 | .CodeMirror-widget { 405 | } 406 | 407 | .CodeMirror-rtl pre { 408 | direction: rtl; 409 | } 410 | 411 | .CodeMirror-code { 412 | outline: none; 413 | } 414 | 415 | /* Force content-box sizing for the elements where we expect it */ 416 | .CodeMirror-scroll, 417 | .CodeMirror-sizer, 418 | .CodeMirror-gutter, 419 | .CodeMirror-gutters, 420 | .CodeMirror-linenumber { 421 | -moz-box-sizing: content-box; 422 | box-sizing: content-box; 423 | } 424 | 425 | .CodeMirror-measure { 426 | position: absolute; 427 | width: 100%; 428 | height: 0; 429 | overflow: hidden; 430 | visibility: hidden; 431 | } 432 | 433 | .CodeMirror-cursor { 434 | position: absolute; 435 | pointer-events: none; 436 | } 437 | .CodeMirror-measure pre { 438 | position: static; 439 | } 440 | 441 | div.CodeMirror-cursors { 442 | visibility: hidden; 443 | position: relative; 444 | z-index: 3; 445 | } 446 | div.CodeMirror-dragcursors { 447 | visibility: visible; 448 | } 449 | 450 | .CodeMirror-focused div.CodeMirror-cursors { 451 | visibility: visible; 452 | } 453 | 454 | .CodeMirror-selected { 455 | background: #d9d9d9; 456 | } 457 | .CodeMirror-focused .CodeMirror-selected { 458 | background: #d7d4f0; 459 | } 460 | .CodeMirror-crosshair { 461 | cursor: crosshair; 462 | } 463 | .CodeMirror-line::selection, 464 | .CodeMirror-line > span::selection, 465 | .CodeMirror-line > span > span::selection { 466 | background: #d7d4f0; 467 | } 468 | .CodeMirror-line::-moz-selection, 469 | .CodeMirror-line > span::-moz-selection, 470 | .CodeMirror-line > span > span::-moz-selection { 471 | background: #d7d4f0; 472 | } 473 | 474 | .cm-searching { 475 | background-color: #ffa; 476 | background-color: rgba(255, 255, 0, 0.4); 477 | } 478 | 479 | /* Used to force a border model for a node */ 480 | .cm-force-border { 481 | padding-right: 0.1px; 482 | } 483 | 484 | @media print { 485 | /* Hide the cursor when printing */ 486 | .CodeMirror div.CodeMirror-cursors { 487 | visibility: hidden; 488 | } 489 | } 490 | 491 | /* See issue #2901 */ 492 | .cm-tab-wrap-hack:after { 493 | content: ''; 494 | } 495 | 496 | /* Help users use markselection to safely style text background */ 497 | span.CodeMirror-selectedtext { 498 | background: none; 499 | } 500 | -------------------------------------------------------------------------------- /server/resolvers/post.resolver.ts: -------------------------------------------------------------------------------- 1 | import { GqlContext } from '@server/decorators/gql-context' 2 | import type { TGqlContext } from '@server/decorators/gql-context' 3 | import { 4 | Args, 5 | ArgsType, 6 | Field, 7 | ID, 8 | Int, 9 | Mutation, 10 | ObjectType, 11 | Resolver, 12 | Query, 13 | FieldResolver, 14 | Root, 15 | Arg, 16 | } from 'type-graphql' 17 | import { requireAuth } from '@server/guards/require-auth' 18 | import { prisma } from '@server/prisma' 19 | import { requireBlogAccess } from '@server/guards/require-blog-access' 20 | import { customAlphabet } from 'nanoid' 21 | import { ApolloError } from 'apollo-server-micro' 22 | import { getExcerpt } from '@server/markdown' 23 | import { slugify } from '@server/lib/slugify' 24 | import { blogService } from '@server/services/blog.service' 25 | import dayjs from 'dayjs' 26 | 27 | const randomSlugSuffix = customAlphabet( 28 | `abcdefghijklmnopqrstuvwxyz0123456789`, 29 | 4, 30 | ) 31 | 32 | @ArgsType() 33 | class CreatePostArgs { 34 | @Field() 35 | title: string 36 | 37 | @Field() 38 | content: string 39 | 40 | @Field() 41 | blogSlug: string 42 | 43 | @Field() 44 | slug: string 45 | 46 | @Field() 47 | tags: string 48 | 49 | @Field() 50 | cover: string 51 | } 52 | 53 | @ObjectType() 54 | class Post { 55 | @Field((type) => Int) 56 | id: number 57 | 58 | @Field() 59 | slug: String 60 | 61 | @Field() 62 | content: String 63 | 64 | @Field() 65 | title: String 66 | 67 | @Field() 68 | createdAt: Date 69 | 70 | @Field() 71 | updatedAt: Date 72 | 73 | @Field((type) => String, { nullable: true }) 74 | cover?: string | null 75 | } 76 | 77 | @ArgsType() 78 | class UpdatePostArgs { 79 | @Field((type) => Int) 80 | id: number 81 | 82 | @Field() 83 | title: string 84 | 85 | @Field() 86 | content: string 87 | 88 | @Field() 89 | tags: string 90 | 91 | @Field() 92 | slug: string 93 | 94 | @Field() 95 | cover: string 96 | } 97 | 98 | @ArgsType() 99 | class LikePostArgs { 100 | @Field((type) => Int) 101 | postId: number 102 | } 103 | 104 | @ObjectType() 105 | class LikePostResult { 106 | @Field((type) => Int) 107 | likesCount: number 108 | 109 | @Field() 110 | isLiked: boolean 111 | } 112 | 113 | const formatTags = (tags: string) => { 114 | tags = tags.trim() 115 | if (!tags) return [] 116 | return tags.split(',').map((tag) => tag.trim()) 117 | } 118 | 119 | const parseContent = async ({ 120 | title, 121 | content, 122 | slug, 123 | checkSlugUniqueness, 124 | }: { 125 | title: string 126 | content: string 127 | slug: string 128 | checkSlugUniqueness?: { 129 | blogId: number 130 | } 131 | }) => { 132 | if (!slug) { 133 | slug = slugify(title) 134 | } 135 | if (checkSlugUniqueness) { 136 | const existingBySlug = await prisma.post.findFirst({ 137 | where: { 138 | slug, 139 | blogId: checkSlugUniqueness.blogId, 140 | }, 141 | }) 142 | if (existingBySlug) { 143 | slug += `-${randomSlugSuffix()}` 144 | } 145 | } 146 | const excerpt = getExcerpt(content) 147 | return { 148 | title, 149 | slug, 150 | content, 151 | excerpt, 152 | } 153 | } 154 | 155 | const populateTags = async (blogId: number, tags: string) => { 156 | const result: { 157 | create: Array<{ 158 | blog: { connect: { id: number } } 159 | slug: string 160 | name: string 161 | }> 162 | connect: Array<{ id: number }> 163 | } = { 164 | create: [], 165 | connect: [], 166 | } 167 | await Promise.all( 168 | formatTags(tags) 169 | .slice(0, 10) // Only allows 10 tags 170 | .map(async (name) => { 171 | const slug = slugify(name) 172 | const existing = await prisma.tag.findFirst({ 173 | where: { 174 | blogId, 175 | slug, 176 | }, 177 | }) 178 | if (existing) { 179 | result.connect.push({ 180 | id: existing.id, 181 | }) 182 | } else { 183 | result.create.push({ 184 | blog: { 185 | connect: { 186 | id: blogId, 187 | }, 188 | }, 189 | slug, 190 | name, 191 | }) 192 | } 193 | }), 194 | ) 195 | return result 196 | } 197 | 198 | @ArgsType() 199 | class GetPostsArgs { 200 | @Field() 201 | blogSlug: string 202 | 203 | @Field((type) => Int, { defaultValue: 20 }) 204 | limit: number 205 | 206 | @Field((type) => Int, { defaultValue: 1 }) 207 | page: number 208 | 209 | @Field({ nullable: true }) 210 | tagSlug?: string 211 | } 212 | 213 | @ObjectType() 214 | class PostsConnection { 215 | @Field((type) => [Post]) 216 | data: Post[] 217 | 218 | @Field() 219 | hasOlder: boolean 220 | 221 | @Field() 222 | hasNewer: boolean 223 | 224 | @Field((type) => Int) 225 | total: number 226 | } 227 | 228 | @ObjectType() 229 | class Tag { 230 | @Field((type) => Int) 231 | id: number 232 | 233 | @Field() 234 | name: string 235 | 236 | @Field() 237 | slug: string 238 | } 239 | 240 | @ArgsType() 241 | class PostByIdArgs { 242 | @Field(() => Int) 243 | id: number 244 | } 245 | 246 | @Resolver((of) => Post) 247 | export class PostResolver { 248 | @Query((returns) => PostsConnection) 249 | async posts(@Args() args: GetPostsArgs): Promise { 250 | const blog = await prisma.blog.findUnique({ 251 | where: { 252 | slug: args.blogSlug, 253 | }, 254 | }) 255 | if (!blog) throw new ApolloError(`blog not found`) 256 | 257 | const skip = (args.page - 1) * args.limit 258 | const whereClause = { 259 | blogId: blog.id, 260 | deletedAt: null, 261 | tags: args.tagSlug 262 | ? { 263 | some: { 264 | slug: args.tagSlug, 265 | }, 266 | } 267 | : undefined, 268 | } 269 | const posts = await prisma.post.findMany({ 270 | where: whereClause, 271 | take: args.limit + 1, 272 | skip, 273 | orderBy: { 274 | createdAt: 'desc', 275 | }, 276 | }) 277 | const total = await prisma.post.count({ 278 | where: whereClause, 279 | }) 280 | 281 | return { 282 | data: posts.slice(0, args.limit), 283 | hasOlder: posts.length > args.limit, 284 | hasNewer: args.page > 1, 285 | total, 286 | } 287 | } 288 | 289 | @Query((returns) => Post) 290 | async post(@Args() args: PostByIdArgs) { 291 | const post = await prisma.post.findUnique({ 292 | where: { 293 | id: args.id, 294 | }, 295 | }) 296 | 297 | if (!post) throw new ApolloError(`post not found`) 298 | 299 | if (post.deletedAt) throw new ApolloError(`this post has been deleted`) 300 | 301 | return post 302 | } 303 | 304 | @FieldResolver((returns) => String) 305 | date(@Root() post: Post): string { 306 | return dayjs(post.createdAt).format('MMM DD, YYYY') 307 | } 308 | 309 | @FieldResolver((returns) => [Tag]) 310 | tags(@Root() post: Post): Promise { 311 | return prisma.tag.findMany({ 312 | where: { 313 | posts: { 314 | some: { 315 | id: post.id, 316 | }, 317 | }, 318 | }, 319 | }) 320 | } 321 | 322 | @Mutation((returns) => Post) 323 | async createPost( 324 | @GqlContext() ctx: TGqlContext, 325 | @Args() args: CreatePostArgs, 326 | ) { 327 | const user = await requireAuth(ctx.req) 328 | const blog = await prisma.blog.findUnique({ 329 | where: { 330 | slug: args.blogSlug, 331 | }, 332 | }) 333 | if (!blog) { 334 | throw new ApolloError(`Blog not found`) 335 | } 336 | requireBlogAccess(user, blog) 337 | 338 | const parsed = await parseContent({ 339 | title: args.title, 340 | content: args.content, 341 | slug: args.slug, 342 | checkSlugUniqueness: { 343 | blogId: blog.id, 344 | }, 345 | }) 346 | 347 | const post = await prisma.post.create({ 348 | data: { 349 | blog: { 350 | connect: { 351 | id: blog.id, 352 | }, 353 | }, 354 | title: parsed.title, 355 | content: parsed.content, 356 | excerpt: parsed.excerpt, 357 | slug: parsed.slug, 358 | cover: args.cover, 359 | tags: await populateTags(blog.id, args.tags), 360 | }, 361 | }) 362 | 363 | return post 364 | } 365 | 366 | @Mutation((returns) => Post) 367 | async updatePost( 368 | @GqlContext() ctx: TGqlContext, 369 | @Args() args: UpdatePostArgs, 370 | ) { 371 | const user = await requireAuth(ctx.req) 372 | const post = await prisma.post.findUnique({ 373 | where: { 374 | id: args.id, 375 | }, 376 | include: { 377 | blog: true, 378 | }, 379 | }) 380 | 381 | if (!post) { 382 | throw new ApolloError(`Post not found`) 383 | } 384 | 385 | await requireBlogAccess(user, post.blog) 386 | 387 | const parsed = await parseContent({ 388 | title: args.title, 389 | content: args.content, 390 | slug: args.slug, 391 | // Skip checking if slug doesn't change at all 392 | checkSlugUniqueness: 393 | args.slug === post.slug 394 | ? undefined 395 | : { 396 | blogId: post.blog.id, 397 | }, 398 | }) 399 | 400 | const updatedPost = await prisma.post.update({ 401 | where: { 402 | id: post.id, 403 | }, 404 | data: { 405 | title: parsed.title, 406 | content: parsed.content, 407 | excerpt: parsed.excerpt, 408 | slug: parsed.slug, 409 | cover: args.cover, 410 | tags: await populateTags(post.blogId, args.tags), 411 | }, 412 | }) 413 | return updatedPost 414 | } 415 | 416 | @Mutation((returns) => Boolean) 417 | async deletePost( 418 | @GqlContext() ctx: TGqlContext, 419 | @Arg('id', (type) => Int) id: number, 420 | ) { 421 | const user = await requireAuth(ctx.req) 422 | const post = await prisma.post.findUnique({ 423 | where: { 424 | id, 425 | }, 426 | include: { 427 | blog: true, 428 | }, 429 | }) 430 | 431 | if (!post) { 432 | throw new ApolloError(`Post not found`) 433 | } 434 | 435 | await requireBlogAccess(user, post.blog) 436 | await prisma.post.update({ 437 | where: { 438 | id, 439 | }, 440 | data: { 441 | deletedAt: new Date(), 442 | }, 443 | }) 444 | return true 445 | } 446 | 447 | @Mutation((returns) => LikePostResult) 448 | async likePost(@GqlContext() ctx: TGqlContext, @Args() args: LikePostArgs) { 449 | const user = await requireAuth(ctx.req) 450 | const post = await prisma.post.findUnique({ 451 | where: { 452 | id: args.postId, 453 | }, 454 | }) 455 | if (!post) { 456 | throw new ApolloError(`Post not found`) 457 | } 458 | const like = await prisma.like.findFirst({ 459 | where: { 460 | userId: user.id, 461 | postId: post.id, 462 | }, 463 | }) 464 | if (like) { 465 | await prisma.like.delete({ 466 | where: { 467 | id: like.id, 468 | }, 469 | }) 470 | await prisma.post.update({ 471 | where: { 472 | id: post.id, 473 | }, 474 | data: { 475 | likesCount: { 476 | decrement: 1, 477 | }, 478 | }, 479 | }) 480 | return { 481 | isLiked: false, 482 | likesCount: post.likesCount - 1, 483 | } 484 | } 485 | await prisma.like.create({ 486 | data: { 487 | user: { 488 | connect: { 489 | id: user.id, 490 | }, 491 | }, 492 | post: { 493 | connect: { 494 | id: post.id, 495 | }, 496 | }, 497 | }, 498 | }) 499 | await prisma.post.update({ 500 | where: { 501 | id: post.id, 502 | }, 503 | data: { 504 | likesCount: { 505 | increment: 1, 506 | }, 507 | }, 508 | }) 509 | return { 510 | likesCount: post.likesCount + 1, 511 | isLiked: true, 512 | } 513 | } 514 | } 515 | -------------------------------------------------------------------------------- /src/generated/graphql.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql' 2 | import * as Urql from 'urql' 3 | export type Maybe = T | null 4 | export type Exact = { 5 | [K in keyof T]: T[K] 6 | } 7 | export type MakeOptional = Omit & 8 | { [SubKey in K]?: Maybe } 9 | export type MakeMaybe = Omit & 10 | { [SubKey in K]: Maybe } 11 | export type Omit = Pick> 12 | /** All built-in and custom scalars, mapped to their actual values */ 13 | export type Scalars = { 14 | ID: string 15 | String: string 16 | Boolean: boolean 17 | Int: number 18 | Float: number 19 | /** The javascript `Date` as string. Type represents date and time as the ISO Date string. */ 20 | DateTime: any 21 | } 22 | 23 | export type Query = { 24 | __typename?: 'Query' 25 | /** Get blogs for current user */ 26 | blogs: Array 27 | blog: Blog 28 | posts: PostsConnection 29 | post: Post 30 | } 31 | 32 | export type QueryBlogArgs = { 33 | slug: Scalars['String'] 34 | } 35 | 36 | export type QueryPostsArgs = { 37 | blogSlug: Scalars['String'] 38 | limit?: Maybe 39 | page?: Maybe 40 | tagSlug?: Maybe 41 | } 42 | 43 | export type QueryPostArgs = { 44 | id: Scalars['Int'] 45 | } 46 | 47 | export type Blog = { 48 | __typename?: 'Blog' 49 | id: Scalars['Int'] 50 | name: Scalars['String'] 51 | introduction: Scalars['String'] 52 | slug: Scalars['String'] 53 | } 54 | 55 | export type PostsConnection = { 56 | __typename?: 'PostsConnection' 57 | data: Array 58 | hasOlder: Scalars['Boolean'] 59 | hasNewer: Scalars['Boolean'] 60 | total: Scalars['Int'] 61 | } 62 | 63 | export type Post = { 64 | __typename?: 'Post' 65 | id: Scalars['Int'] 66 | slug: Scalars['String'] 67 | content: Scalars['String'] 68 | title: Scalars['String'] 69 | createdAt: Scalars['DateTime'] 70 | updatedAt: Scalars['DateTime'] 71 | cover?: Maybe 72 | date: Scalars['String'] 73 | tags: Array 74 | } 75 | 76 | export type Tag = { 77 | __typename?: 'Tag' 78 | id: Scalars['Int'] 79 | name: Scalars['String'] 80 | slug: Scalars['String'] 81 | } 82 | 83 | export type Mutation = { 84 | __typename?: 'Mutation' 85 | createBlog: Blog 86 | updateBlog: Blog 87 | setLastActiveBlog: Scalars['Boolean'] 88 | createPost: Post 89 | updatePost: Post 90 | deletePost: Scalars['Boolean'] 91 | likePost: LikePostResult 92 | } 93 | 94 | export type MutationCreateBlogArgs = { 95 | name: Scalars['String'] 96 | slug: Scalars['String'] 97 | } 98 | 99 | export type MutationUpdateBlogArgs = { 100 | id: Scalars['Int'] 101 | slug: Scalars['String'] 102 | name: Scalars['String'] 103 | introduction: Scalars['String'] 104 | } 105 | 106 | export type MutationSetLastActiveBlogArgs = { 107 | id: Scalars['Int'] 108 | } 109 | 110 | export type MutationCreatePostArgs = { 111 | title: Scalars['String'] 112 | content: Scalars['String'] 113 | blogSlug: Scalars['String'] 114 | slug: Scalars['String'] 115 | tags: Scalars['String'] 116 | cover: Scalars['String'] 117 | } 118 | 119 | export type MutationUpdatePostArgs = { 120 | id: Scalars['Int'] 121 | title: Scalars['String'] 122 | content: Scalars['String'] 123 | tags: Scalars['String'] 124 | slug: Scalars['String'] 125 | cover: Scalars['String'] 126 | } 127 | 128 | export type MutationDeletePostArgs = { 129 | id: Scalars['Int'] 130 | } 131 | 132 | export type MutationLikePostArgs = { 133 | postId: Scalars['Int'] 134 | } 135 | 136 | export type LikePostResult = { 137 | __typename?: 'LikePostResult' 138 | likesCount: Scalars['Int'] 139 | isLiked: Scalars['Boolean'] 140 | } 141 | 142 | export type CreateBlogMutationVariables = Exact<{ 143 | name: Scalars['String'] 144 | slug: Scalars['String'] 145 | }> 146 | 147 | export type CreateBlogMutation = { __typename?: 'Mutation' } & { 148 | createBlog: { __typename?: 'Blog' } & Pick 149 | } 150 | 151 | export type CreatePostMutationVariables = Exact<{ 152 | title: Scalars['String'] 153 | content: Scalars['String'] 154 | slug: Scalars['String'] 155 | tags: Scalars['String'] 156 | blogSlug: Scalars['String'] 157 | cover: Scalars['String'] 158 | }> 159 | 160 | export type CreatePostMutation = { __typename?: 'Mutation' } & { 161 | createPost: { __typename?: 'Post' } & Pick 162 | } 163 | 164 | export type DeletePostMutationVariables = Exact<{ 165 | id: Scalars['Int'] 166 | }> 167 | 168 | export type DeletePostMutation = { __typename?: 'Mutation' } & Pick< 169 | Mutation, 170 | 'deletePost' 171 | > 172 | 173 | export type GetBlogForDashboardQueryVariables = Exact<{ 174 | slug: Scalars['String'] 175 | }> 176 | 177 | export type GetBlogForDashboardQuery = { __typename?: 'Query' } & { 178 | blog: { __typename?: 'Blog' } & Pick 179 | } 180 | 181 | export type GetMyBlogsQueryVariables = Exact<{ [key: string]: never }> 182 | 183 | export type GetMyBlogsQuery = { __typename?: 'Query' } & { 184 | blogs: Array<{ __typename?: 'Blog' } & Pick> 185 | } 186 | 187 | export type GetPostForEditQueryVariables = Exact<{ 188 | id: Scalars['Int'] 189 | }> 190 | 191 | export type GetPostForEditQuery = { __typename?: 'Query' } & { 192 | post: { __typename?: 'Post' } & Pick< 193 | Post, 194 | 'id' | 'title' | 'content' | 'slug' | 'cover' 195 | > & { tags: Array<{ __typename?: 'Tag' } & Pick> } 196 | } 197 | 198 | export type GetPostsForDashboardQueryVariables = Exact<{ 199 | blogSlug: Scalars['String'] 200 | page: Scalars['Int'] 201 | limit?: Maybe 202 | tagSlug?: Maybe 203 | }> 204 | 205 | export type GetPostsForDashboardQuery = { __typename?: 'Query' } & { 206 | posts: { __typename?: 'PostsConnection' } & Pick< 207 | PostsConnection, 208 | 'hasOlder' | 'hasNewer' | 'total' 209 | > & { 210 | data: Array< 211 | { __typename?: 'Post' } & Pick 212 | > 213 | } 214 | } 215 | 216 | export type LikePostMutationVariables = Exact<{ 217 | postId: Scalars['Int'] 218 | }> 219 | 220 | export type LikePostMutation = { __typename?: 'Mutation' } & { 221 | likePost: { __typename?: 'LikePostResult' } & Pick< 222 | LikePostResult, 223 | 'likesCount' | 'isLiked' 224 | > 225 | } 226 | 227 | export type SetLastActiveBlogMutationVariables = Exact<{ 228 | id: Scalars['Int'] 229 | }> 230 | 231 | export type SetLastActiveBlogMutation = { __typename?: 'Mutation' } & Pick< 232 | Mutation, 233 | 'setLastActiveBlog' 234 | > 235 | 236 | export type UpdateBlogMutationVariables = Exact<{ 237 | id: Scalars['Int'] 238 | name: Scalars['String'] 239 | slug: Scalars['String'] 240 | introduction: Scalars['String'] 241 | }> 242 | 243 | export type UpdateBlogMutation = { __typename?: 'Mutation' } & { 244 | updateBlog: { __typename?: 'Blog' } & Pick 245 | } 246 | 247 | export type UpdatePostMutationVariables = Exact<{ 248 | id: Scalars['Int'] 249 | title: Scalars['String'] 250 | content: Scalars['String'] 251 | slug: Scalars['String'] 252 | tags: Scalars['String'] 253 | cover: Scalars['String'] 254 | }> 255 | 256 | export type UpdatePostMutation = { __typename?: 'Mutation' } & { 257 | updatePost: { __typename?: 'Post' } & Pick 258 | } 259 | 260 | export const CreateBlogDocument: DocumentNode = { 261 | kind: 'Document', 262 | definitions: [ 263 | { 264 | kind: 'OperationDefinition', 265 | operation: 'mutation', 266 | name: { kind: 'Name', value: 'createBlog' }, 267 | variableDefinitions: [ 268 | { 269 | kind: 'VariableDefinition', 270 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, 271 | type: { 272 | kind: 'NonNullType', 273 | type: { 274 | kind: 'NamedType', 275 | name: { kind: 'Name', value: 'String' }, 276 | }, 277 | }, 278 | }, 279 | { 280 | kind: 'VariableDefinition', 281 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'slug' } }, 282 | type: { 283 | kind: 'NonNullType', 284 | type: { 285 | kind: 'NamedType', 286 | name: { kind: 'Name', value: 'String' }, 287 | }, 288 | }, 289 | }, 290 | ], 291 | selectionSet: { 292 | kind: 'SelectionSet', 293 | selections: [ 294 | { 295 | kind: 'Field', 296 | name: { kind: 'Name', value: 'createBlog' }, 297 | arguments: [ 298 | { 299 | kind: 'Argument', 300 | name: { kind: 'Name', value: 'name' }, 301 | value: { 302 | kind: 'Variable', 303 | name: { kind: 'Name', value: 'name' }, 304 | }, 305 | }, 306 | { 307 | kind: 'Argument', 308 | name: { kind: 'Name', value: 'slug' }, 309 | value: { 310 | kind: 'Variable', 311 | name: { kind: 'Name', value: 'slug' }, 312 | }, 313 | }, 314 | ], 315 | selectionSet: { 316 | kind: 'SelectionSet', 317 | selections: [ 318 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 319 | { kind: 'Field', name: { kind: 'Name', value: 'slug' } }, 320 | ], 321 | }, 322 | }, 323 | ], 324 | }, 325 | }, 326 | ], 327 | } 328 | 329 | export function useCreateBlogMutation() { 330 | return Urql.useMutation( 331 | CreateBlogDocument, 332 | ) 333 | } 334 | export const CreatePostDocument: DocumentNode = { 335 | kind: 'Document', 336 | definitions: [ 337 | { 338 | kind: 'OperationDefinition', 339 | operation: 'mutation', 340 | name: { kind: 'Name', value: 'createPost' }, 341 | variableDefinitions: [ 342 | { 343 | kind: 'VariableDefinition', 344 | variable: { 345 | kind: 'Variable', 346 | name: { kind: 'Name', value: 'title' }, 347 | }, 348 | type: { 349 | kind: 'NonNullType', 350 | type: { 351 | kind: 'NamedType', 352 | name: { kind: 'Name', value: 'String' }, 353 | }, 354 | }, 355 | }, 356 | { 357 | kind: 'VariableDefinition', 358 | variable: { 359 | kind: 'Variable', 360 | name: { kind: 'Name', value: 'content' }, 361 | }, 362 | type: { 363 | kind: 'NonNullType', 364 | type: { 365 | kind: 'NamedType', 366 | name: { kind: 'Name', value: 'String' }, 367 | }, 368 | }, 369 | }, 370 | { 371 | kind: 'VariableDefinition', 372 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'slug' } }, 373 | type: { 374 | kind: 'NonNullType', 375 | type: { 376 | kind: 'NamedType', 377 | name: { kind: 'Name', value: 'String' }, 378 | }, 379 | }, 380 | }, 381 | { 382 | kind: 'VariableDefinition', 383 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'tags' } }, 384 | type: { 385 | kind: 'NonNullType', 386 | type: { 387 | kind: 'NamedType', 388 | name: { kind: 'Name', value: 'String' }, 389 | }, 390 | }, 391 | }, 392 | { 393 | kind: 'VariableDefinition', 394 | variable: { 395 | kind: 'Variable', 396 | name: { kind: 'Name', value: 'blogSlug' }, 397 | }, 398 | type: { 399 | kind: 'NonNullType', 400 | type: { 401 | kind: 'NamedType', 402 | name: { kind: 'Name', value: 'String' }, 403 | }, 404 | }, 405 | }, 406 | { 407 | kind: 'VariableDefinition', 408 | variable: { 409 | kind: 'Variable', 410 | name: { kind: 'Name', value: 'cover' }, 411 | }, 412 | type: { 413 | kind: 'NonNullType', 414 | type: { 415 | kind: 'NamedType', 416 | name: { kind: 'Name', value: 'String' }, 417 | }, 418 | }, 419 | }, 420 | ], 421 | selectionSet: { 422 | kind: 'SelectionSet', 423 | selections: [ 424 | { 425 | kind: 'Field', 426 | name: { kind: 'Name', value: 'createPost' }, 427 | arguments: [ 428 | { 429 | kind: 'Argument', 430 | name: { kind: 'Name', value: 'title' }, 431 | value: { 432 | kind: 'Variable', 433 | name: { kind: 'Name', value: 'title' }, 434 | }, 435 | }, 436 | { 437 | kind: 'Argument', 438 | name: { kind: 'Name', value: 'content' }, 439 | value: { 440 | kind: 'Variable', 441 | name: { kind: 'Name', value: 'content' }, 442 | }, 443 | }, 444 | { 445 | kind: 'Argument', 446 | name: { kind: 'Name', value: 'slug' }, 447 | value: { 448 | kind: 'Variable', 449 | name: { kind: 'Name', value: 'slug' }, 450 | }, 451 | }, 452 | { 453 | kind: 'Argument', 454 | name: { kind: 'Name', value: 'tags' }, 455 | value: { 456 | kind: 'Variable', 457 | name: { kind: 'Name', value: 'tags' }, 458 | }, 459 | }, 460 | { 461 | kind: 'Argument', 462 | name: { kind: 'Name', value: 'blogSlug' }, 463 | value: { 464 | kind: 'Variable', 465 | name: { kind: 'Name', value: 'blogSlug' }, 466 | }, 467 | }, 468 | { 469 | kind: 'Argument', 470 | name: { kind: 'Name', value: 'cover' }, 471 | value: { 472 | kind: 'Variable', 473 | name: { kind: 'Name', value: 'cover' }, 474 | }, 475 | }, 476 | ], 477 | selectionSet: { 478 | kind: 'SelectionSet', 479 | selections: [ 480 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 481 | { kind: 'Field', name: { kind: 'Name', value: 'slug' } }, 482 | ], 483 | }, 484 | }, 485 | ], 486 | }, 487 | }, 488 | ], 489 | } 490 | 491 | export function useCreatePostMutation() { 492 | return Urql.useMutation( 493 | CreatePostDocument, 494 | ) 495 | } 496 | export const DeletePostDocument: DocumentNode = { 497 | kind: 'Document', 498 | definitions: [ 499 | { 500 | kind: 'OperationDefinition', 501 | operation: 'mutation', 502 | name: { kind: 'Name', value: 'deletePost' }, 503 | variableDefinitions: [ 504 | { 505 | kind: 'VariableDefinition', 506 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 507 | type: { 508 | kind: 'NonNullType', 509 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 510 | }, 511 | }, 512 | ], 513 | selectionSet: { 514 | kind: 'SelectionSet', 515 | selections: [ 516 | { 517 | kind: 'Field', 518 | name: { kind: 'Name', value: 'deletePost' }, 519 | arguments: [ 520 | { 521 | kind: 'Argument', 522 | name: { kind: 'Name', value: 'id' }, 523 | value: { 524 | kind: 'Variable', 525 | name: { kind: 'Name', value: 'id' }, 526 | }, 527 | }, 528 | ], 529 | }, 530 | ], 531 | }, 532 | }, 533 | ], 534 | } 535 | 536 | export function useDeletePostMutation() { 537 | return Urql.useMutation( 538 | DeletePostDocument, 539 | ) 540 | } 541 | export const GetBlogForDashboardDocument: DocumentNode = { 542 | kind: 'Document', 543 | definitions: [ 544 | { 545 | kind: 'OperationDefinition', 546 | operation: 'query', 547 | name: { kind: 'Name', value: 'getBlogForDashboard' }, 548 | variableDefinitions: [ 549 | { 550 | kind: 'VariableDefinition', 551 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'slug' } }, 552 | type: { 553 | kind: 'NonNullType', 554 | type: { 555 | kind: 'NamedType', 556 | name: { kind: 'Name', value: 'String' }, 557 | }, 558 | }, 559 | }, 560 | ], 561 | selectionSet: { 562 | kind: 'SelectionSet', 563 | selections: [ 564 | { 565 | kind: 'Field', 566 | name: { kind: 'Name', value: 'blog' }, 567 | arguments: [ 568 | { 569 | kind: 'Argument', 570 | name: { kind: 'Name', value: 'slug' }, 571 | value: { 572 | kind: 'Variable', 573 | name: { kind: 'Name', value: 'slug' }, 574 | }, 575 | }, 576 | ], 577 | selectionSet: { 578 | kind: 'SelectionSet', 579 | selections: [ 580 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 581 | { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 582 | ], 583 | }, 584 | }, 585 | ], 586 | }, 587 | }, 588 | ], 589 | } 590 | 591 | export function useGetBlogForDashboardQuery( 592 | options: Omit< 593 | Urql.UseQueryArgs, 594 | 'query' 595 | > = {}, 596 | ) { 597 | return Urql.useQuery({ 598 | query: GetBlogForDashboardDocument, 599 | ...options, 600 | }) 601 | } 602 | export const GetMyBlogsDocument: DocumentNode = { 603 | kind: 'Document', 604 | definitions: [ 605 | { 606 | kind: 'OperationDefinition', 607 | operation: 'query', 608 | name: { kind: 'Name', value: 'getMyBlogs' }, 609 | selectionSet: { 610 | kind: 'SelectionSet', 611 | selections: [ 612 | { 613 | kind: 'Field', 614 | name: { kind: 'Name', value: 'blogs' }, 615 | selectionSet: { 616 | kind: 'SelectionSet', 617 | selections: [ 618 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 619 | { kind: 'Field', name: { kind: 'Name', value: 'slug' } }, 620 | { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 621 | ], 622 | }, 623 | }, 624 | ], 625 | }, 626 | }, 627 | ], 628 | } 629 | 630 | export function useGetMyBlogsQuery( 631 | options: Omit, 'query'> = {}, 632 | ) { 633 | return Urql.useQuery({ 634 | query: GetMyBlogsDocument, 635 | ...options, 636 | }) 637 | } 638 | export const GetPostForEditDocument: DocumentNode = { 639 | kind: 'Document', 640 | definitions: [ 641 | { 642 | kind: 'OperationDefinition', 643 | operation: 'query', 644 | name: { kind: 'Name', value: 'getPostForEdit' }, 645 | variableDefinitions: [ 646 | { 647 | kind: 'VariableDefinition', 648 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 649 | type: { 650 | kind: 'NonNullType', 651 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 652 | }, 653 | }, 654 | ], 655 | selectionSet: { 656 | kind: 'SelectionSet', 657 | selections: [ 658 | { 659 | kind: 'Field', 660 | name: { kind: 'Name', value: 'post' }, 661 | arguments: [ 662 | { 663 | kind: 'Argument', 664 | name: { kind: 'Name', value: 'id' }, 665 | value: { 666 | kind: 'Variable', 667 | name: { kind: 'Name', value: 'id' }, 668 | }, 669 | }, 670 | ], 671 | selectionSet: { 672 | kind: 'SelectionSet', 673 | selections: [ 674 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 675 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 676 | { kind: 'Field', name: { kind: 'Name', value: 'content' } }, 677 | { kind: 'Field', name: { kind: 'Name', value: 'slug' } }, 678 | { kind: 'Field', name: { kind: 'Name', value: 'cover' } }, 679 | { 680 | kind: 'Field', 681 | name: { kind: 'Name', value: 'tags' }, 682 | selectionSet: { 683 | kind: 'SelectionSet', 684 | selections: [ 685 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 686 | { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 687 | ], 688 | }, 689 | }, 690 | ], 691 | }, 692 | }, 693 | ], 694 | }, 695 | }, 696 | ], 697 | } 698 | 699 | export function useGetPostForEditQuery( 700 | options: Omit, 'query'> = {}, 701 | ) { 702 | return Urql.useQuery({ 703 | query: GetPostForEditDocument, 704 | ...options, 705 | }) 706 | } 707 | export const GetPostsForDashboardDocument: DocumentNode = { 708 | kind: 'Document', 709 | definitions: [ 710 | { 711 | kind: 'OperationDefinition', 712 | operation: 'query', 713 | name: { kind: 'Name', value: 'getPostsForDashboard' }, 714 | variableDefinitions: [ 715 | { 716 | kind: 'VariableDefinition', 717 | variable: { 718 | kind: 'Variable', 719 | name: { kind: 'Name', value: 'blogSlug' }, 720 | }, 721 | type: { 722 | kind: 'NonNullType', 723 | type: { 724 | kind: 'NamedType', 725 | name: { kind: 'Name', value: 'String' }, 726 | }, 727 | }, 728 | }, 729 | { 730 | kind: 'VariableDefinition', 731 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'page' } }, 732 | type: { 733 | kind: 'NonNullType', 734 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 735 | }, 736 | }, 737 | { 738 | kind: 'VariableDefinition', 739 | variable: { 740 | kind: 'Variable', 741 | name: { kind: 'Name', value: 'limit' }, 742 | }, 743 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 744 | }, 745 | { 746 | kind: 'VariableDefinition', 747 | variable: { 748 | kind: 'Variable', 749 | name: { kind: 'Name', value: 'tagSlug' }, 750 | }, 751 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 752 | }, 753 | ], 754 | selectionSet: { 755 | kind: 'SelectionSet', 756 | selections: [ 757 | { 758 | kind: 'Field', 759 | name: { kind: 'Name', value: 'posts' }, 760 | arguments: [ 761 | { 762 | kind: 'Argument', 763 | name: { kind: 'Name', value: 'blogSlug' }, 764 | value: { 765 | kind: 'Variable', 766 | name: { kind: 'Name', value: 'blogSlug' }, 767 | }, 768 | }, 769 | { 770 | kind: 'Argument', 771 | name: { kind: 'Name', value: 'page' }, 772 | value: { 773 | kind: 'Variable', 774 | name: { kind: 'Name', value: 'page' }, 775 | }, 776 | }, 777 | { 778 | kind: 'Argument', 779 | name: { kind: 'Name', value: 'limit' }, 780 | value: { 781 | kind: 'Variable', 782 | name: { kind: 'Name', value: 'limit' }, 783 | }, 784 | }, 785 | { 786 | kind: 'Argument', 787 | name: { kind: 'Name', value: 'tagSlug' }, 788 | value: { 789 | kind: 'Variable', 790 | name: { kind: 'Name', value: 'tagSlug' }, 791 | }, 792 | }, 793 | ], 794 | selectionSet: { 795 | kind: 'SelectionSet', 796 | selections: [ 797 | { 798 | kind: 'Field', 799 | name: { kind: 'Name', value: 'data' }, 800 | selectionSet: { 801 | kind: 'SelectionSet', 802 | selections: [ 803 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 804 | { kind: 'Field', name: { kind: 'Name', value: 'slug' } }, 805 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 806 | { kind: 'Field', name: { kind: 'Name', value: 'date' } }, 807 | ], 808 | }, 809 | }, 810 | { kind: 'Field', name: { kind: 'Name', value: 'hasOlder' } }, 811 | { kind: 'Field', name: { kind: 'Name', value: 'hasNewer' } }, 812 | { kind: 'Field', name: { kind: 'Name', value: 'total' } }, 813 | ], 814 | }, 815 | }, 816 | ], 817 | }, 818 | }, 819 | ], 820 | } 821 | 822 | export function useGetPostsForDashboardQuery( 823 | options: Omit< 824 | Urql.UseQueryArgs, 825 | 'query' 826 | > = {}, 827 | ) { 828 | return Urql.useQuery({ 829 | query: GetPostsForDashboardDocument, 830 | ...options, 831 | }) 832 | } 833 | export const LikePostDocument: DocumentNode = { 834 | kind: 'Document', 835 | definitions: [ 836 | { 837 | kind: 'OperationDefinition', 838 | operation: 'mutation', 839 | name: { kind: 'Name', value: 'likePost' }, 840 | variableDefinitions: [ 841 | { 842 | kind: 'VariableDefinition', 843 | variable: { 844 | kind: 'Variable', 845 | name: { kind: 'Name', value: 'postId' }, 846 | }, 847 | type: { 848 | kind: 'NonNullType', 849 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 850 | }, 851 | }, 852 | ], 853 | selectionSet: { 854 | kind: 'SelectionSet', 855 | selections: [ 856 | { 857 | kind: 'Field', 858 | name: { kind: 'Name', value: 'likePost' }, 859 | arguments: [ 860 | { 861 | kind: 'Argument', 862 | name: { kind: 'Name', value: 'postId' }, 863 | value: { 864 | kind: 'Variable', 865 | name: { kind: 'Name', value: 'postId' }, 866 | }, 867 | }, 868 | ], 869 | selectionSet: { 870 | kind: 'SelectionSet', 871 | selections: [ 872 | { kind: 'Field', name: { kind: 'Name', value: 'likesCount' } }, 873 | { kind: 'Field', name: { kind: 'Name', value: 'isLiked' } }, 874 | ], 875 | }, 876 | }, 877 | ], 878 | }, 879 | }, 880 | ], 881 | } 882 | 883 | export function useLikePostMutation() { 884 | return Urql.useMutation( 885 | LikePostDocument, 886 | ) 887 | } 888 | export const SetLastActiveBlogDocument: DocumentNode = { 889 | kind: 'Document', 890 | definitions: [ 891 | { 892 | kind: 'OperationDefinition', 893 | operation: 'mutation', 894 | name: { kind: 'Name', value: 'setLastActiveBlog' }, 895 | variableDefinitions: [ 896 | { 897 | kind: 'VariableDefinition', 898 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 899 | type: { 900 | kind: 'NonNullType', 901 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 902 | }, 903 | }, 904 | ], 905 | selectionSet: { 906 | kind: 'SelectionSet', 907 | selections: [ 908 | { 909 | kind: 'Field', 910 | name: { kind: 'Name', value: 'setLastActiveBlog' }, 911 | arguments: [ 912 | { 913 | kind: 'Argument', 914 | name: { kind: 'Name', value: 'id' }, 915 | value: { 916 | kind: 'Variable', 917 | name: { kind: 'Name', value: 'id' }, 918 | }, 919 | }, 920 | ], 921 | }, 922 | ], 923 | }, 924 | }, 925 | ], 926 | } 927 | 928 | export function useSetLastActiveBlogMutation() { 929 | return Urql.useMutation< 930 | SetLastActiveBlogMutation, 931 | SetLastActiveBlogMutationVariables 932 | >(SetLastActiveBlogDocument) 933 | } 934 | export const UpdateBlogDocument: DocumentNode = { 935 | kind: 'Document', 936 | definitions: [ 937 | { 938 | kind: 'OperationDefinition', 939 | operation: 'mutation', 940 | name: { kind: 'Name', value: 'updateBlog' }, 941 | variableDefinitions: [ 942 | { 943 | kind: 'VariableDefinition', 944 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 945 | type: { 946 | kind: 'NonNullType', 947 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 948 | }, 949 | }, 950 | { 951 | kind: 'VariableDefinition', 952 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, 953 | type: { 954 | kind: 'NonNullType', 955 | type: { 956 | kind: 'NamedType', 957 | name: { kind: 'Name', value: 'String' }, 958 | }, 959 | }, 960 | }, 961 | { 962 | kind: 'VariableDefinition', 963 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'slug' } }, 964 | type: { 965 | kind: 'NonNullType', 966 | type: { 967 | kind: 'NamedType', 968 | name: { kind: 'Name', value: 'String' }, 969 | }, 970 | }, 971 | }, 972 | { 973 | kind: 'VariableDefinition', 974 | variable: { 975 | kind: 'Variable', 976 | name: { kind: 'Name', value: 'introduction' }, 977 | }, 978 | type: { 979 | kind: 'NonNullType', 980 | type: { 981 | kind: 'NamedType', 982 | name: { kind: 'Name', value: 'String' }, 983 | }, 984 | }, 985 | }, 986 | ], 987 | selectionSet: { 988 | kind: 'SelectionSet', 989 | selections: [ 990 | { 991 | kind: 'Field', 992 | name: { kind: 'Name', value: 'updateBlog' }, 993 | arguments: [ 994 | { 995 | kind: 'Argument', 996 | name: { kind: 'Name', value: 'id' }, 997 | value: { 998 | kind: 'Variable', 999 | name: { kind: 'Name', value: 'id' }, 1000 | }, 1001 | }, 1002 | { 1003 | kind: 'Argument', 1004 | name: { kind: 'Name', value: 'name' }, 1005 | value: { 1006 | kind: 'Variable', 1007 | name: { kind: 'Name', value: 'name' }, 1008 | }, 1009 | }, 1010 | { 1011 | kind: 'Argument', 1012 | name: { kind: 'Name', value: 'slug' }, 1013 | value: { 1014 | kind: 'Variable', 1015 | name: { kind: 'Name', value: 'slug' }, 1016 | }, 1017 | }, 1018 | { 1019 | kind: 'Argument', 1020 | name: { kind: 'Name', value: 'introduction' }, 1021 | value: { 1022 | kind: 'Variable', 1023 | name: { kind: 'Name', value: 'introduction' }, 1024 | }, 1025 | }, 1026 | ], 1027 | selectionSet: { 1028 | kind: 'SelectionSet', 1029 | selections: [ 1030 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 1031 | ], 1032 | }, 1033 | }, 1034 | ], 1035 | }, 1036 | }, 1037 | ], 1038 | } 1039 | 1040 | export function useUpdateBlogMutation() { 1041 | return Urql.useMutation( 1042 | UpdateBlogDocument, 1043 | ) 1044 | } 1045 | export const UpdatePostDocument: DocumentNode = { 1046 | kind: 'Document', 1047 | definitions: [ 1048 | { 1049 | kind: 'OperationDefinition', 1050 | operation: 'mutation', 1051 | name: { kind: 'Name', value: 'updatePost' }, 1052 | variableDefinitions: [ 1053 | { 1054 | kind: 'VariableDefinition', 1055 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 1056 | type: { 1057 | kind: 'NonNullType', 1058 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 1059 | }, 1060 | }, 1061 | { 1062 | kind: 'VariableDefinition', 1063 | variable: { 1064 | kind: 'Variable', 1065 | name: { kind: 'Name', value: 'title' }, 1066 | }, 1067 | type: { 1068 | kind: 'NonNullType', 1069 | type: { 1070 | kind: 'NamedType', 1071 | name: { kind: 'Name', value: 'String' }, 1072 | }, 1073 | }, 1074 | }, 1075 | { 1076 | kind: 'VariableDefinition', 1077 | variable: { 1078 | kind: 'Variable', 1079 | name: { kind: 'Name', value: 'content' }, 1080 | }, 1081 | type: { 1082 | kind: 'NonNullType', 1083 | type: { 1084 | kind: 'NamedType', 1085 | name: { kind: 'Name', value: 'String' }, 1086 | }, 1087 | }, 1088 | }, 1089 | { 1090 | kind: 'VariableDefinition', 1091 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'slug' } }, 1092 | type: { 1093 | kind: 'NonNullType', 1094 | type: { 1095 | kind: 'NamedType', 1096 | name: { kind: 'Name', value: 'String' }, 1097 | }, 1098 | }, 1099 | }, 1100 | { 1101 | kind: 'VariableDefinition', 1102 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'tags' } }, 1103 | type: { 1104 | kind: 'NonNullType', 1105 | type: { 1106 | kind: 'NamedType', 1107 | name: { kind: 'Name', value: 'String' }, 1108 | }, 1109 | }, 1110 | }, 1111 | { 1112 | kind: 'VariableDefinition', 1113 | variable: { 1114 | kind: 'Variable', 1115 | name: { kind: 'Name', value: 'cover' }, 1116 | }, 1117 | type: { 1118 | kind: 'NonNullType', 1119 | type: { 1120 | kind: 'NamedType', 1121 | name: { kind: 'Name', value: 'String' }, 1122 | }, 1123 | }, 1124 | }, 1125 | ], 1126 | selectionSet: { 1127 | kind: 'SelectionSet', 1128 | selections: [ 1129 | { 1130 | kind: 'Field', 1131 | name: { kind: 'Name', value: 'updatePost' }, 1132 | arguments: [ 1133 | { 1134 | kind: 'Argument', 1135 | name: { kind: 'Name', value: 'id' }, 1136 | value: { 1137 | kind: 'Variable', 1138 | name: { kind: 'Name', value: 'id' }, 1139 | }, 1140 | }, 1141 | { 1142 | kind: 'Argument', 1143 | name: { kind: 'Name', value: 'title' }, 1144 | value: { 1145 | kind: 'Variable', 1146 | name: { kind: 'Name', value: 'title' }, 1147 | }, 1148 | }, 1149 | { 1150 | kind: 'Argument', 1151 | name: { kind: 'Name', value: 'content' }, 1152 | value: { 1153 | kind: 'Variable', 1154 | name: { kind: 'Name', value: 'content' }, 1155 | }, 1156 | }, 1157 | { 1158 | kind: 'Argument', 1159 | name: { kind: 'Name', value: 'tags' }, 1160 | value: { 1161 | kind: 'Variable', 1162 | name: { kind: 'Name', value: 'tags' }, 1163 | }, 1164 | }, 1165 | { 1166 | kind: 'Argument', 1167 | name: { kind: 'Name', value: 'slug' }, 1168 | value: { 1169 | kind: 'Variable', 1170 | name: { kind: 'Name', value: 'slug' }, 1171 | }, 1172 | }, 1173 | { 1174 | kind: 'Argument', 1175 | name: { kind: 'Name', value: 'cover' }, 1176 | value: { 1177 | kind: 'Variable', 1178 | name: { kind: 'Name', value: 'cover' }, 1179 | }, 1180 | }, 1181 | ], 1182 | selectionSet: { 1183 | kind: 'SelectionSet', 1184 | selections: [ 1185 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 1186 | { kind: 'Field', name: { kind: 'Name', value: 'slug' } }, 1187 | ], 1188 | }, 1189 | }, 1190 | ], 1191 | }, 1192 | }, 1193 | ], 1194 | } 1195 | 1196 | export function useUpdatePostMutation() { 1197 | return Urql.useMutation( 1198 | UpdatePostDocument, 1199 | ) 1200 | } 1201 | --------------------------------------------------------------------------------