├── public
├── hero.png
├── og.png
├── banner.png
├── fonts
│ ├── arial.ttf
│ └── arial_bold.ttf
└── logo.svg
├── .gitignore
├── composables
├── url.ts
├── subdomain.ts
├── dashboard.ts
└── head.ts
├── pages
├── dashboard
│ ├── index.vue
│ ├── posts.vue
│ ├── profile.vue
│ └── domain.vue
├── user
│ ├── [siteId]
│ │ ├── home.vue
│ │ ├── index.vue
│ │ └── [slug].vue
│ └── [siteId].vue
├── login.vue
├── dashboard.vue
├── posts.vue
├── index.vue
└── edit
│ └── [id].vue
├── tsconfig.json
├── server
├── api
│ ├── request-delegation.ts
│ ├── user-validation.post.ts
│ ├── delete-domain.post.ts
│ ├── _supabase
│ │ └── session.post.ts
│ ├── check-domain.post.ts
│ └── add-domain.post.ts
├── middleware
│ ├── subdomain.ts
│ └── login.ts
└── routes
│ └── og
│ └── [slug].ts
├── utils
├── tiptap
│ ├── link.ts
│ ├── placeholder.ts
│ ├── commands.ts
│ ├── code.ts
│ ├── hardbreak.ts
│ ├── upload.ts
│ ├── iframe.ts
│ ├── move.ts
│ └── suggestion.ts
├── functions.ts
└── types.ts
├── middleware
└── auth.ts
├── .env.example
├── modules
└── og.ts
├── components
├── Loader.vue
├── Modal
│ └── Login.vue
├── Logo.vue
├── Drawer.vue
├── Modal.vue
├── Toggle.vue
├── Drawer
│ └── EditPost.vue
├── Tiptap
│ ├── ModalIframe.vue
│ ├── ModalImage.vue
│ ├── CommandList.vue
│ └── Bubble.vue
├── Button.vue
├── Footer.vue
├── Upload.vue
├── TiptapHeading.vue
├── Post
│ └── Card.vue
├── Tiptap.vue
├── TagsInput.vue
└── Command.vue
├── app.vue
├── plugins
└── umami.client.ts
├── layouts
├── default.vue
└── user.vue
├── license.md
├── app
└── router.options.ts
├── package.json
├── nuxt.config.ts
├── assets
└── main.css
└── README.md
/public/hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zernonia/keypress/HEAD/public/hero.png
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zernonia/keypress/HEAD/public/og.png
--------------------------------------------------------------------------------
/public/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zernonia/keypress/HEAD/public/banner.png
--------------------------------------------------------------------------------
/public/fonts/arial.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zernonia/keypress/HEAD/public/fonts/arial.ttf
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log*
3 | .nuxt
4 | .nitro
5 | .cache
6 | .output
7 | .env
8 | dist
9 | .vercel
--------------------------------------------------------------------------------
/public/fonts/arial_bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zernonia/keypress/HEAD/public/fonts/arial_bold.ttf
--------------------------------------------------------------------------------
/composables/url.ts:
--------------------------------------------------------------------------------
1 | export const useUrl = () => (process.dev ? "http://localhost:3000" : "https://keypress.blog")
2 |
--------------------------------------------------------------------------------
/pages/dashboard/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://v3.nuxtjs.org/concepts/typescript
3 | "extends": "./.nuxt/tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------
/server/api/request-delegation.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler((event) => {
2 | return 'Hello add-domain'
3 | })
4 |
--------------------------------------------------------------------------------
/pages/user/[siteId]/home.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | Home for user {{ params.slug }}
7 |
8 |
--------------------------------------------------------------------------------
/utils/tiptap/link.ts:
--------------------------------------------------------------------------------
1 | // 1. Import the extension
2 | import Link from "@tiptap/extension-link"
3 |
4 | // 2. Overwrite the keyboard shortcuts
5 | export default Link.extend({
6 | exitable: true,
7 | })
8 |
--------------------------------------------------------------------------------
/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtRouteMiddleware((to, from) => {
2 | const user = useSupabaseUser()
3 | if (!user.value && to.path !== "/write") {
4 | return navigateTo("/login")
5 | }
6 | })
7 |
--------------------------------------------------------------------------------
/composables/subdomain.ts:
--------------------------------------------------------------------------------
1 | import type { Profiles } from "~~/utils/types"
2 |
3 | export const useSubdomain = () => useState("subdomain", () => null)
4 | export const useSubdomainProfile = () => useState("subdomain-profile", () => null)
5 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | SUPABASE_URL=
2 | SUPABASE_KEY=
3 | AUTH_BEARER_TOKEN=
4 | VERCEL_PROJECT_ID=
5 | GITHUB_PROVIDER_TOKEN=
--------------------------------------------------------------------------------
/modules/og.ts:
--------------------------------------------------------------------------------
1 | import { defineNuxtModule } from "@nuxt/kit"
2 | import { copyFile, cp } from "fs/promises"
3 |
4 | export default defineNuxtModule({
5 | setup(options, nuxt) {
6 | nuxt.hook("close", async () => {
7 | await cp("public/fonts", ".vercel/output/functions/__nitro.func/public/fonts", { recursive: true })
8 | })
9 | },
10 | })
11 |
--------------------------------------------------------------------------------
/components/Loader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Tips:
6 |
⌘ + K to search for commands
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/server/api/user-validation.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const { access_token } = await useBody(event)
3 | const hostname = event.req.headers.host
4 |
5 | // validate this token
6 | // setCookie(event, "sb-access-token", access_token, {
7 | // maxAge: 60 * 60 * 8 ?? 0,
8 | // path: "/",
9 | // sameSite: "lax",
10 | // })
11 | return "auth cookie set"
12 | })
13 |
--------------------------------------------------------------------------------
/utils/functions.ts:
--------------------------------------------------------------------------------
1 | import { Posts } from "./types"
2 |
3 | export const constructUrl = (post: Posts, subdomain = false) => {
4 | if (subdomain) return `/${post.slug}`
5 | if (process.dev) return `http://${post?.profiles?.username}.localhost:3000/${post.slug}`
6 | else {
7 | if (post?.profiles?.domains?.active) return `https://${post.profiles.domains.url}/${post.slug}`
8 | else return `https://${post?.profiles?.username}.keypress.blog/${post.slug}`
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/pages/login.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
Login
14 |
15 | Login with GitHub
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/server/middleware/subdomain.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(({ req, res, context }) => {
2 | const hostname = req.headers.host || "keypress.blog"
3 |
4 | const mainDomain = ["localhost:3000", "keypress.blog"]
5 |
6 | if (!mainDomain.includes(hostname)) {
7 | const currentHost =
8 | process.env.NODE_ENV === "production" && process.env.VERCEL === "1"
9 | ? hostname.replace(`.keypress.blog`, "")
10 | : hostname.replace(`.localhost:3000`, "")
11 |
12 | console.log({ currentHost })
13 | context.subdomain = currentHost
14 | }
15 | })
16 |
--------------------------------------------------------------------------------
/utils/tiptap/placeholder.ts:
--------------------------------------------------------------------------------
1 | // 1. Import the extension
2 | import Placeholder from "@tiptap/extension-placeholder"
3 | import { NodeSelection, TextSelection } from "prosemirror-state"
4 |
5 | // 2. Overwrite the keyboard shortcuts
6 | export default Placeholder.extend({
7 | addOptions() {
8 | return {
9 | ...this.parent?.(),
10 | placeholder: ({ node, editor }) => {
11 | const selection = editor.state.selection as NodeSelection
12 | if (selection instanceof TextSelection) {
13 | return " Type '/' for commands"
14 | }
15 | },
16 | includeChildren: true,
17 | }
18 | },
19 | })
20 |
--------------------------------------------------------------------------------
/components/Modal/Login.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
Login
18 | Login with GitHub
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/utils/tiptap/commands.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core"
2 | import Suggestion from "@tiptap/suggestion"
3 |
4 | // ref: https://tiptap.dev/experiments/commands
5 | export default Extension.create({
6 | name: "commands",
7 |
8 | addOptions() {
9 | return {
10 | suggestion: {
11 | char: "/",
12 | command: ({ editor, range, props }) => {
13 | props.command({ editor, range })
14 | },
15 | },
16 | }
17 | },
18 |
19 | addProseMirrorPlugins() {
20 | return [
21 | Suggestion({
22 | editor: this.editor,
23 | startOfLine: true,
24 | ...this.options.suggestion,
25 | }),
26 | ]
27 | },
28 | })
29 |
--------------------------------------------------------------------------------
/pages/dashboard/posts.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
{{ post.title }}
14 | {{ format(new Date(post.created_at), "MMM d") }}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/plugins/umami.client.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtPlugin(() => {
2 | const cfg = useRuntimeConfig()
3 |
4 | const moduleOptions = {
5 | scriptUrl: "https://umami-zernonia.vercel.app/script.js",
6 | websiteId: cfg.public.UMAMI_WEBSITE_ID,
7 | }
8 | const options = { ...moduleOptions }
9 |
10 | if (moduleOptions.websiteId) {
11 | loadScript(options)
12 | }
13 | })
14 |
15 | function loadScript(options: any) {
16 | const head = document.head || document.getElementsByTagName("head")[0]
17 | const script = document.createElement("script")
18 |
19 | script.async = true
20 | script.defer = true
21 | script.setAttribute('data-website-id', options.websiteId);
22 |
23 | script.src = options.scriptUrl
24 |
25 | head.appendChild(script)
26 | }
27 |
--------------------------------------------------------------------------------
/pages/dashboard/profile.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 | Save ⌘S
19 |
20 |
21 |
22 | Name :
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
--------------------------------------------------------------------------------
/pages/user/[siteId].vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 |
Page not found
29 |
30 |
31 |
--------------------------------------------------------------------------------
/components/Logo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/server/middleware/login.ts:
--------------------------------------------------------------------------------
1 | import { sendRedirect, defineEventHandler } from "h3"
2 |
3 | export default defineEventHandler(async (event) => {
4 | const { req, res } = event
5 | const referrer = req.headers.referer
6 | const cookie = useCookies(event)
7 | const accessToken = cookie["sb-access-token"]
8 | const refreshToken = cookie["sb-refresh-token"]
9 | // console.log({ url: req.url, referrer, accessToken, refreshToken, cookie })
10 | // if cookie already exist in main route, then redirect with jwt
11 | if (req.url === "/login" && referrer && accessToken && refreshToken) {
12 | // redirect with same parameter as Supabase login
13 | return await sendRedirect(
14 | event,
15 | referrer +
16 | `#access_token=${accessToken}&expires_in=604800&provider_token=${process.env.GITHUB_PROVIDER_TOKEN}&refresh_token=${refreshToken}&token_type=bearer`,
17 | 302
18 | )
19 | }
20 | })
21 |
--------------------------------------------------------------------------------
/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
Login
16 |
17 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/utils/types.ts:
--------------------------------------------------------------------------------
1 | // generated from https://supabase-schema.vercel.app/
2 | export interface Profiles {
3 | id: string /* primary key */;
4 | username?: string;
5 | avatar_url?: string;
6 | name?: string;
7 | created_at?: string;
8 | subdomain?: string;
9 | domains: Domains;
10 | posts: Posts[];
11 | }
12 |
13 | export interface Domains {
14 | user_id?: string /* foreign key to profiles.id */;
15 | url: string /* primary key */;
16 | active?: boolean;
17 | created_at?: string;
18 | profiles?: Profiles;
19 | }
20 |
21 | export interface Posts {
22 | id: string /* primary key */;
23 | author_id?: string /* foreign key to profiles.id */;
24 | created_at?: string;
25 | slug?: string;
26 | title?: string;
27 | body?: string;
28 | cover_img?: string;
29 | active?: boolean;
30 | tags?: string[];
31 | profiles?: Profiles;
32 | featured?: boolean;
33 | }
34 |
35 | export interface Tags {
36 | name: string;
37 | count: number;
38 | }
39 |
--------------------------------------------------------------------------------
/components/Drawer.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
24 |
25 | Content
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/utils/tiptap/code.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core"
2 | import Code from "@tiptap/extension-code"
3 | import CodeBlock from "@tiptap/extension-code-block"
4 | import CodeBlockLowLight from "@tiptap/extension-code-block-lowlight"
5 | import { lowlight } from "lowlight"
6 |
7 | import css from "highlight.js/lib/languages/css"
8 | import js from "highlight.js/lib/languages/javascript"
9 | import ts from "highlight.js/lib/languages/typescript"
10 | import html from "highlight.js/lib/languages/xml"
11 |
12 | // ref: https://tiptap.dev/experiments/commands
13 |
14 | lowlight.registerLanguage("html", html)
15 | lowlight.registerLanguage("css", css)
16 | lowlight.registerLanguage("js", js)
17 | lowlight.registerLanguage("ts", ts)
18 |
19 | export default Extension.create({
20 | name: "code",
21 | addExtensions() {
22 | return [
23 | Code,
24 | CodeBlock,
25 | CodeBlockLowLight.configure({
26 | lowlight,
27 | }),
28 | ]
29 | },
30 | })
31 |
--------------------------------------------------------------------------------
/components/Modal.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
28 | Content
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/composables/dashboard.ts:
--------------------------------------------------------------------------------
1 | import type { Profiles } from "~~/utils/types"
2 | import type { Ref } from "vue"
3 |
4 | export const useProfile = () => useState("profile", () => null)
5 |
6 | export const useProfileSave = (payload: Ref>) => {
7 | const user = useSupabaseUser()
8 | const client = useSupabaseClient()
9 |
10 | const isSaving = ref(false)
11 |
12 | const save = async () => {
13 | // validate input here (if any)
14 | isSaving.value = true
15 |
16 | console.log("save profile settings", payload.value)
17 | const { data } = await client
18 | .from("profiles")
19 | .upsert({ ...payload.value, id: user.value?.id })
20 | .single()
21 | console.log({ data })
22 | isSaving.value = false
23 | }
24 |
25 | useMagicKeys({
26 | passive: false,
27 | onEventFired(e) {
28 | if ((e.ctrlKey || e.metaKey) && e.key === "s" && e.type === "keydown") {
29 | e.preventDefault()
30 | save()
31 | }
32 | },
33 | })
34 |
35 | return {
36 | save,
37 | isSaving,
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/components/Toggle.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
23 |
26 |
29 |
30 | Toggle
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/layouts/user.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
Login
17 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/server/api/delete-domain.post.ts:
--------------------------------------------------------------------------------
1 | // ref: https://github.com/vercel/platforms/blob/main/lib/api/domain.ts
2 |
3 | export default defineEventHandler(async (event) => {
4 | try {
5 | const { domain, user_id } = await useBody(event)
6 |
7 | if (Array.isArray(domain) || Array.isArray(user_id))
8 | createError({ statusCode: 400, statusMessage: "Bad request. Query parameters are not valid." })
9 |
10 | const data = (await $fetch(
11 | `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain}`,
12 | {
13 | method: "DELETE",
14 | headers: {
15 | Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
16 | },
17 | }
18 | )) as any
19 | console.log({ domain, data })
20 |
21 | // Domain is successfully added
22 | // await prisma.site.update({
23 | // where: {
24 | // id: siteId,
25 | // },
26 | // data: {
27 | // customDomain: domain,
28 | // },
29 | // });
30 |
31 | return data
32 | } catch (err) {
33 | return createError({ statusCode: 500, statusMessage: err })
34 | }
35 | })
36 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 zernonia
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/pages/user/[siteId]/index.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
{{ profile?.name }}'s posts
26 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/router.options.ts:
--------------------------------------------------------------------------------
1 | import type { RouterOptions } from "@nuxt/schema"
2 |
3 | // https://router.vuejs.org/api/interfaces/routeroptions.html
4 | export default {
5 | routes: (_routes) => {
6 | const { ssrContext } = useNuxtApp()
7 | const subdomain = useSubdomain()
8 | if (ssrContext?.event.context.subdomain) subdomain.value = ssrContext?.event.context.subdomain
9 |
10 | if (subdomain.value) {
11 | const userRoute = _routes.filter((i) => i.path.includes("/user/:siteId"))
12 | const userRouteMapped = userRoute.map((i) => ({
13 | ...i,
14 | path: i.path === "/user/:siteId" ? i.path.replace("/user/:siteId", "/") : i.path.replace("/user/:siteId/", "/"),
15 | }))
16 |
17 | return userRouteMapped
18 | }
19 | },
20 | scrollBehavior(to, from, savedPosition) {
21 | if (savedPosition) return savedPosition
22 | if (to.hash) {
23 | const el = document.querySelector(to.hash) as HTMLElement
24 | return { left: 0, top: (el?.offsetTop ?? 0) - 30, behavior: "smooth" }
25 | }
26 |
27 | if (to.fullPath === from.fullPath) return
28 | return { left: 0, top: 0, behavior: "smooth" }
29 | },
30 | }
31 |
--------------------------------------------------------------------------------
/utils/tiptap/hardbreak.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core"
2 |
3 | export default Extension.create({
4 | addKeyboardShortcuts() {
5 | // copied from tiptap
6 | const defaultHandler = () =>
7 | this.editor.commands.first(({ commands }) => [
8 | () => commands.newlineInCode(),
9 | () => commands.createParagraphNear(),
10 | () => commands.liftEmptyBlock(),
11 | () => commands.splitListItem("listItem"),
12 | () => commands.splitBlock(),
13 | ])
14 |
15 | const shiftEnter = () => {
16 | return this.editor.commands.first(({ commands }) => [
17 | () => commands.newlineInCode(),
18 | () => commands.createParagraphNear(),
19 | ])
20 | }
21 |
22 | const modEnter = () => {
23 | return this.editor.commands.first(({ commands }) => [
24 | () => commands.newlineInCode(),
25 |
26 | (a) => {
27 | commands.selectTextblockEnd()
28 | return commands.createParagraphNear()
29 | },
30 | ])
31 | }
32 |
33 | return {
34 | Enter: defaultHandler,
35 | "Shift-Enter": shiftEnter,
36 | "Mod-Enter": modEnter,
37 | }
38 | },
39 | })
40 |
--------------------------------------------------------------------------------
/utils/tiptap/upload.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core"
2 | import ModalImage from "~~/components/Tiptap/ModalImage.vue"
3 | import ModalIframe from "~~/components/Tiptap/ModalIframe.vue"
4 | import { createApp } from "vue"
5 |
6 | declare module "@tiptap/core" {
7 | interface Commands {
8 | upload: {
9 | openModal: (type: "image" | "iframe") => ReturnType
10 | }
11 | }
12 | }
13 |
14 | export default Extension.create({
15 | name: "upload",
16 |
17 | addCommands() {
18 | return {
19 | openModal:
20 | (type: "image" | "iframe") =>
21 | ({ commands, editor }) => {
22 | let component: typeof ModalImage
23 |
24 | switch (type) {
25 | case "image": {
26 | component = ModalImage
27 | break
28 | }
29 | case "iframe": {
30 | component = ModalIframe
31 | break
32 | }
33 | }
34 | if (!component) return
35 |
36 | const instance = createApp(component, {
37 | show: true,
38 | editor,
39 | }).mount("#modal")
40 |
41 | return !!instance
42 | },
43 | }
44 | },
45 | })
46 |
--------------------------------------------------------------------------------
/components/Drawer/EditPost.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
Settings
30 |
31 | Publish
32 |
33 |
34 |
35 | Cover image:
36 |
37 |
38 |
39 |
40 | Tags:
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/pages/dashboard.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
Dashboard
34 |
35 |
36 |
37 |
38 | Posts
39 | Profile
40 | Domain
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/server/api/_supabase/session.post.ts:
--------------------------------------------------------------------------------
1 | import { setCookie, defineEventHandler } from "h3"
2 |
3 | export default defineEventHandler(async (event) => {
4 | const body = await useBody(event)
5 | const config = useRuntimeConfig().public
6 |
7 | const cookieOptions = config.supabase.cookies
8 |
9 | const { event: signEvent, session } = body
10 |
11 | if (!event) {
12 | throw new Error("Auth event missing!")
13 | }
14 |
15 | if (signEvent === "SIGNED_IN") {
16 | if (!session) {
17 | throw new Error("Auth session missing!")
18 | }
19 | setCookie(event, `${cookieOptions.name}-access-token`, session.access_token, {
20 | domain: cookieOptions.domain,
21 | maxAge: cookieOptions.lifetime ?? 0,
22 | path: cookieOptions.path,
23 | sameSite: cookieOptions.sameSite as boolean | "lax" | "strict" | "none",
24 | })
25 | setCookie(event, `${cookieOptions.name}-refresh-token`, session.refresh_token, {
26 | domain: cookieOptions.domain,
27 | maxAge: cookieOptions.lifetime ?? 0,
28 | path: cookieOptions.path,
29 | sameSite: cookieOptions.sameSite as boolean | "lax" | "strict" | "none",
30 | })
31 | }
32 |
33 | if (signEvent === "SIGNED_OUT") {
34 | setCookie(event, `${cookieOptions.name}-access-token`, "", {
35 | maxAge: -1,
36 | path: cookieOptions.path,
37 | })
38 | }
39 |
40 | return "custom auth cookie set"
41 | })
42 |
--------------------------------------------------------------------------------
/components/Tiptap/ModalIframe.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
36 |
Add iframe
37 |
38 |
39 | URL :
40 |
41 |
42 |
43 |
44 | Cancel
45 | Save
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/components/Button.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
69 |
--------------------------------------------------------------------------------
/components/Tiptap/ModalImage.vue:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
43 |
Add image
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | Save
55 | Cancel
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/components/Footer.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
11 |
12 |
13 |
14 |
15 | Menu
16 |
17 | Write
18 |
19 |
20 | All Posts
21 |
22 |
23 | Login
24 |
25 |
26 |
27 |
28 | Open Source
29 |
30 | GitHub
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/components/Upload.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
42 |
43 |
44 |
Press 'Enter' to upload image
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/composables/head.ts:
--------------------------------------------------------------------------------
1 | import { ComputedRef } from "vue"
2 |
3 | export const useCustomHead = (
4 | title?: string | ComputedRef,
5 | description?: string | ComputedRef,
6 | image?: string | ComputedRef
7 | ) => {
8 | useHead({
9 | title,
10 | meta: [
11 | {
12 | name: "description",
13 | content:
14 | description ?? "An open-source blogging platform + free custom domains. Powered by Nuxt 3, Supabase & Vercel",
15 | },
16 | { name: "twitter:card", content: "summary_large_image" },
17 | { name: "twitter:site", content: "@zernonia" },
18 | { name: "twitter:title", content: title ?? "KeyPress | Write your blog with keyboard only experience" },
19 | {
20 | name: "twitter:description",
21 | content:
22 | description ?? "An open-source blogging platform + free custom domains. Powered by Nuxt 3, Supabase & Vercel",
23 | },
24 | { name: "twitter:image", content: image ?? "https://keypress.blog/og.png" },
25 | { property: "og:type", content: "website" },
26 | { property: "og:title", content: title ?? "KeyPress | Write your blog with keyboard only experience" },
27 | { property: "og:url", content: "https://keypress.blog/" },
28 | { property: "og:image", content: image ?? "https://keypress.blog/og.png" },
29 | { property: "og:image:secure_url", content: image ?? "https://keypress.blog/og.png" },
30 | { property: "og:image:type", content: "image/png" },
31 | {
32 | property: "og:description",
33 | content:
34 | description ?? "An open-source blogging platform + free custom domains. Powered by Nuxt 3, Supabase & Vercel",
35 | },
36 | ],
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/server/api/check-domain.post.ts:
--------------------------------------------------------------------------------
1 | //ref: https://github.com/vercel/platforms/blob/main/pages/api/domain/check.ts
2 | import { serverSupabaseClient } from "#supabase/server"
3 | import type { Domains } from "~~/utils/types"
4 |
5 | export default defineEventHandler(async (event) => {
6 | try {
7 | const { domain, subdomain = false } = await useBody(event)
8 | const client = serverSupabaseClient(event)
9 |
10 | if (Array.isArray(domain))
11 | return createError({ statusCode: 400, statusMessage: "Bad request. domain parameter cannot be an array." })
12 |
13 | // if (subdomain) {
14 | // const sub = (domain as string).replace(/[^a-zA-Z0-9/-]+/g, "");
15 |
16 | // const data = await prisma.site.findUnique({
17 | // where: {
18 | // subdomain: sub,
19 | // },
20 | // });
21 |
22 | // const available = data === null && sub.length !== 0;
23 |
24 | // return res.status(200).json(available);
25 | // }
26 |
27 | const data = (await $fetch(`https://api.vercel.com/v6/domains/${domain}/config`, {
28 | headers: {
29 | Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
30 | },
31 | })) as any
32 | console.log({ domain, data })
33 |
34 | const valid = data?.configuredBy ? true : false
35 | if (valid) {
36 | const { error: domainError } = await client.from("domains").update({
37 | url: domain,
38 | active: true,
39 | })
40 | if (domainError)
41 | return createError({ statusCode: 400, statusMessage: "Bad request. domain parameter cannot be an array." })
42 | }
43 |
44 | return { valid }
45 | } catch (err) {
46 | return createError({ statusCode: 404, statusMessage: err })
47 | }
48 | })
49 |
--------------------------------------------------------------------------------
/pages/posts.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
Posts
28 |
29 |
39 |
40 | Tags
41 |
42 |
43 |
44 | {{ tag.name }}
45 | {{ tag.count }}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "build": "nuxt build",
5 | "dev": "nuxt dev",
6 | "generate": "nuxt generate",
7 | "preview": "nuxt preview",
8 | "postinstall": "nuxt prepare"
9 | },
10 | "devDependencies": {
11 | "@iconify-json/ic": "^1.1.9",
12 | "@iconify-json/mdi": "^1.1.9",
13 | "@nuxt/image-edge": "^1.0.0-27719579.87dcdf2",
14 | "@nuxtjs/supabase": "^0.1.25",
15 | "@unocss/nuxt": "^0.45.6",
16 | "@unocss/preset-icons": "^0.45.6",
17 | "@unocss/preset-typography": "^0.45.8",
18 | "@unocss/preset-uno": "^0.45.6",
19 | "@unocss/reset": "^0.45.6",
20 | "@unocss/transformer-directives": "^0.45.6",
21 | "@vueuse/core": "^9.1.0",
22 | "@vueuse/integrations": "^9.3.0",
23 | "@vueuse/nuxt": "^9.1.0",
24 | "nuxt": "3.0.0-rc.11"
25 | },
26 | "dependencies": {
27 | "@resvg/resvg-js": "^2.1.0",
28 | "@tiptap/extension-bubble-menu": "^2.0.0-beta.199",
29 | "@tiptap/extension-code-block": "^2.0.0-beta.199",
30 | "@tiptap/extension-code-block-lowlight": "^2.0.0-beta.199",
31 | "@tiptap/extension-focus": "^2.0.0-beta.199",
32 | "@tiptap/extension-image": "^2.0.0-beta.199",
33 | "@tiptap/extension-link": "^2.0.0-beta.199",
34 | "@tiptap/extension-placeholder": "^2.0.0-beta.199",
35 | "@tiptap/extension-underline": "^2.0.0-beta.199",
36 | "@tiptap/starter-kit": "^2.0.0-beta.199",
37 | "@tiptap/suggestion": "^2.0.0-beta.199",
38 | "@tiptap/vue-3": "^2.0.0-beta.199",
39 | "@vueform/multiselect": "^2.5.6",
40 | "date-fns": "^2.29.3",
41 | "focus-trap": "^7.0.0",
42 | "fuse.js": "^6.6.2",
43 | "lowlight": "^2.7.0",
44 | "prosemirror-tables": "^1.2.5",
45 | "prosemirror-utils": "^0.9.6",
46 | "satori": "^0.0.38",
47 | "slugify": "^1.6.5",
48 | "string-strip-html": "^11.6.10"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/components/Tiptap/CommandList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 | {{ item.title }}
12 | {{ item.description }}
13 |
14 |
15 |
No result
16 |
17 |
18 |
19 |
69 |
--------------------------------------------------------------------------------
/components/TiptapHeading.vue:
--------------------------------------------------------------------------------
1 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/server/api/add-domain.post.ts:
--------------------------------------------------------------------------------
1 | // ref: https://github.com/vercel/platforms/blob/main/lib/api/domain.ts
2 | import { serverSupabaseClient, serverSupabaseUser } from "#supabase/server"
3 | import type { Domains } from "~~/utils/types"
4 |
5 | export default defineEventHandler(async (event) => {
6 | try {
7 | const { domain, user_id } = await useBody(event)
8 | const user = await serverSupabaseUser(event)
9 | const client = serverSupabaseClient(event)
10 |
11 | if (Array.isArray(domain) || Array.isArray(user_id))
12 | createError({ statusCode: 400, statusMessage: "Bad request. Query parameters are not valid." })
13 |
14 | const { data: domainData } = await client.from("domains").select("*").eq("url", domain).maybeSingle()
15 | if (domainData.user_id === user.id) return true
16 |
17 | const data = (await $fetch(`https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains`, {
18 | method: "POST",
19 | headers: {
20 | Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
21 | },
22 | body: {
23 | name: domain,
24 | },
25 | })) as any
26 | console.log({ domain, data })
27 | // Domain is already owned by another team but you can request delegation to access it
28 | if (data.error?.code === "forbidden") return createError({ statusCode: 400, statusMessage: data.error.code })
29 |
30 | // Domain is already being used by a different project
31 | if (data.error?.code === "domain_taken") return createError({ statusCode: 409, statusMessage: data.error.code })
32 |
33 | const { error: domainError } = await client.from("domains").upsert({
34 | url: domain,
35 | user_id: user.id,
36 | active: false,
37 | })
38 |
39 | if (domainError) return createError({ statusCode: 400, statusMessage: domainError.message })
40 |
41 | return data
42 | } catch (err) {
43 | return createError({ statusCode: 500, statusMessage: err })
44 | }
45 | })
46 |
--------------------------------------------------------------------------------
/utils/tiptap/iframe.ts:
--------------------------------------------------------------------------------
1 | // ref: https://github.com/ueberdosis/tiptap/blob/9afadeb7fe368f95064f84424d6a3dd6cd85b43d/demos/src/Experiments/Embeds/Vue/iframe.ts
2 | import { mergeAttributes, Node } from "@tiptap/core"
3 |
4 | export interface IframeOptions {
5 | allowFullscreen: boolean
6 | HTMLAttributes: {
7 | [key: string]: any
8 | }
9 | }
10 |
11 | declare module "@tiptap/core" {
12 | interface Commands {
13 | iframe: {
14 | /**
15 | * Add an iframe
16 | */
17 | setIframe: (options: { src: string }) => ReturnType
18 | }
19 | }
20 | }
21 |
22 | export default Node.create({
23 | name: "iframe",
24 |
25 | group: "block",
26 |
27 | atom: true,
28 |
29 | addOptions() {
30 | return {
31 | allowFullscreen: true,
32 | HTMLAttributes: {
33 | class: "iframe-wrapper",
34 | },
35 | }
36 | },
37 |
38 | addAttributes() {
39 | return {
40 | src: {
41 | default: null,
42 | },
43 | frameborder: {
44 | default: 0,
45 | },
46 | allowfullscreen: {
47 | default: this.options.allowFullscreen,
48 | parseHTML: () => this.options.allowFullscreen,
49 | },
50 | }
51 | },
52 |
53 | parseHTML() {
54 | return [
55 | {
56 | tag: "iframe",
57 | },
58 | ]
59 | },
60 |
61 | renderHTML({ HTMLAttributes }) {
62 | return [
63 | "div",
64 | this.options.HTMLAttributes,
65 | ["iframe", mergeAttributes(HTMLAttributes, { frameborder: 10, tabindex: -1 })],
66 | ]
67 | },
68 |
69 | addCommands() {
70 | return {
71 | setIframe:
72 | (options: { src: string }) =>
73 | ({ tr, dispatch }) => {
74 | const { selection } = tr
75 | const node = this.type.create(options)
76 |
77 | if (dispatch) {
78 | tr.replaceRangeWith(selection.from, selection.to, node)
79 | }
80 |
81 | return true
82 | },
83 | }
84 | },
85 | })
86 |
--------------------------------------------------------------------------------
/components/Post/Card.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
21 |
22 |
23 |
24 |
25 |
{{ post.profiles.name }}
26 |
33 |
34 |
35 |
{{ post.title }}
36 |
{{ stripHtml(post.body).result.slice(0, 120) }}...
37 |
38 |
39 |
40 | {{ format(new Date(post.created_at), "MMM d") }}
41 | {{ post.tags?.[0] }}
42 |
43 |
44 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/components/Tiptap.vue:
--------------------------------------------------------------------------------
1 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
93 |
--------------------------------------------------------------------------------
/components/TagsInput.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
46 |
47 |
48 |
49 |
50 |
51 |
59 |
--------------------------------------------------------------------------------
/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import transformerDirective from "@unocss/transformer-directives"
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: ["@unocss/nuxt", "@nuxtjs/supabase", "@vueuse/nuxt", "@nuxt/image-edge", "~~/modules/og"],
6 | css: ["@unocss/reset/tailwind.css", "~~/assets/main.css"],
7 | runtimeConfig: {
8 | public: {
9 | UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
10 | },
11 | },
12 | unocss: {
13 | // presets
14 | uno: true, // enabled `@unocss/preset-uno`
15 | icons: true, // enabled `@unocss/preset-icons`,
16 | typography: {
17 | cssExtend: {
18 | h1: {
19 | "font-weight": 700,
20 | },
21 | img: {
22 | "border-radius": "1.5rem",
23 | },
24 | pre: {
25 | "border-radius": "1.5rem",
26 | background: "white !important",
27 | },
28 | iframe: {
29 | height: "400px",
30 | "border-radius": "1.5rem",
31 | },
32 | "p code": {
33 | padding: "0.25rem 0.5rem",
34 | "border-radius": "0.35rem",
35 | "background-color": "#ececec",
36 | },
37 | "code::before": {
38 | content: "''",
39 | },
40 | "code::after": {
41 | content: "''",
42 | },
43 | },
44 | },
45 | transformers: [transformerDirective({ enforce: "pre" })], // enabled `@unocss/transformer-directives`,
46 | safelist: [
47 | "ic-round-format-bold",
48 | "ic-round-format-underlined",
49 | "ic-round-format-strikethrough",
50 | "ic-round-format-italic",
51 | ],
52 | // core options
53 | shortcuts: [
54 | {
55 | btn: " text-sm md:text-base font-medium rounded-2xl py-2 px-4 transition ring-3 ring-transparent disabled:opacity-50 relative inline-flex justify-center items-center shadow-none",
56 | "btn-plain": "btn font-semibold text-gray-400 focus:text-dark-50 hover:text-dark-50",
57 | "btn-primary": "btn bg-dark-300 text-white focus:ring-gray-400 focus:shadow-xl",
58 | "btn-secondary": "btn bg-white hover:bg-gray-100 focus:ring-gray-100",
59 | "btn-danger": "btn bg-red-500 text-white hover:bg-red-600 focus:ring-red-300",
60 | },
61 | ],
62 | rules: [],
63 | },
64 | image: {
65 | domains: ["avatars0.githubusercontent.com", "avatars.githubusercontent.com/", "images.unsplash.com/"],
66 | },
67 | build: {
68 | transpile: ["@tiptap/extension-link", "@tiptap/extension-placeholder", "@tiptap/extension-document"],
69 | },
70 | nitro: {
71 | preset: "vercel",
72 | },
73 | })
74 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Keyboard-first
40 | Blogging Platform
41 |
42 |
43 |
44 | KeyPress let you write your blog
45 | with only keyboard
46 |
47 |
48 |
49 | Press '/' to write
50 | or 'Tab' to Navigate
51 |
52 |
53 |
54 |
55 |
56 |
62 |
65 |
66 | Nuxt 3 + Supabase + Vercel
67 |
68 |
69 |
70 |
Posts
71 |
72 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/components/Tiptap/Bubble.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
30 |
35 |
36 |
37 |
42 |
43 |
44 |
49 |
50 |
51 |
56 |
57 |
58 |
59 |
60 |
61 | Edit
62 |
63 |
64 | Edit
65 | Focus
66 |
67 |
68 |
69 |
70 |
71 |
79 |
--------------------------------------------------------------------------------
/pages/user/[siteId]/[slug].vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
{{ data.profiles?.name }}
37 | {{ format(new Date(data.created_at), "MMMM d") }}
38 |
39 |
40 | {{ data.title }}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
55 |
56 |
57 |
62 |
64 |
69 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/pages/dashboard/domain.vue:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 | Save ⌘S
60 |
61 |
62 |
63 | Domain :
64 |
72 |
73 |
74 |
75 |
Check domain
76 |
77 |
78 |
Set the following record on your DNS provider to continue:
79 |
80 |
81 |
82 |
Type
83 |
CNAME
84 |
85 |
86 |
Name
87 |
{{ payload.subdomain.split(".")[0] }}
88 |
89 |
90 |
Value
91 |
cname.vercel-dns.com
92 |
93 |
94 |
95 |
96 | Depending on your provider, it might take some time for the changes to apply.
97 |
98 | Learn More
101 |
102 |
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/components/Command.vue:
--------------------------------------------------------------------------------
1 |
76 |
77 |
78 |
79 |
⌘K
80 |
81 |
82 |
83 |
84 |
90 |
91 |
92 |
93 |
94 | Navigation
95 |
96 |
100 | {{ item.label }}
101 |
102 |
103 |
104 |
No results found.
105 |
106 |
107 |
108 |
109 |
110 |
--------------------------------------------------------------------------------
/assets/main.css:
--------------------------------------------------------------------------------
1 | /* for easy focus tracking */
2 | button:not(:where(.not-default, [data-tippy-root])),
3 | a:not(:where(.not-default, [data-tippy-root])) {
4 | outline: 2px solid transparent;
5 | outline-offset: 15px;
6 | transition: 0.3s all ease !important;
7 | }
8 | button:not(:where(.not-default)):focus,
9 | a:not(:where(.not-default)):focus {
10 | outline-offset: 2px;
11 | outline: 2px solid #2d2d2d;
12 | border-radius: 1rem;
13 | }
14 |
15 | html {
16 | scroll-behavior: smooth;
17 | }
18 | body {
19 | @apply text-dark-300 overflow-x-hidden;
20 | }
21 | ::-moz-selection {
22 | /* Code for Firefox */
23 | @apply bg-gray-200;
24 | }
25 |
26 | ::selection {
27 | @apply bg-gray-200;
28 | }
29 | ::-webkit-scrollbar {
30 | @apply bg-light-300 w-6px h-6px md:w-8px md:h-8px;
31 | }
32 | ::-webkit-scrollbar-thumb {
33 | @apply rounded-xl transition bg-light-900;
34 | }
35 | ::-webkit-scrollbar-thumb:hover {
36 | @apply bg-gray-300;
37 | }
38 | ::-webkit-scrollbar-track {
39 | @apply rounded-xl bg-transparent;
40 | }
41 |
42 | kbd {
43 | height: 20px;
44 | width: 20px;
45 | border-radius: 4px;
46 | padding: 0 4px;
47 | display: flex;
48 | align-items: center;
49 | justify-content: center;
50 | font-family: inherit;
51 | @apply text-gray-400 bg-light-300;
52 | }
53 | kbd:first-of-type {
54 | margin-left: 8px;
55 | }
56 |
57 | input[type="text"]:not(:where(.not-default)),
58 | input[type="url"]:not(:where(.not-default)) {
59 | @apply flex items-center rounded-2xl w-full h-10 py-1 md:py-2 px-5 bg-light-300 placeholder-gray-400 outline-none transition ring-3 ring-transparent !focus-within:ring-gray-400;
60 | }
61 |
62 | .fade-enter-active,
63 | .fade-leave-active {
64 | transition: all 0.3s ease-in-out;
65 | }
66 |
67 | .fade-enter-from,
68 | .fade-leave-to {
69 | opacity: 0;
70 | }
71 | .fade-enter-active .inner,
72 | .fade-leave-active .inner {
73 | transition: all 0.3s ease-in-out;
74 | }
75 |
76 | .fade-enter-from .inner,
77 | .fade-leave-to .inner {
78 | opacity: 0;
79 | transform: scale(0);
80 | }
81 |
82 | .slide-in-right-enter-active,
83 | .slide-in-right-leave-active {
84 | transition: all 0.3s ease-in-out;
85 | }
86 |
87 | .slide-in-right-enter-from,
88 | .slide-in-right-leave-to {
89 | opacity: 0;
90 | transform: translateX(300px);
91 | }
92 |
93 | .command-dialog-enter-active,
94 | .command-dialog-leave-active {
95 | transition: all 0.2s ease-in-out;
96 | }
97 |
98 | .command-dialog-enter-from,
99 | .command-dialog-leave-to {
100 | opacity: 0;
101 | transform: scale(0.95);
102 | }
103 |
104 | .prose :where(iframe):not(:where(.not-prose, .not-prose *)) {
105 | width: 100%;
106 | max-width: 100%;
107 | }
108 |
109 | /* .prose pre {
110 | background: #0d0d0d;
111 | color: #fff;
112 | font-family: "JetBrainsMono", monospace;
113 | padding: 0.75rem 1rem;
114 | border-radius: 0.5rem;
115 | } */
116 |
117 | .hljs-comment,
118 | .hljs-quote {
119 | color: #616161;
120 | }
121 |
122 | .hljs-variable,
123 | .hljs-template-variable,
124 | .hljs-attribute,
125 | .hljs-tag,
126 | .hljs-name,
127 | .hljs-regexp,
128 | .hljs-link,
129 | .hljs-name,
130 | .hljs-selector-id,
131 | .hljs-selector-class {
132 | color: #f98181;
133 | }
134 |
135 | .hljs-number,
136 | .hljs-meta,
137 | .hljs-built_in,
138 | .hljs-builtin-name,
139 | .hljs-literal,
140 | .hljs-type,
141 | .hljs-params {
142 | color: #fbbc88;
143 | }
144 |
145 | .hljs-string,
146 | .hljs-symbol,
147 | .hljs-bullet {
148 | color: #b9f18d;
149 | }
150 |
151 | .hljs-title,
152 | .hljs-section {
153 | color: #faf594;
154 | }
155 |
156 | .hljs-keyword,
157 | .hljs-selector-tag {
158 | color: #70cff8;
159 | }
160 |
161 | .hljs-emphasis {
162 | font-style: italic;
163 | }
164 |
165 | .hljs-strong {
166 | font-weight: 700;
167 | }
168 |
--------------------------------------------------------------------------------
/utils/tiptap/move.ts:
--------------------------------------------------------------------------------
1 | import { CommandProps, Editor, Extension } from "@tiptap/core"
2 | import { findParentNodeOfType } from "prosemirror-utils"
3 | import { EditorState, NodeSelection, Selection, TextSelection } from "prosemirror-state"
4 | import { Fragment, NodeType, Slice } from "prosemirror-model"
5 | import { ReplaceStep } from "prosemirror-transform"
6 |
7 | declare module "@tiptap/core" {
8 | interface Commands {
9 | move: {
10 | moveParent: (direction: "up" | "down") => ReturnType
11 | }
12 | }
13 | }
14 |
15 | // ref: https://github.com/bangle-io/bangle.dev/blob/960fb4706a953ef910a9ddf2d80a7f10bdd2921b/core/core-commands.js#L101
16 |
17 | function arrayify(x: any) {
18 | if (x == null) {
19 | throw new Error("undefined value passed")
20 | }
21 | return Array.isArray(x) ? x : [x]
22 | }
23 | function mapChildren(node: any, callback: any) {
24 | const array = []
25 | for (let i = 0; i < node.childCount; i++) {
26 | array.push(callback(node.child(i), i, node instanceof Fragment ? node : node.content))
27 | }
28 |
29 | return array
30 | }
31 |
32 | const moveNode = (type: NodeType, dir: "up" | "down") => {
33 | const isDown = dir === "down"
34 | return (state: EditorState, dispatch: any) => {
35 | // @ts-ignore (node) only exist in custom element. eg: image, iframe
36 | const { $from, node } = state.selection
37 |
38 | const currentResolved = findParentNodeOfType(type)(state.selection) ?? {
39 | depth: 1,
40 | node,
41 | pos: 34,
42 | start: 34,
43 | }
44 |
45 | if (!currentResolved.node) {
46 | return false
47 | }
48 |
49 | const { node: currentNode } = currentResolved
50 | const parentDepth = currentResolved.depth - 1
51 | const parent = $from.node(parentDepth)
52 | const parentPos = $from.start(parentDepth)
53 |
54 | if (currentNode.type !== type) {
55 | return false
56 | }
57 |
58 | const arr = mapChildren(parent, (node) => node)
59 |
60 | let index = arr.indexOf(currentNode)
61 |
62 | let swapWith = isDown ? index + 1 : index - 1
63 |
64 | // If swap is out of bound
65 | if (swapWith >= arr.length || swapWith < 0) {
66 | return false
67 | }
68 |
69 | const swapWithNodeSize = arr[swapWith].nodeSize
70 | ;[arr[index], arr[swapWith]] = [arr[swapWith], arr[index]]
71 |
72 | let tr = state.tr
73 | let replaceStart = parentPos
74 | let replaceEnd = $from.end(parentDepth)
75 |
76 | const slice = new Slice(Fragment.fromArray(arr), 0, 0) // the zeros lol -- are not depth they are something that represents the opening closing
77 | // .toString on slice gives you an idea. for this case we want them balanced
78 | tr = tr.step(new ReplaceStep(replaceStart, replaceEnd, slice, false))
79 |
80 | tr = tr.setSelection(
81 | Selection.near(tr.doc.resolve(isDown ? $from.pos + swapWithNodeSize : $from.pos - swapWithNodeSize))
82 | )
83 | if (dispatch) {
84 | dispatch(tr.scrollIntoView())
85 | }
86 | return true
87 | }
88 | }
89 |
90 | export default Extension.create({
91 | name: "move",
92 |
93 | addCommands() {
94 | return {
95 | moveParent:
96 | (direction: "up" | "down") =>
97 | ({ editor, state, dispatch, ...a }) => {
98 | // @ts-ignore (node) only exist in custom element. eg: image, iframe
99 | const type = editor.state.selection.node?.type ?? editor.state.selection.$head.parent.type
100 | return moveNode(type, direction)(state, dispatch)
101 | },
102 | }
103 | },
104 |
105 | addKeyboardShortcuts() {
106 | return {
107 | "Alt-ArrowUp": () => this.editor.commands.moveParent("up"),
108 | "Alt-ArrowDown": () => this.editor.commands.moveParent("down"),
109 | }
110 | },
111 | })
112 |
--------------------------------------------------------------------------------
/pages/edit/[id].vue:
--------------------------------------------------------------------------------
1 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | Settings ⌘E
106 |
107 | Save ⌘S
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
--------------------------------------------------------------------------------
/utils/tiptap/suggestion.ts:
--------------------------------------------------------------------------------
1 | import type { Editor, Range } from "@tiptap/core"
2 | import { VueRenderer } from "@tiptap/vue-3"
3 | import tippy from "tippy.js"
4 |
5 | import CommandsList from "~~/components/Tiptap/CommandList.vue"
6 |
7 | interface Command {
8 | editor: Editor
9 | range: Range
10 | }
11 | export default {
12 | items: ({ query }) => {
13 | return [
14 | {
15 | title: "Heading 2",
16 | description: "Big section heading.",
17 | command: ({ editor, range }: Command) => {
18 | editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run()
19 | },
20 | },
21 | {
22 | title: "Heading 3",
23 | description: "Medium section heading.",
24 | command: ({ editor, range }: Command) => {
25 | editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run()
26 | },
27 | },
28 | {
29 | title: "Numbered List",
30 | description: "Create a list with numbering.",
31 | command: ({ editor, range }: Command) => {
32 | editor.chain().focus().deleteRange(range).wrapInList("orderedList").run()
33 | },
34 | },
35 | {
36 | title: "Bulleted List",
37 | description: "Create a simple bulleted list.",
38 | command: ({ editor, range }: Command) => {
39 | editor.chain().focus().deleteRange(range).wrapInList("bulletList").run()
40 | },
41 | },
42 | {
43 | title: "Image",
44 | description: "Upload or embed with link.",
45 | command: ({ editor, range }: Command) => {
46 | editor.chain().focus().deleteRange(range).openModal("image").run()
47 | },
48 | },
49 | {
50 | title: "Iframe",
51 | description: "Embed website with link.",
52 | command: ({ editor, range }: Command) => {
53 | editor.chain().focus().deleteRange(range).openModal("iframe").run()
54 | },
55 | },
56 | // {
57 | // title: "bold",
58 | // command: ({ editor, range }: Command) => {
59 | // editor.chain().focus().deleteRange(range).setMark("bold").run()
60 | // },
61 | // },
62 | // {
63 | // title: "underline",
64 | // command: ({ editor, range }: Command) => {
65 | // editor.chain().focus().deleteRange(range).setMark("underline").run()
66 | // },
67 | // },
68 | // {
69 | // title: "italic",
70 | // command: ({ editor, range }: Command) => {
71 | // editor.chain().focus().deleteRange(range).setMark("italic").run()
72 | // },
73 | // },
74 | ]
75 | .filter((item) => item.title.toLowerCase().startsWith(query.toLowerCase()))
76 | .slice(0, 10)
77 | },
78 |
79 | render: () => {
80 | let component
81 | let popup
82 |
83 | return {
84 | onStart: (props) => {
85 | component = new VueRenderer(CommandsList, {
86 | props,
87 | editor: props.editor,
88 | })
89 |
90 | if (!props.clientRect) {
91 | return
92 | }
93 |
94 | popup = tippy("body", {
95 | getReferenceClientRect: props.clientRect,
96 | appendTo: () => document.body,
97 | content: component.element,
98 | showOnCreate: true,
99 | interactive: true,
100 | trigger: "manual",
101 | placement: "bottom-start",
102 | })
103 | },
104 |
105 | onUpdate(props) {
106 | component.updateProps(props)
107 |
108 | if (!props.clientRect) {
109 | return
110 | }
111 |
112 | popup[0].setProps({
113 | getReferenceClientRect: props.clientRect,
114 | })
115 | },
116 |
117 | onKeyDown(props) {
118 | if (props.event.key === "Escape") {
119 | popup[0].hide()
120 |
121 | return true
122 | }
123 |
124 | return component.ref?.onKeyDown(props.event)
125 | },
126 |
127 | onExit() {
128 | popup[0].destroy()
129 | component.destroy()
130 | },
131 | }
132 | },
133 | }
134 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | A keyboard-first blogging platform.
10 | Finally write your blog post only with keys 🎹
11 |
12 |
13 |
14 | View Demo
15 | ·
16 | Report Bug
17 | ·
18 | Request Feature
19 |
20 |
21 |
22 |
23 |
24 | 
25 |
26 | ## Introduction
27 |
28 | KeyPress is an open-source blogging platform that focused on keyboard-first experience. It was inspired by Vercel's Platform Starter Kit.
29 |
30 | I always wanted to build a multi-tenant platform using [Nuxt3](https://v3.nuxtjs.org/), and I finally did it! - in `nuxt-rc11`.
31 |
32 | If you are interested to implement the same, checkout
33 |
34 | 1. [`server/middleware/subdomain.ts`](https://github.com/zernonia/keypress/blob/main/server/middleware/subdomain.ts) - check the current domain and set srr context.
35 | 2. [`app/router.option.ts`](https://github.com/zernonia/keypress/blob/main/app/router.options.ts) - based on the ssr context, map a new route.
36 | 3. [`pages/user/[siteId]`](https://github.com/zernonia/keypress/tree/main/pages/user/%5BsiteId%5D) - this will now be your new router root
37 |
38 | ## 🚀 Features
39 |
40 | - 🤩 Free
41 | - 📖 Open-Source
42 | - 🚀 Free custom domain
43 | - 🌌 Auto OG image (using [Satori](https://github.com/vercel/satori))
44 |
45 | ### 🔨 Built With
46 |
47 | - [Nuxt 3](https://v3.nuxtjs.org/)
48 | - [Supabase](https://supabase.com)
49 | - [UnoCss](https://uno.antfu.me/)
50 | - [Vercel - Hosting & Domain](https://vercel.com)
51 |
52 | ## 🌎 Setup
53 |
54 | ### Prerequisites
55 |
56 | Yarn
57 |
58 | - ```sh
59 | npm install --global yarn
60 | ```
61 |
62 | ### Development
63 |
64 | 1. Clone the repo
65 | ```sh
66 | git clone https://github.com/zernonia/keypress.git
67 | ```
68 | 2. Install NPM packages
69 | ```sh
70 | cd keypress
71 | yarn install
72 | ```
73 | 3. Run local development instance
74 | ```sh
75 | yarn dev
76 | ```
77 |
78 | ### Supabase Database
79 |
80 | ```sql
81 | create table domains (
82 | user_id uuid,
83 | url text not null primary key,
84 | active boolean,
85 | created_at timestamp default now()
86 | );
87 |
88 | create table profiles (
89 | id uuid default uuid_generate_v4() primary key,
90 | username text,
91 | avatar_url text,
92 | name text,
93 | created_at timestamp default now(),
94 | subdomain text references domains (url)
95 | );
96 |
97 | create table posts (
98 | id uuid default uuid_generate_v4() primary key,
99 | author_id uuid references profiles (id),
100 | created_at timestamp default now(),
101 | slug text not null,
102 | title text,
103 | body text,
104 | cover_img text,
105 | active boolean,
106 | tags ARRAY,
107 | featured boolean not null
108 | );
109 |
110 |
111 | create or replace view tags_view as
112 | select *, count(*)
113 | from
114 | (select unnest(tags) as name from posts where active is true) s
115 | group by name;
116 |
117 |
118 |
119 | create or replace function public.handle_new_user()
120 | returns trigger as $$
121 | begin
122 | insert into public.profiles (id, avatar_url, username, name)
123 | values (new.id, new.raw_user_meta_data->>'avatar_url', new.raw_user_meta_data->>'user_name', new.raw_user_meta_data->>'preferred_username');
124 | return new;
125 | end;
126 | $$ language plpgsql security definer;
127 |
128 |
129 | create trigger on_auth_user_created
130 | after insert on auth.users
131 | for each row execute procedure public.handle_new_user();
132 | ```
133 |
134 | ## ➕ Contributing
135 |
136 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
137 |
138 | 1. Fork the Project
139 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
140 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
141 | 4. Push to the Branch (`git push origin feature/AmazingFeature`)
142 | 5. Open a Pull Request
143 |
144 | ## Acknowledgement
145 |
146 | 1. [Nuxt 3 - Awesome framework](https://v3.nuxtjs.org/)
147 | 1. [Supabase - Super easy setup (as always)](https://supabase.com)
148 | 1. [Tiptap - Awesome editor](https://tiptap.dev/)
149 | 1. [Vercel's Platform Starter Kit - Subdomain/Custom domain](https://github.com/vercel/platforms)
150 | 1. [Vercel's new og generation](https://github.com/vercel/satori)
151 |
152 | ## Author
153 |
154 | - Zernonia ([@zernonia](https://twitter.com/zernonia))
155 |
156 | Also, if you like my work, please buy me a coffee ☕😳
157 |
158 |
159 |
160 |
161 |
162 | ## 🔥 Contributors
163 |
164 |
165 |
166 |
167 |
168 | ## 📜 License
169 |
170 | Distributed under the MIT License. See `LICENSE` for more information.
171 |
--------------------------------------------------------------------------------
/server/routes/og/[slug].ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs"
2 | import { join, resolve } from "path"
3 | import { serverSupabaseClient } from "#supabase/server"
4 | import { useUrl } from "~~/composables/url"
5 | import { Resvg, ResvgRenderOptions } from "@resvg/resvg-js"
6 | import type { Posts } from "~~/utils/types"
7 | import satori from "satori"
8 |
9 | export default defineEventHandler(async (event) => {
10 | const client = serverSupabaseClient(event)
11 | const url = useUrl()
12 | const slug = event.context.params.slug
13 | const fonts = ["arial.ttf", "arial_bold.ttf"]
14 |
15 | try {
16 | const { data, error } = await client
17 | .from("posts")
18 | .select("title, profiles(name, avatar_url)")
19 | .eq("slug", slug)
20 | .single()
21 | if (error) throw Error(error.message)
22 |
23 | // svg inspired from https://og-playground.vercel.app/
24 | const svg = await satori(
25 | {
26 | type: "div",
27 | props: {
28 | style: {
29 | display: "flex",
30 | height: "100%",
31 | width: "100%",
32 | alignItems: "center",
33 | justifyContent: "center",
34 | letterSpacing: "-.02em",
35 | fontWeight: 700,
36 | background: "#f8f9fa",
37 | },
38 | children: [
39 | {
40 | type: "img",
41 | props: {
42 | style: {
43 | right: 42,
44 | bottom: 42,
45 | position: "absolute",
46 | display: "flex",
47 | alignItems: "center",
48 | width: "300px",
49 | },
50 | src: url + "/banner.png",
51 | },
52 | },
53 | {
54 | type: "div",
55 | props: {
56 | style: {
57 | left: 42,
58 | bottom: 42,
59 | position: "absolute",
60 | display: "flex",
61 | alignItems: "center",
62 | },
63 | children: [
64 | {
65 | type: "img",
66 | props: {
67 | style: {
68 | width: "70px",
69 | height: "70px",
70 | borderRadius: "9999px",
71 | },
72 | src: data.profiles.avatar_url,
73 | },
74 | },
75 | {
76 | type: "p",
77 | props: {
78 | style: {
79 | marginLeft: "20px",
80 | fontSize: "24px",
81 | },
82 | children: data.profiles.name,
83 | },
84 | },
85 | ],
86 | },
87 | },
88 | {
89 | type: "div",
90 | props: {
91 | style: {
92 | display: "flex",
93 | flexWrap: "wrap",
94 | justifyContent: "center",
95 | padding: "20px 50px",
96 | margin: "0 42px 150px 42px",
97 | fontSize: "64px",
98 | width: "auto",
99 | maxWidth: 1200 - 48 * 2,
100 | textAlign: "center",
101 | backgroundColor: "#2D2D2D",
102 | borderRadius: "30px",
103 | color: "white",
104 | lineHeight: 1.4,
105 | },
106 | children: data.title,
107 | },
108 | },
109 | ],
110 | },
111 | },
112 | {
113 | width: 1200,
114 | height: 630,
115 | fonts: [
116 | {
117 | name: "Arial",
118 | data: readFileSync(join(process.cwd(), "public/fonts", fonts[0])),
119 | weight: 400,
120 | style: "normal",
121 | },
122 | {
123 | name: "Arial",
124 | data: readFileSync(join(process.cwd(), "public/fonts", fonts[1])),
125 | weight: 700,
126 | style: "normal",
127 | },
128 | ],
129 | }
130 | )
131 |
132 | // render to svg as image
133 |
134 | const resvg = new Resvg(svg, {
135 | fitTo: {
136 | mode: "width",
137 | value: 1200,
138 | },
139 | font: {
140 | fontFiles: fonts.map((i) => join(resolve("."), "public/fonts", i)), // Load custom fonts.
141 | loadSystemFonts: false,
142 | },
143 | })
144 |
145 | const resolved = await Promise.all(
146 | resvg.imagesToResolve().map(async (url) => {
147 | console.info("image url", url)
148 | const img = await fetch(url)
149 | const buffer = await img.arrayBuffer()
150 | return {
151 | url,
152 | buffer: Buffer.from(buffer),
153 | }
154 | })
155 | )
156 | if (resolved.length > 0) {
157 | for (const result of resolved) {
158 | const { url, buffer } = result
159 | resvg.resolveImage(url, buffer)
160 | }
161 | }
162 |
163 | const renderData = resvg.render()
164 | const pngBuffer = renderData.asPng()
165 |
166 | event.res.setHeader("Cache-Control", "s-maxage=7200, stale-while-revalidate")
167 | return pngBuffer
168 | } catch (err) {
169 | return createError({ statusCode: 500, statusMessage: err })
170 | }
171 | })
172 |
--------------------------------------------------------------------------------