├── .env.example ├── .gitignore ├── README.md ├── app.vue ├── app └── router.options.ts ├── assets └── main.css ├── components ├── Button.vue ├── Command.vue ├── Drawer.vue ├── Drawer │ └── EditPost.vue ├── Footer.vue ├── Loader.vue ├── Logo.vue ├── Modal.vue ├── Modal │ └── Login.vue ├── Post │ └── Card.vue ├── TagsInput.vue ├── Tiptap.vue ├── Tiptap │ ├── Bubble.vue │ ├── CommandList.vue │ ├── ModalIframe.vue │ └── ModalImage.vue ├── TiptapHeading.vue ├── Toggle.vue └── Upload.vue ├── composables ├── dashboard.ts ├── head.ts ├── subdomain.ts └── url.ts ├── layouts ├── default.vue └── user.vue ├── license.md ├── middleware └── auth.ts ├── modules └── og.ts ├── nuxt.config.ts ├── package.json ├── pages ├── dashboard.vue ├── dashboard │ ├── domain.vue │ ├── index.vue │ ├── posts.vue │ └── profile.vue ├── edit │ └── [id].vue ├── index.vue ├── login.vue ├── posts.vue └── user │ ├── [siteId].vue │ └── [siteId] │ ├── [slug].vue │ ├── home.vue │ └── index.vue ├── plugins └── umami.client.ts ├── public ├── banner.png ├── fonts │ ├── arial.ttf │ └── arial_bold.ttf ├── hero.png ├── logo.svg └── og.png ├── server ├── api │ ├── _supabase │ │ └── session.post.ts │ ├── add-domain.post.ts │ ├── check-domain.post.ts │ ├── delete-domain.post.ts │ ├── request-delegation.ts │ └── user-validation.post.ts ├── middleware │ ├── login.ts │ └── subdomain.ts └── routes │ └── og │ └── [slug].ts ├── tsconfig.json ├── utils ├── functions.ts ├── tiptap │ ├── code.ts │ ├── commands.ts │ ├── hardbreak.ts │ ├── iframe.ts │ ├── link.ts │ ├── move.ts │ ├── placeholder.ts │ ├── suggestion.ts │ └── upload.ts └── types.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | SUPABASE_URL= 2 | SUPABASE_KEY= 3 | AUTH_BEARER_TOKEN= 4 | VERCEL_PROJECT_ID= 5 | GITHUB_PROVIDER_TOKEN= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .vercel -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | KeyPress's Logo 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 | ![KeyPress - open-source blogging platform that focused on keyboard-first experience](public/hero.png) 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 | Logo 160 | 161 | 162 | ## 🔥 Contributors 163 | 164 | 165 | 166 | 167 | 168 | ## 📜 License 169 | 170 | Distributed under the MIT License. See `LICENSE` for more information. 171 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 15 | 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/Button.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 69 | -------------------------------------------------------------------------------- /components/Command.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 110 | -------------------------------------------------------------------------------- /components/Drawer.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /components/Drawer/EditPost.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 46 | -------------------------------------------------------------------------------- /components/Footer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 42 | -------------------------------------------------------------------------------- /components/Loader.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /components/Logo.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /components/Modal.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /components/Modal/Login.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /components/Post/Card.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 52 | -------------------------------------------------------------------------------- /components/TagsInput.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 48 | 49 | 50 | 51 | 59 | -------------------------------------------------------------------------------- /components/Tiptap.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 65 | 66 | 93 | -------------------------------------------------------------------------------- /components/Tiptap/Bubble.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 70 | 71 | 79 | -------------------------------------------------------------------------------- /components/Tiptap/CommandList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 69 | -------------------------------------------------------------------------------- /components/Tiptap/ModalIframe.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 50 | -------------------------------------------------------------------------------- /components/Tiptap/ModalImage.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 60 | -------------------------------------------------------------------------------- /components/TiptapHeading.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 70 | -------------------------------------------------------------------------------- /components/Toggle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | -------------------------------------------------------------------------------- /components/Upload.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /composables/url.ts: -------------------------------------------------------------------------------- 1 | export const useUrl = () => (process.dev ? "http://localhost:3000" : "https://keypress.blog") 2 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 30 | -------------------------------------------------------------------------------- /layouts/user.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 48 | -------------------------------------------------------------------------------- /pages/dashboard/domain.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 106 | -------------------------------------------------------------------------------- /pages/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /pages/dashboard/posts.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | -------------------------------------------------------------------------------- /pages/dashboard/profile.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /pages/edit/[id].vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 121 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 79 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /pages/posts.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 52 | -------------------------------------------------------------------------------- /pages/user/[siteId].vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /pages/user/[siteId]/[slug].vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 81 | -------------------------------------------------------------------------------- /pages/user/[siteId]/home.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /pages/user/[siteId]/index.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 37 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zernonia/keypress/7bfb22382810dc84e26cfaa1b0dd589ff8e8da23/public/banner.png -------------------------------------------------------------------------------- /public/fonts/arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zernonia/keypress/7bfb22382810dc84e26cfaa1b0dd589ff8e8da23/public/fonts/arial.ttf -------------------------------------------------------------------------------- /public/fonts/arial_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zernonia/keypress/7bfb22382810dc84e26cfaa1b0dd589ff8e8da23/public/fonts/arial_bold.ttf -------------------------------------------------------------------------------- /public/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zernonia/keypress/7bfb22382810dc84e26cfaa1b0dd589ff8e8da23/public/hero.png -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zernonia/keypress/7bfb22382810dc84e26cfaa1b0dd589ff8e8da23/public/og.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /server/api/request-delegation.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | return 'Hello add-domain' 3 | }) 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------