├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── Procfile ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── sql └── schema.sql ├── src ├── app.css ├── app.html ├── hooks.server.js ├── lib │ ├── _db.js │ ├── api.js │ ├── components │ │ ├── Article.svelte │ │ ├── ArticleTeaser.svelte │ │ ├── BaseButton.svelte │ │ ├── EditableWebsiteTeaser.svelte │ │ ├── EditorToolbar.svelte │ │ ├── Footer.svelte │ │ ├── Image.svelte │ │ ├── ImageEditor.svelte │ │ ├── Input.svelte │ │ ├── IntroStep.svelte │ │ ├── Limiter.svelte │ │ ├── LoginMenu.svelte │ │ ├── Modal.svelte │ │ ├── NotEditable.svelte │ │ ├── PlainText.svelte │ │ ├── PlainTextEditor.svelte │ │ ├── PrimaryButton.svelte │ │ ├── RichText.svelte │ │ ├── RichTextEditor.svelte │ │ ├── Search.svelte │ │ ├── SecondaryButton.svelte │ │ ├── Testimonial.svelte │ │ ├── Toggle.svelte │ │ ├── WebsiteNav.svelte │ │ └── tools │ │ │ ├── CreateLink.svelte │ │ │ ├── InsertImage.svelte │ │ │ ├── ToggleAICompletion.svelte │ │ │ ├── ToggleBlockquote.svelte │ │ │ ├── ToggleBulletList.svelte │ │ │ ├── ToggleHeading.svelte │ │ │ ├── ToggleMark.svelte │ │ │ └── ToggleOrderedList.svelte │ ├── constants.js │ ├── prosemirrorCommands.js │ ├── prosemirrorInputrules.js │ ├── prosemirrorKeymap.js │ ├── prosemirrorSchemas.js │ ├── prosemirrorUtil.js │ ├── stores.js │ ├── uploadAsset.js │ ├── util.js │ └── uuid.js └── routes │ ├── +layout.svelte │ ├── +page.server.js │ ├── +page.svelte │ ├── api │ ├── counter │ │ └── +server.js │ ├── create-article │ │ └── +server.js │ ├── delete-article │ │ └── +server.js │ ├── generate │ │ └── +server.js │ ├── presignedurl │ │ └── +server.js │ ├── save-page │ │ └── +server.js │ ├── search │ │ └── +server.js │ └── update-article │ │ └── +server.js │ ├── blog │ ├── +page.server.js │ ├── +page.svelte │ ├── [slug] │ │ ├── +page.server.js │ │ └── +page.svelte │ └── new │ │ ├── +page.server.js │ │ └── +page.svelte │ ├── imprint │ ├── +page.server.js │ └── +page.svelte │ ├── login │ ├── +page.server.js │ └── +page.svelte │ └── logout │ ├── +page.server.js │ └── +page.svelte ├── static ├── favicon-144x144.png ├── favicon-192x192.png ├── favicon-256x256.png ├── favicon-32x32.png ├── favicon-384x384.png ├── favicon-48x48.png ├── favicon-512x512.png ├── favicon-72x72.png ├── favicon-96x96.png ├── favicon.png ├── images │ └── person-placeholder.jpg └── manifest.webmanifest ├── svelte.config.js ├── tailwind.config.js └── vite.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['eslint:recommended', 'prettier'], 4 | plugins: ['svelte3'], 5 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 6 | parserOptions: { 7 | sourceType: 'module', 8 | ecmaVersion: 2020 9 | }, 10 | env: { 11 | browser: true, 12 | es2017: true, 13 | node: true 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "arrowParens": "avoid", 7 | "svelteSortOrder": "options-scripts-markup-styles", 8 | "svelteStrictMode": false, 9 | "svelteIndentScriptAndStyle": true, 10 | "plugins": ["prettier-plugin-svelte"], 11 | "pluginSearchDirs": ["."], 12 | "overrides": [ 13 | { "files": "*.svelte", "options": { "parser": "svelte" } }, 14 | { "files": "*.css", "options": { "singleQuote": false } } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Michael Aufreiter. 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node build/index.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # editable-website - AI completion editor tool feature showcase 2 | 3 | A SvelteKit template for coding **completely custom website**, while allowing non-technical people to **make edits** to the content by simply logging in with a secure admin password. 4 | This is a fork of the original template created by [editable.website](https://editable.website) with the following changes: 5 | - Add feature to complete texts using ChatGPT (provided your OpenAI API key) 6 | 7 | Check out the original core demo at [editable.website](https://editable.website). 8 | 9 | ## But why? 10 | 11 | It's a dynamic website but light as a feather compared to building on top of a CMS. It makes editing content self-explanatory. 12 | 13 | ## Step 0 - Requirements 14 | 15 | - Node.js 16+ or compatible JavaScript runtime 16 | - Postgres 14+ 17 | - MinIO or other S3-compatible storage solution 18 | 19 | 20 | 21 | These are needed to run the example as is, but you can choose any other database and file storage solution. 22 | 23 | ## Step 1 - Development setup 24 | 25 | This is a full-fledged web app you want adjust to your own needs. So please **create a copy** or fork of the source code and rename the project accordingly. Then check out your own copy: 26 | 27 | ```bash 28 | git clone https://github.com/your-user/your-website.git 29 | cd your-website 30 | ``` 31 | 32 | For media storage this template is configured to use S3 compatible storage. For local development you can run a container with MinIO using this docker image: 33 | ```bash 34 | docker run \ 35 | -p 9000:9000 \ 36 | -p 9090:9090 \ 37 | --name minio \ 38 | -v ~/minio/data:/data \ 39 | -e "MINIO_ROOT_USER=ROOTNAME" \ 40 | -e "MINIO_ROOT_PASSWORD=CHANGEME123" \ 41 | quay.io/minio/minio server /data --console-address ":9090" 42 | ``` 43 | 44 | Create a `.env` file and set the following environment variables to point to your development database and MinIO instance: 45 | 46 | ```bash 47 | VITE_DB_URL=postgresql://$USER@localhost:5432/editable-website 48 | VITE_S3_ACCESS_KEY=000000000000000000 49 | VITE_S3_SECRET_ACCESS_KEY=00000000000000000000000000000000000000 50 | VITE_S3_ENDPOINT=https://minio.ew-dev-assets--000000000000.addon.code.run 51 | VITE_S3_BUCKET=editable-website 52 | VITE_ASSET_PATH=https://minio.ew-dev-assets--000000000000.addon.code.run/editable-website 53 | VITE_ADMIN_PASSWORD=00000000000000000000000000000000000000 54 | VITE_OPENAI_API_KEY=00000000000000000000000000000000000000 55 | ``` 56 | 57 | If you are running MinIO locally, you can use these default environment credentials to connect to it: 58 | ```bash 59 | VITE_S3_ENDPOINT=http://127.0.0.1:9000 60 | VITE_S3_BUCKET=editable-website 61 | VITE_ASSET_PATH=http://127.0.0.1:9000/editable-website 62 | ``` 63 | 64 | 65 | Seed the database: 66 | 67 | ```bash 68 | psql -h localhost -U $USER -d editable-website -a -f sql/schema.sql 69 | ``` 70 | 71 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 72 | 73 | ```bash 74 | npm run dev 75 | ``` 76 | 77 | To create and test a production version of your app: 78 | 79 | ```bash 80 | npm run build 81 | ``` 82 | 83 | You can preview the production build with `npm run preview`. 84 | 85 | ## Step 2 - Making changes to your website 86 | 87 | You can literally do everything that SvelteKit allows you to do. Below is the source code for the /imprint page, which has a `` title and `<RichText>` content. 88 | 89 | ```svelte 90 | <svelte:head> 91 | <title>Imprint</title> 92 | </svelte:head> 93 | 94 | {#if showUserMenu} 95 | <Modal on:close={() => (showUserMenu = false)}> 96 | <div class="w-full flex flex-col space-y-4 p-4 sm:p-6"> 97 | <PrimaryButton on:click={toggleEdit}>Edit page</PrimaryButton> 98 | <LoginMenu {currentUser} /> 99 | </div> 100 | </Modal> 101 | {/if} 102 | 103 | {#if editable} 104 | <EditorToolbar on:cancel={initOrReset} on:save={savePage} /> 105 | {/if} 106 | 107 | <WebsiteNav bind:showUserMenu {currentUser} bind:editable /> 108 | 109 | <div class="py-12 sm:py-24"> 110 | <div class="max-w-screen-md mx-auto px-6 md:text-xl"> 111 | <h1 class="text-4xl md:text-7xl font-bold pb-8"> 112 | <PlainText {editable} bind:content={title} /> 113 | </h1> 114 | <div class="prose md:prose-xl pb-12 sm:pb-24"> 115 | <RichText multiLine {editable} bind:content={imprint} /> 116 | </div> 117 | </div> 118 | </div> 119 | 120 | <Footer counter="/imprint" /> 121 | ``` 122 | 123 | To see the full picture, open [src/routes/imprint/+page.svelte](src/routes/imprint/%2Bpage.svelte) and [src/routes/imprint/+page.server.js](src/routes/imprint/%2Bpage.server.js). 124 | 125 | Please use this as a starting point for new pages you want to add to your website. `editable-website` is not a widget-library on purpose. Instead you are encouraged to inspect and adjust all source code, including the [schema](./src/lib/prosemirrorSchemas.js) for the editors. I want you to be in control of everything. No behind-the-scene magic. 126 | 127 | ## Step 3 - Making changes to the content 128 | 129 | Just navigate to `http://127.0.0.1:5173/login` and enter your secure admin password (`VITE_ADMIN_PASSWORD`). Now you see an additional ellipsis menu, which will provide you an "Edit page" or "Edit post" option for all pages that you have set up as "editable". 130 | 131 | ## Step 4 - Deployment 132 | 133 | I will describe the steps to deploy to [Northflank](https://northflank.com/) (which I am using). I recommend to assign 0.2 vCPU and 512MB RAM to each resource (~ $17/month) but you can go lower to save some costs or higher if you expect your site to have significant traffic. 134 | 135 | 1. Create instances for Postgres 14 and MinIO through the Northflank user interface. 136 | 137 | 2. Create a combined service, select the Heroku buildpack and assign the environment variables as they are exposed by the Postgres and MinIO addons. Use the same environment variables during the build step and runtime (yes, you have to type them twice). 138 | 139 | You can deploy your editable website anywhere else as well. For instance if you'd like to go the "Serverless" path, you can deploy on Vercel, and use NeonDB (or DigitalOcean with Connection Pooling activated). You may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 140 | 141 | ## Step 5 - Get in touch 142 | 143 | If you have questions or need help (with development or deployment), send me an email (michael@letsken.com) and suggest a few slots where you have time for a 30 minute chat (I'm based in Austria GMT+1). 144 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "editable-website", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 10 | "format": "prettier --plugin-search-dir . --write ." 11 | }, 12 | "devDependencies": { 13 | "@sveltejs/adapter-auto": "^2.0.0", 14 | "@sveltejs/kit": "^1.5.3", 15 | "@tailwindcss/forms": "^0.5.3", 16 | "@tailwindcss/typography": "^0.5.9", 17 | "autoprefixer": "^10.4.14", 18 | "eslint": "^8.28.0", 19 | "eslint-config-prettier": "^8.5.0", 20 | "eslint-plugin-svelte3": "^4.0.0", 21 | "postcss": "^8.4.21", 22 | "prettier": "^2.8.0", 23 | "prettier-plugin-svelte": "^2.8.1", 24 | "svelte": "^3.54.0", 25 | "tailwindcss": "^3.3.1", 26 | "vite": "^4.2.0" 27 | }, 28 | "type": "module", 29 | "dependencies": { 30 | "@fontsource/jost": "^4.5.13", 31 | "@sveltejs/adapter-node": "^1.2.3", 32 | "aws-sdk": "^2.1350.0", 33 | "camelcase-keys": "^8.0.2", 34 | "pg-promise": "^11.4.3", 35 | "prosemirror-commands": "^1.5.0", 36 | "prosemirror-dropcursor": "^1.6.1", 37 | "prosemirror-example-setup": "^1.2.1", 38 | "prosemirror-gapcursor": "^1.3.1", 39 | "prosemirror-history": "^1.3.0", 40 | "prosemirror-inputrules": "^1.2.0", 41 | "prosemirror-keymap": "^1.2.0", 42 | "prosemirror-model": "^1.19.0", 43 | "prosemirror-schema-basic": "^1.2.1", 44 | "prosemirror-schema-list": "^1.2.2", 45 | "prosemirror-state": "^1.4.2", 46 | "prosemirror-view": "^1.30.1", 47 | "slugify": "^1.6.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /sql/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 2 | 3 | -- articles 4 | DROP TABLE IF EXISTS articles cascade; 5 | CREATE TABLE articles ( 6 | article_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 7 | slug varchar(100) UNIQUE NOT NULL, 8 | title varchar(100) NOT NULL, 9 | teaser varchar(1000) NOT NULL, 10 | content text, 11 | created_at timestamptz DEFAULT NOW()::timestamptz, 12 | published_at timestamptz NULL, 13 | updated_at timestamptz NULL 14 | ); 15 | 16 | -- sessions 17 | DROP TABLE IF EXISTS sessions cascade; 18 | CREATE TABLE sessions ( 19 | session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 20 | expires timestamptz NOT NULL 21 | ); 22 | 23 | -- pages 24 | DROP TABLE IF EXISTS pages cascade; 25 | CREATE TABLE pages ( 26 | page_id varchar(100) PRIMARY KEY, 27 | data json NOT NULL 28 | ); 29 | 30 | -- counters (for view counts and everything you want to track anonymously) 31 | DROP TABLE IF EXISTS counters cascade; 32 | CREATE TABLE counters ( 33 | counter_id varchar(100) PRIMARY KEY, 34 | count integer NOT NULL 35 | ); 36 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html { 6 | @apply text-gray-900; 7 | } 8 | 9 | .bounce { 10 | animation: bounce 2s infinite; 11 | } 12 | 13 | @keyframes bounce { 14 | 0%, 15 | 20%, 16 | 50%, 17 | 80%, 18 | 100% { 19 | transform: translateY(0); 20 | } 21 | 40% { 22 | transform: translateY(-10px); 23 | } 24 | 60% { 25 | transform: translateY(-5px); 26 | } 27 | } 28 | 29 | /* Prosemirror styles */ 30 | 31 | .ProseMirror { 32 | position: relative; 33 | } 34 | 35 | .ProseMirror { 36 | word-wrap: break-word; 37 | white-space: pre-wrap; 38 | white-space: break-spaces; 39 | -webkit-font-variant-ligatures: none; 40 | font-variant-ligatures: none; 41 | font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ 42 | } 43 | 44 | .ProseMirror pre { 45 | white-space: pre-wrap; 46 | } 47 | 48 | .ProseMirror li { 49 | position: relative; 50 | } 51 | 52 | .ProseMirror-hideselection *::selection { 53 | background: transparent; 54 | } 55 | .ProseMirror-hideselection *::-moz-selection { 56 | background: transparent; 57 | } 58 | .ProseMirror-hideselection { 59 | caret-color: transparent; 60 | } 61 | 62 | .ProseMirror-selectednode { 63 | outline: 2px solid #8cf; 64 | } 65 | 66 | /* Make sure li selections wrap around markers */ 67 | li.ProseMirror-selectednode { 68 | outline: none; 69 | } 70 | 71 | li.ProseMirror-selectednode:after { 72 | content: ""; 73 | position: absolute; 74 | left: -32px; 75 | right: -2px; 76 | top: -2px; 77 | bottom: -2px; 78 | border: 2px solid #8cf; 79 | pointer-events: none; 80 | } 81 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8" /> 5 | <link rel="icon" type="image/png" href="%sveltekit.assets%/favicon-32x32.png" /> 6 | <link rel="apple-touch-icon" sizes="48x48" href="%sveltekit.assets%/favicon-48x48.png" /> 7 | <link rel="apple-touch-icon" sizes="72x72" href="%sveltekit.assets%/favicon-72x72.png" /> 8 | <link rel="apple-touch-icon" sizes="96x96" href="%sveltekit.assets%/favicon-96x96.png" /> 9 | <link rel="apple-touch-icon" sizes="256x256" href="%sveltekit.assets%/favicon-256x256.png" /> 10 | <link rel="apple-touch-icon" sizes="384x384" href="%sveltekit.assets%/favicon-384x384.png" /> 11 | <link rel="apple-touch-icon" sizes="512x512" href="%sveltekit.assets%/favicon-512x512.png" /> 12 | <link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" crossorigin="anonymous" /> 13 | <meta name="viewport" content="width=device-width" /> 14 | %sveltekit.head% 15 | </head> 16 | <body data-sveltekit-preload-data="hover"> 17 | <div style="display: contents">%sveltekit.body%</div> 18 | </body> 19 | </html> 20 | -------------------------------------------------------------------------------- /src/hooks.server.js: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from '$lib/api'; 2 | 3 | export async function handle({ event, resolve }) { 4 | event.locals.user = await getCurrentUser(event.cookies.get('sessionid')); 5 | const response = await resolve(event); 6 | return response; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/_db.js: -------------------------------------------------------------------------------- 1 | import pgPromise from 'pg-promise'; 2 | import camelcaseKeys from 'camelcase-keys'; 3 | 4 | const pgOptions = { 5 | receive: ({ data }) => { 6 | camelizeColumns(data); 7 | } 8 | }; 9 | 10 | const camelizeColumns = data => { 11 | const template = data[0]; 12 | for (const prop in template) { 13 | const camel = pgPromise.utils.camelize(prop); 14 | if (!(camel in template)) { 15 | for (let i = 0; i < data.length; i++) { 16 | const d = data[i]; 17 | d[camel] = d[prop]; 18 | delete d[prop]; 19 | } 20 | } 21 | } 22 | }; 23 | 24 | const DB_SSL = import.meta.env.VITE_DB_SSL; 25 | const DB_URL = import.meta.env.VITE_DB_URL; 26 | 27 | const pgp = pgPromise(pgOptions); 28 | 29 | // Configure types 30 | // https://github.com/vitaly-t/pg-promise/wiki/FAQ#how-to-access-the-instance-of-node-postgres-thats-used 31 | 32 | const types = pgp.pg.types; 33 | // Use strings to represent timestamps rather than a Date object (pg default) 34 | types.setTypeParser(types.builtins.TIMESTAMPTZ, function (val) { 35 | return new Date(val).toJSON(); 36 | }); 37 | types.setTypeParser(types.builtins.DATE, function (val) { 38 | return val; 39 | }); 40 | types.setTypeParser(types.builtins.JSON, function (val) { 41 | const json = camelcaseKeys(JSON.parse(val), { deep: true }); 42 | return json; 43 | }); 44 | 45 | // Default number parsing Postgres -> JS Types (maybe consider going away from numeric for performance gains and automatic conversion to JS floats) 46 | // smallint: parseInt() 47 | // integer: parseInt() 48 | // bigint: string 49 | // decimal: string 50 | // numeric: string 51 | // real: parseFloat() 52 | // double precision: parseFloat() 53 | // smallserial: parseInt() 54 | // serial: parseInt() 55 | // bigserial: string 56 | 57 | // Singleton usage as described here 58 | // https://www.codeoftheprogrammer.com/2020/01/16/postgresql-from-nextjs-api-route/ 59 | 60 | // Avoid self-signed ssl errors (with DigitalOcean) as described here 61 | // https://www.javaniceday.com/post/pg-promise-self-signed-certificate-error-in-postgres 62 | 63 | let ssl = null; 64 | if (DB_SSL) { 65 | ssl = { rejectUnauthorized: false }; 66 | } 67 | 68 | // Or you can use it this way 69 | const config = { 70 | connectionString: DB_URL, // 'postgres://john:pass123@localhost:5432/products', 71 | max: 30, 72 | ssl 73 | }; 74 | 75 | // Use a symbol to store a global instance of a connection, and to access it from the singleton. 76 | const DB_KEY = Symbol.for('The.db'); 77 | const globalSymbols = Object.getOwnPropertySymbols(global); 78 | const hasDb = globalSymbols.indexOf(DB_KEY) > -1; 79 | if (!hasDb) { 80 | global[DB_KEY] = pgp(config); 81 | } 82 | 83 | // Create and freeze the singleton object so that it has an instance property. 84 | const singleton = {}; 85 | Object.defineProperty(singleton, 'instance', { 86 | get: function () { 87 | return global[DB_KEY]; 88 | } 89 | }); 90 | Object.freeze(singleton); 91 | 92 | export default singleton; 93 | -------------------------------------------------------------------------------- /src/lib/api.js: -------------------------------------------------------------------------------- 1 | import slugify from 'slugify'; 2 | import _db from './_db'; 3 | import { SHORTCUTS } from './constants'; 4 | 5 | const ADMIN_PASSWORD = import.meta.env.VITE_ADMIN_PASSWORD; 6 | 7 | /** Use a singleton DB instance */ 8 | const db = _db.instance; 9 | 10 | /** 11 | * Creates a new draft 12 | */ 13 | export async function createArticle(title, content, teaser, currentUser) { 14 | if (!currentUser) throw new Error('Not authorized'); 15 | 16 | const slug = slugify(title, { 17 | lower: true, 18 | strict: true 19 | }); 20 | 21 | return await db.tx('create-article', async t => { 22 | let newArticle = await t.one( 23 | 'INSERT INTO articles (slug, title, content, teaser, published_at) values($1, $2, $3, $4, NOW()) RETURNING slug, created_at', 24 | [slug, title, content, teaser] 25 | ); 26 | return newArticle; 27 | }); 28 | } 29 | 30 | /** 31 | * We automatically extract a teaser text from the document's content. 32 | */ 33 | export async function updateArticle(slug, title, content, teaser, currentUser) { 34 | if (!currentUser) throw new Error('Not authorized'); 35 | return await db.tx('update-article', async t => { 36 | return await t.one( 37 | 'UPDATE articles SET title= $1, content = $2, teaser = $3, updated_at = NOW() WHERE slug = $4 RETURNING slug, updated_at', 38 | [title, content, teaser, slug] 39 | ); 40 | }); 41 | } 42 | 43 | /* 44 | This can be replaced with any user-based authentication system 45 | */ 46 | export async function authenticate(password, sessionTimeout) { 47 | return await db.tx('create-session', async t => { 48 | const expires = __getDateTimeMinutesAfter(sessionTimeout); 49 | if (password === ADMIN_PASSWORD) { 50 | const { sessionId } = await t.one( 51 | 'INSERT INTO sessions (expires) values($1) returning session_id', 52 | [expires] 53 | ); 54 | return { sessionId }; 55 | } else { 56 | throw 'Authentication failed.'; 57 | } 58 | }); 59 | } 60 | 61 | /* 62 | Log out of the admin session ... 63 | */ 64 | export async function destroySession(sessionId) { 65 | return await db.tx('destroy-session', async t => { 66 | await t.any('DELETE FROM sessions WHERE session_id = $1', [sessionId]); 67 | return true; 68 | }); 69 | } 70 | 71 | /** 72 | * List all available articles (newest first) 73 | */ 74 | export async function getArticles(currentUser) { 75 | return await db.tx('get-articles', async t => { 76 | let articles; 77 | if (currentUser) { 78 | // When logged in show both, drafts and published articles 79 | articles = await t.any( 80 | 'SELECT *, COALESCE(published_at, updated_at, created_at) AS modified_at FROM articles ORDER BY modified_at DESC' 81 | ); 82 | } else { 83 | articles = await t.any( 84 | 'SELECT * FROM articles WHERE published_at IS NOT NULL ORDER BY published_at DESC' 85 | ); 86 | } 87 | return articles; 88 | }); 89 | } 90 | 91 | /** 92 | * Given a slug, determine article to "read next" 93 | */ 94 | export async function getNextArticle(slug) { 95 | return db.tx('get-next-article', async t => { 96 | return t.oneOrNone( 97 | ` 98 | ( 99 | SELECT 100 | title, 101 | teaser, 102 | slug, 103 | published_at 104 | FROM articles 105 | WHERE 106 | published_at < (SELECT published_at FROM articles WHERE slug= $1) 107 | ORDER BY published_at DESC 108 | LIMIT 1 109 | ) 110 | UNION 111 | ( 112 | SELECT title, teaser, slug, published_at FROM articles ORDER BY published_at DESC LIMIT 1 113 | ) 114 | ORDER BY published_at ASC 115 | LIMIT 1; 116 | `, 117 | [slug] 118 | ); 119 | }); 120 | } 121 | 122 | /** 123 | * Search within all searchable items (including articles and website sections) 124 | */ 125 | export async function search(q, currentUser) { 126 | return await db.tx('search', async t => { 127 | let result; 128 | if (currentUser) { 129 | result = await t.any( 130 | "SELECT title AS name, CONCAT('/blog/', slug) AS url, COALESCE(published_at, updated_at, created_at) AS modified_at FROM articles WHERE title ILIKE $1 ORDER BY modified_at DESC", 131 | [`%${q}%`] 132 | ); 133 | } else { 134 | result = await t.any( 135 | "SELECT title AS name, CONCAT('/blog/', slug) AS url, COALESCE(published_at, updated_at, created_at) AS modified_at FROM articles WHERE title ILIKE $1 AND published_at IS NOT NULL ORDER BY modified_at DESC", 136 | [`%${q}%`] 137 | ); 138 | } 139 | 140 | // Also include prefined shortcuts in search 141 | SHORTCUTS.forEach(shortcut => { 142 | if (shortcut.name.toLowerCase().includes(q.toLowerCase())) { 143 | result.push(shortcut); 144 | } 145 | }); 146 | 147 | return result; 148 | }); 149 | } 150 | 151 | /** 152 | * Retrieve article based on a given slug 153 | */ 154 | export async function getArticleBySlug(slug) { 155 | return await db.tx('get-article-by-slug', async t => { 156 | const article = await t.one('SELECT * FROM articles WHERE slug = $1', [slug]); 157 | return article; 158 | }); 159 | } 160 | 161 | /** 162 | * Remove the entire article 163 | */ 164 | export async function deleteArticle(slug, currentUser) { 165 | if (!currentUser) throw new Error('Not authorized'); 166 | return await db.tx('delete-article', async t => { 167 | await t.any('DELETE FROM articles WHERE slug = $1', [slug]); 168 | return true; 169 | }); 170 | } 171 | 172 | /** 173 | * In this minimal setup there is only one user, the website admin. 174 | * If you want to support multiple users/authors you want to return the current user record here. 175 | */ 176 | export async function getCurrentUser(sessionId) { 177 | return await db.tx('get-current-user', async t => { 178 | const session = await t.oneOrNone('SELECT session_id FROM sessions WHERE session_id = $1', [ 179 | sessionId 180 | ]); 181 | if (session) { 182 | return { 183 | name: 'Admin' 184 | }; 185 | } else { 186 | return null; 187 | } 188 | }); 189 | } 190 | 191 | /** 192 | * Update the page 193 | */ 194 | export async function createOrUpdatePage(pageId, page, currentUser) { 195 | if (!currentUser) throw new Error('Not authorized'); 196 | return await db.tx('create-or-update-page', async t => { 197 | const pageExists = await t.oneOrNone('SELECT page_id FROM pages WHERE page_id = $1', [pageId]); 198 | if (pageExists) { 199 | return await t.one('UPDATE pages SET data = $1 WHERE page_id = $2 RETURNING page_id', [ 200 | page, 201 | pageId 202 | ]); 203 | } else { 204 | return await t.one('INSERT INTO pages (page_id, data) values($1, $2) RETURNING page_id', [ 205 | pageId, 206 | page 207 | ]); 208 | } 209 | }); 210 | } 211 | 212 | /** 213 | * E.g. getPage("home") gets all dynamic data for the home page 214 | */ 215 | export async function getPage(pageId) { 216 | return await db.tx('get-page', async t => { 217 | const page = await t.oneOrNone('SELECT data FROM pages WHERE page_id = $1', [pageId]); 218 | return page?.data; 219 | }); 220 | } 221 | 222 | /** 223 | * TODO: Turn this into a Postgres function 224 | */ 225 | export async function createOrUpdateCounter(counterId) { 226 | return await db.tx('create-or-update-counter', async t => { 227 | const counterExists = await t.oneOrNone( 228 | 'SELECT counter_id FROM counters WHERE counter_id = $1', 229 | [counterId] 230 | ); 231 | if (counterExists) { 232 | return await t.one( 233 | 'UPDATE counters SET count = count + 1 WHERE counter_id = $1 RETURNING count', 234 | [counterId] 235 | ); 236 | } else { 237 | return await t.one('INSERT INTO counters (counter_id, count) values($1, 1) RETURNING count', [ 238 | counterId 239 | ]); 240 | } 241 | }); 242 | } 243 | 244 | /** 245 | * Helpers 246 | */ 247 | function __getDateTimeMinutesAfter(minutes) { 248 | return new Date(new Date().getTime() + minutes * 60000).toISOString(); 249 | } 250 | -------------------------------------------------------------------------------- /src/lib/components/Article.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import PlainText from '$lib/components/PlainText.svelte'; 3 | import RichText from '$lib/components/RichText.svelte'; 4 | import { formatDate } from '$lib/util'; 5 | export let title; 6 | export let content; 7 | export let publishedAt = undefined; 8 | export let editable; 9 | </script> 10 | 11 | <div> 12 | <div class="max-w-screen-md mx-auto px-6"> 13 | <div class="pt-12 sm:pt-24"> 14 | {#if !publishedAt} 15 | <div class="font-bold text-sm">DRAFT</div> 16 | {:else} 17 | <div class="font-bold text-sm">{formatDate(publishedAt)}</div> 18 | {/if} 19 | </div> 20 | <h1 class="text-3xl md:text-5xl font-bold pt-1"> 21 | <PlainText {editable} bind:content={title} /> 22 | </h1> 23 | </div> 24 | </div> 25 | 26 | <div class="max-w-screen-md mx-auto px-6 pb-12 sm:pb-24"> 27 | <div id="article_content" class="prose sm:prose-xl"> 28 | <RichText multiLine {editable} bind:content /> 29 | </div> 30 | </div> 31 | -------------------------------------------------------------------------------- /src/lib/components/ArticleTeaser.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | export let article; 3 | export let firstEntry; 4 | import { classNames } from '$lib/util'; 5 | import SecondaryButton from './SecondaryButton.svelte'; 6 | </script> 7 | 8 | <div> 9 | <div 10 | class={classNames( 11 | 'max-w-screen-md mx-auto px-6 md:text-xl', 12 | firstEntry ? 'pt-2 pb-8 sm:pb-12' : 'py-6 sm:py-10' 13 | )} 14 | > 15 | <div class={classNames(article.publishedAt ? '' : 'opacity-50')}> 16 | <div> 17 | <a 18 | class={classNames('mb-12 text-2xl md:text-3xl font-bold')} 19 | href={`/blog/${article.slug}`} 20 | > 21 | {article.title} 22 | </a> 23 | </div> 24 | <div class="pt-2 pb-4"> 25 | <div class="line-clamp-4"> 26 | <a href={`/blog/${article.slug}`}> 27 | {article.teaser} 28 | </a> 29 | </div> 30 | </div> 31 | </div> 32 | <div class="pt-2"> 33 | <SecondaryButton size="sm" href={`/blog/${article.slug}`}> 34 | Continue reading&nbsp;→ 35 | </SecondaryButton> 36 | </div> 37 | </div> 38 | </div> 39 | -------------------------------------------------------------------------------- /src/lib/components/BaseButton.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | export let styles; 4 | export let size = 'default'; 5 | export let type = 'button'; 6 | export let href = undefined; 7 | export let disabled = false; 8 | const STYLE_SHARED = 9 | 'm-0 p-0 disabled:cursor-not-allowed disabled:opacity-50 rounded-full cursor-pointer focus:outline-none focus:ring-2 focus:ring-offset-2 font-sans no-underline text-center'; 10 | const STYLE_SIZES = { 11 | sm: `px-4 py-2 text-sm sm:text-base sm:px-4 sm:py-1`, 12 | default: `px-4 py-2 text-sm sm:text-base sm:px-5 sm:py-3`, 13 | lg: `px-8 sm:px-12 py-3 text-base sm:py-4 sm:text-xl` 14 | }; 15 | $: className = classNames(styles, STYLE_SHARED, STYLE_SIZES[size], disabled ? 'disabled' : ''); 16 | </script> 17 | 18 | {#if href} 19 | <a {href} class={className} {disabled}> 20 | <slot /> 21 | </a> 22 | {:else} 23 | <button {type} class={className} {disabled} on:click> 24 | <slot /> 25 | </button> 26 | {/if} 27 | -------------------------------------------------------------------------------- /src/lib/components/EditableWebsiteTeaser.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import PrimaryButton from './PrimaryButton.svelte'; 4 | </script> 5 | 6 | <div class={classNames('py-12 sm:py-24 border-t-2 border-gray-100')}> 7 | <div 8 | class="max-w-screen-md mx-auto px-6 flex flex-col sm:flex-row space-y-6 sm:space-x-8 sm:space-y-0" 9 | > 10 | <img 11 | class="flex-shrink-0 w-24 h-24 rounded-full" 12 | src="https://letsken.imgix.net/users/958dc1d9-de59-40ee-b814-b43885b3053f/27421e71f9e3ef6f828be3018eb69d74.jpg?fit=max&w=576&auto=format" 13 | alt="Michael Aufreiter" 14 | /> 15 | <div> 16 | <h2 class="text-3xl md:text-5xl font-bold">Hi, I'm Michael.</h2> 17 | <div class="mt-4 md:text-xl"> 18 | I want your website to be <strong>editable</strong>. 19 | </div> 20 | <div class="pt-8 sm:pt-12"> 21 | <PrimaryButton size="lg" href="/">Learn more</PrimaryButton> 22 | </div> 23 | </div> 24 | </div> 25 | </div> 26 | -------------------------------------------------------------------------------- /src/lib/components/EditorToolbar.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { activeEditorView } from '$lib/stores'; 3 | import { onDestroy } from 'svelte'; 4 | import ToggleMark from './tools/ToggleMark.svelte'; 5 | import ToggleBulletList from './tools/ToggleBulletList.svelte'; 6 | import ToggleBlockquote from './tools/ToggleBlockquote.svelte'; 7 | import ToggleOrderedList from './tools/ToggleOrderedList.svelte'; 8 | import PrimaryButton from './PrimaryButton.svelte'; 9 | import SecondaryButton from './SecondaryButton.svelte'; 10 | import { createEventDispatcher } from 'svelte'; 11 | import ToggleHeading from './tools/ToggleHeading.svelte'; 12 | import InsertImage from './tools/InsertImage.svelte'; 13 | import CreateLink from './tools/CreateLink.svelte'; 14 | import ToggleAICompletion from '$lib/components/tools/ToggleAICompletion.svelte'; 15 | import Modal from '$lib/components/Modal.svelte'; 16 | import { fetchJSON } from '$lib/util.js'; 17 | 18 | export let currentUser = undefined; 19 | 20 | let editorView = null; 21 | let editorState = null; 22 | let isPromptOpen = false; 23 | 24 | const unsubscribe = activeEditorView.subscribe(value => { 25 | editorView = value; 26 | editorState = value?.state; 27 | }); 28 | 29 | const dispatch = createEventDispatcher(); 30 | 31 | function handleCancel() { 32 | dispatch('cancel', {}); 33 | } 34 | 35 | function handleSave() { 36 | dispatch('save', {}); 37 | } 38 | 39 | onDestroy(unsubscribe); 40 | 41 | function onKeyDown(e) { 42 | // Trigger save 43 | if (e.key === 's' && e.metaKey) { 44 | dispatch('save', {}); 45 | e.preventDefault(); 46 | e.stopPropagation(); 47 | } 48 | } 49 | 50 | let prompt = ''; 51 | let generating; 52 | async function onGeneratePrompt() { 53 | const existingText = editorState.doc.textBetween( 54 | editorState.selection.from, 55 | editorState.selection.to 56 | ); 57 | const result = await fetchJSON('POST', `/api/generate`, { 58 | prompt, 59 | existingText 60 | }); 61 | const { tr } = editorView.state; 62 | tr.replaceSelectionWith(editorView.state.schema.text(result), true); 63 | editorView.dispatch(tr); 64 | } 65 | </script> 66 | 67 | {#if isPromptOpen} 68 | <Modal 69 | on:close={() => { 70 | editorView.focus(); 71 | isPromptOpen = false; 72 | }} 73 | > 74 | <div class="flex flex-col p-4"> 75 | <h2 class="text-lg font-medium text-gray-900">Generate AI text</h2> 76 | <p class="my-4 text-sm text-gray-500"> 77 | The current selected text will be replaced: 78 | {#if editorState} 79 | <strong> 80 | {editorState.doc.textBetween(editorState.selection.from, editorState.selection.to)} 81 | </strong> 82 | {/if} 83 | </p> 84 | <label for="prompt" class="font-medium text-gray-700">What would you like to generate?</label> 85 | <div class="mt-1"> 86 | <input 87 | type="text" 88 | name="prompt" 89 | id="prompt" 90 | class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md h-10" 91 | bind:value={prompt} 92 | /> 93 | </div> 94 | <div class="mt-4 flex items-center"> 95 | <PrimaryButton 96 | on:click={async () => { 97 | editorView.focus(); 98 | generating = onGeneratePrompt(); 99 | await generating; 100 | isPromptOpen = false; 101 | prompt = ''; 102 | }}>Generate</PrimaryButton 103 | > 104 | {#await generating} 105 | <!-- a tailwind spinner --> 106 | <div 107 | class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-gray-900 ml-4" 108 | /> 109 | {/await} 110 | </div> 111 | </div> 112 | </Modal> 113 | {/if} 114 | 115 | <div class="sticky top-0 z-10 sm:py-4 sm:px-4"> 116 | <div 117 | class="max-w-screen-lg mx-auto px-2 backdrop-blur-sm bg-white bg-opacity-95 border-b border-t sm:border sm:rounded-full border-gray-100 shadow" 118 | > 119 | <div> 120 | <div class="flex items-center overflow-x-auto py-3 px-1"> 121 | {#if editorState} 122 | <div class="flex"> 123 | <ToggleMark {editorState} {editorView} type="strong"> 124 | <svg 125 | class="h-3 w-3 sm:h-4 sm:w-4" 126 | xmlns="http://www.w3.org/2000/svg" 127 | fill="currentColor" 128 | stroke="currentColor" 129 | viewBox="0 0 384 512" 130 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 131 | d="M0 64C0 46.3 14.3 32 32 32H80 96 224c70.7 0 128 57.3 128 128c0 31.3-11.3 60.1-30 82.3c37.1 22.4 62 63.1 62 109.7c0 70.7-57.3 128-128 128H96 80 32c-17.7 0-32-14.3-32-32s14.3-32 32-32H48V256 96H32C14.3 96 0 81.7 0 64zM224 224c35.3 0 64-28.7 64-64s-28.7-64-64-64H112V224H224zM112 288V416H256c35.3 0 64-28.7 64-64s-28.7-64-64-64H224 112z" 132 | /></svg 133 | > 134 | </ToggleMark> 135 | <ToggleMark {editorState} {editorView} type="em"> 136 | <svg 137 | class="h-3 w-3 sm:h-4 sm:w-4" 138 | fill="currentColor" 139 | stroke="currentColor" 140 | xmlns="http://www.w3.org/2000/svg" 141 | viewBox="0 0 384 512" 142 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 143 | d="M128 64c0-17.7 14.3-32 32-32H352c17.7 0 32 14.3 32 32s-14.3 32-32 32H293.3L160 416h64c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H90.7L224 96H160c-17.7 0-32-14.3-32-32z" 144 | /></svg 145 | > 146 | </ToggleMark> 147 | <CreateLink {editorState} {editorView}> 148 | <svg 149 | class="h-3 w-3 sm:h-4 sm:w-4" 150 | fill="currentColor" 151 | stroke="currentColor" 152 | xmlns="http://www.w3.org/2000/svg" 153 | viewBox="0 0 640 512" 154 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 155 | d="M562.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L405.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C189.5 251.2 196 330 246 380c56.5 56.5 148 56.5 204.5 0L562.8 267.7zM43.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C57 372 57 321 88.5 289.5L200.7 177.2c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C416.5 260.8 410 182 360 132c-56.5-56.5-148-56.5-204.5 0L43.2 244.3z" 156 | /></svg 157 | > 158 | </CreateLink> 159 | <div class="hidden sm:block w-px bg-gray-300 mx-3" /> 160 | <ToggleHeading {editorState} {editorView}> 161 | <svg 162 | class="h-3 w-3 sm:h-4 sm:w-4" 163 | fill="currentColor" 164 | stroke="currentColor" 165 | xmlns="http://www.w3.org/2000/svg" 166 | viewBox="0 0 448 512" 167 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 168 | d="M0 64C0 46.3 14.3 32 32 32H80h48c17.7 0 32 14.3 32 32s-14.3 32-32 32H112V208H336V96H320c-17.7 0-32-14.3-32-32s14.3-32 32-32h48 48c17.7 0 32 14.3 32 32s-14.3 32-32 32H400V240 416h16c17.7 0 32 14.3 32 32s-14.3 32-32 32H368 320c-17.7 0-32-14.3-32-32s14.3-32 32-32h16V272H112V416h16c17.7 0 32 14.3 32 32s-14.3 32-32 32H80 32c-17.7 0-32-14.3-32-32s14.3-32 32-32H48V240 96H32C14.3 96 0 81.7 0 64z" 169 | /></svg 170 | > 171 | </ToggleHeading> 172 | <ToggleBlockquote {editorState} {editorView}> 173 | <svg 174 | class="h-3 w-3 sm:h-4 sm:w-4" 175 | fill="currentColor" 176 | stroke="currentColor" 177 | xmlns="http://www.w3.org/2000/svg" 178 | viewBox="0 0 448 512" 179 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 180 | d="M0 216C0 149.7 53.7 96 120 96h8c17.7 0 32 14.3 32 32s-14.3 32-32 32h-8c-30.9 0-56 25.1-56 56v8h64c35.3 0 64 28.7 64 64v64c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V320 288 216zm256 0c0-66.3 53.7-120 120-120h8c17.7 0 32 14.3 32 32s-14.3 32-32 32h-8c-30.9 0-56 25.1-56 56v8h64c35.3 0 64 28.7 64 64v64c0 35.3-28.7 64-64 64H320c-35.3 0-64-28.7-64-64V320 288 216z" 181 | /></svg 182 | > 183 | </ToggleBlockquote> 184 | <div class="hidden sm:block w-px bg-gray-300 mx-3" /> 185 | <ToggleBulletList {editorState} {editorView}> 186 | <svg 187 | class="h-3 w-3 sm:h-4 sm:w-4" 188 | fill="currentColor" 189 | stroke="currentColor" 190 | xmlns="http://www.w3.org/2000/svg" 191 | viewBox="0 0 512 512" 192 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 193 | d="M64 144a48 48 0 1 0 0-96 48 48 0 1 0 0 96zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H192zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H192zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H192zM64 464a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm48-208a48 48 0 1 0 -96 0 48 48 0 1 0 96 0z" 194 | /></svg 195 | > 196 | </ToggleBulletList> 197 | <ToggleOrderedList {editorState} {editorView}> 198 | <svg 199 | class="h-3 w-3 sm:h-4 sm:w-4" 200 | fill="currentColor" 201 | stroke="currentColor" 202 | xmlns="http://www.w3.org/2000/svg" 203 | viewBox="0 0 512 512" 204 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 205 | d="M24 56c0-13.3 10.7-24 24-24H80c13.3 0 24 10.7 24 24V176h16c13.3 0 24 10.7 24 24s-10.7 24-24 24H40c-13.3 0-24-10.7-24-24s10.7-24 24-24H56V80H48C34.7 80 24 69.3 24 56zM86.7 341.2c-6.5-7.4-18.3-6.9-24 1.2L51.5 357.9c-7.7 10.8-22.7 13.3-33.5 5.6s-13.3-22.7-5.6-33.5l11.1-15.6c23.7-33.2 72.3-35.6 99.2-4.9c21.3 24.4 20.8 60.9-1.1 84.7L86.8 432H120c13.3 0 24 10.7 24 24s-10.7 24-24 24H32c-9.5 0-18.2-5.6-22-14.4s-2.1-18.9 4.3-25.9l72-78c5.3-5.8 5.4-14.6 .3-20.5zM224 64H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H224c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 160H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H224c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 160H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H224c-17.7 0-32-14.3-32-32s14.3-32 32-32z" 206 | /></svg 207 | > 208 | </ToggleOrderedList> 209 | <div class="hidden sm:block w-px bg-gray-300 mx-3" /> 210 | <InsertImage {currentUser} {editorState} {editorView}> 211 | <svg 212 | class="h-3 w-3 sm:h-4 sm:w-4" 213 | fill="currentColor" 214 | stroke="currentColor" 215 | xmlns="http://www.w3.org/2000/svg" 216 | viewBox="0 0 512 512" 217 | ><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path 218 | d="M0 96C0 60.7 28.7 32 64 32H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6h96 32H424c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z" 219 | /></svg 220 | > 221 | </InsertImage> 222 | <div class="hidden sm:block w-px bg-gray-300 mx-3" /> 223 | <ToggleAICompletion {editorState} {editorView} bind:isPromptOpen> 224 | <svg 225 | xmlns="http://www.w3.org/2000/svg" 226 | fill="none" 227 | viewBox="0 0 24 24" 228 | stroke-width="1.5" 229 | stroke="currentColor" 230 | class="h-3 w-3 sm:h-4 sm:w-4" 231 | > 232 | <path 233 | stroke-linecap="round" 234 | stroke-linejoin="round" 235 | d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" 236 | /> 237 | </svg> 238 | </ToggleAICompletion> 239 | </div> 240 | {/if} 241 | 242 | <div class="flex-1 h-8" /> 243 | <SecondaryButton type="button" on:click={handleCancel}>Cancel</SecondaryButton> 244 | <div class="shrink-0 w-2 sm:w-4" /> 245 | <PrimaryButton type="button" on:click={handleSave}>Save</PrimaryButton> 246 | </div> 247 | </div> 248 | </div> 249 | </div> 250 | 251 | <svelte:window on:keydown={onKeyDown} /> 252 | -------------------------------------------------------------------------------- /src/lib/components/Footer.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { onMount } from 'svelte'; 3 | import { fetchJSON } from '$lib/util'; 4 | import NotEditable from '$lib/components/NotEditable.svelte'; 5 | 6 | export let counter; 7 | export let editable = false; 8 | let count; 9 | 10 | onMount(async () => { 11 | if (counter) { 12 | const result = await fetchJSON('GET', `/api/counter?c=${counter}`); 13 | count = result.count; 14 | } 15 | }); 16 | </script> 17 | 18 | <NotEditable {editable}> 19 | <div class="bg-white font-medium"> 20 | <div class="max-w-screen-md mx-auto px-6 py-5 flex space-x-8 text-sm"> 21 | <a href="/">About</a> 22 | <a href="/blog">Blog</a> 23 | <a href="/#contact">Contact</a> 24 | <a href="/imprint">Imprint</a> 25 | {#if count} 26 | <div class="flex-1" /> 27 | <div class="text-xs font-normal flex items-center space-x-2"> 28 | <svg 29 | xmlns="http://www.w3.org/2000/svg" 30 | fill="none" 31 | viewBox="0 0 24 24" 32 | stroke-width="1.5" 33 | stroke="currentColor" 34 | class="w-4 h-4" 35 | > 36 | <path 37 | stroke-linecap="round" 38 | stroke-linejoin="round" 39 | d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" 40 | /> 41 | <path 42 | stroke-linecap="round" 43 | stroke-linejoin="round" 44 | d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" 45 | /> 46 | </svg> 47 | <span>{count}</span> 48 | </div> 49 | {/if} 50 | </div> 51 | </div> 52 | </NotEditable> 53 | -------------------------------------------------------------------------------- /src/lib/components/Image.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import ImageEditor from './ImageEditor.svelte'; 3 | 4 | export let editable; 5 | export let currentUser; 6 | export let src; 7 | export let alt; 8 | export let maxWidth; 9 | export let maxHeight; 10 | export let quality; 11 | let className = ''; 12 | export { className as class }; 13 | </script> 14 | 15 | {#if editable} 16 | <ImageEditor {currentUser} class={className} bind:src {alt} {maxWidth} {maxHeight} {quality} /> 17 | {:else} 18 | <img class={className} {src} {alt} /> 19 | {/if} 20 | -------------------------------------------------------------------------------- /src/lib/components/ImageEditor.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import uuid from '$lib/uuid'; 3 | import { resizeImage } from '$lib/util'; 4 | import uploadAsset from '$lib/uploadAsset'; 5 | 6 | export let currentUser; 7 | export let src; 8 | export let alt; 9 | export let maxWidth; 10 | export let maxHeight; 11 | export let quality; 12 | let className = ''; 13 | export { className as class }; 14 | 15 | const ASSET_PATH = import.meta.env.VITE_ASSET_PATH; 16 | 17 | let fileInput; // for uploading an image 18 | let progress = undefined; // file upload progress 19 | 20 | async function uploadImage() { 21 | const file = fileInput.files[0]; 22 | 23 | // We convert all uploads to the WEBP image format 24 | const extension = 'webp'; 25 | const path = [['editable-website', 'images', uuid()].join('/'), extension].join('.'); 26 | 27 | const resizedBlob = await resizeImage(file, maxWidth, maxHeight, quality); 28 | const resizedFile = new File([resizedBlob], `${file.name.split('.')[0]}.webp`, { 29 | type: 'image/webp' 30 | }); 31 | 32 | progress = 0; 33 | try { 34 | if (currentUser) { 35 | await uploadAsset(resizedFile, path, p => { 36 | progress = p; 37 | }); 38 | src = `${ASSET_PATH}/${path}`; 39 | } else { 40 | src = URL.createObjectURL(file); 41 | } 42 | progress = undefined; 43 | } catch (err) { 44 | console.error(err); 45 | alert('An error occured. Please try again'); 46 | progress = undefined; 47 | } 48 | fileInput.value = null; 49 | } 50 | </script> 51 | 52 | <img on:mousedown={() => fileInput.click()} class={className + ' cursor-pointer'} {src} {alt} /> 53 | 54 | <input 55 | class="w-px h-px opacity-0 fixed -top-40" 56 | type="file" 57 | accept="image/*" 58 | name="imagefile" 59 | bind:this={fileInput} 60 | on:change={uploadImage} 61 | /> 62 | -------------------------------------------------------------------------------- /src/lib/components/Input.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | export let value = ''; 3 | export let id; 4 | export let type = 'text'; 5 | export let name; 6 | export let required = false; 7 | export let inputRef = null; 8 | export let placeholder = ''; 9 | function setType(node) { 10 | node.type = type; 11 | } 12 | </script> 13 | 14 | <input 15 | autocomplete="off" 16 | use:setType 17 | {placeholder} 18 | {name} 19 | {id} 20 | {required} 21 | bind:value 22 | bind:this={inputRef} 23 | class="border focus focus:border-gray-800 focus:ring-gray-800" 24 | /> 25 | -------------------------------------------------------------------------------- /src/lib/components/IntroStep.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import PlainText from './PlainText.svelte'; 3 | import RichText from './RichText.svelte'; 4 | 5 | export let intro; 6 | export let editable; 7 | </script> 8 | 9 | <div class="my-12"> 10 | <div class="bg-white relative py-8 mt-20 mb-20"> 11 | <div class="font-bold text-center text-sm sm:text-base"> 12 | <PlainText {editable} bind:content={intro.label} /> 13 | </div> 14 | <div class="text-2xl md:text-5xl font-bold text-center pt-2"> 15 | <PlainText {editable} bind:content={intro.title} /> 16 | </div> 17 | <div class="max-w-md mx-auto text-lg md:text-2xl text-center pt-2 md:pt-4"> 18 | <RichText {editable} bind:content={intro.description} /> 19 | </div> 20 | </div> 21 | </div> 22 | -------------------------------------------------------------------------------- /src/lib/components/Limiter.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | export let noPadding = false; 4 | </script> 5 | 6 | <div class={classNames('max-w-lg mx-auto', noPadding ? '' : 'px-4')}> 7 | <slot /> 8 | </div> 9 | -------------------------------------------------------------------------------- /src/lib/components/LoginMenu.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { goto } from '$app/navigation'; 3 | import PrimaryButton from './PrimaryButton.svelte'; 4 | export let currentUser; 5 | </script> 6 | 7 | <div> 8 | <div class="py-4 text-center">Signed in as {currentUser.name}</div> 9 | <div class="flex flex-col"> 10 | <PrimaryButton on:click={() => goto('/logout')}>Sign out</PrimaryButton> 11 | </div> 12 | </div> 13 | -------------------------------------------------------------------------------- /src/lib/components/Modal.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { createEventDispatcher, onMount, onDestroy } from 'svelte'; 3 | import { browser } from '$app/environment'; 4 | import { classNames } from '$lib/util'; 5 | 6 | // Only relevant for mobile 7 | export let position = 'bottom'; 8 | 9 | const dispatch = createEventDispatcher(); 10 | let surface; 11 | onMount(async () => { 12 | window.document.children[0].style = 'overflow: hidden;'; 13 | }); 14 | onDestroy(() => { 15 | if (browser) { 16 | window.document.children[0].style = ''; 17 | } 18 | }); 19 | function onMouseUp(e) { 20 | if (e.target === surface) dispatch('close'); 21 | } 22 | </script> 23 | 24 | <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 25 | <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> 26 | 27 | <div class="fixed inset-0 z-50 overflow-y-auto" on:mouseup={onMouseUp}> 28 | <div 29 | bind:this={surface} 30 | class={classNames( 31 | 'flex min-h-full justify-center p-4 text-center sm:items-center sm:p-0', 32 | position === 'bottom' ? 'items-end' : 'items-start' 33 | )} 34 | > 35 | <div 36 | class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl sm:my-8 w-full sm:max-w-lg" 37 | > 38 | <slot /> 39 | </div> 40 | </div> 41 | </div> 42 | </div> 43 | -------------------------------------------------------------------------------- /src/lib/components/NotEditable.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | 4 | export let editable; 5 | </script> 6 | 7 | <div class={classNames(editable ? 'opacity-25 cursor-not-allowed relative' : '')}> 8 | {#if editable} 9 | <div class="absolute inset-0 z-50" /> 10 | {/if} 11 | <slot /> 12 | </div> 13 | -------------------------------------------------------------------------------- /src/lib/components/PlainText.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import PlainTextEditor from './PlainTextEditor.svelte'; 3 | 4 | export let editable; 5 | export let content; 6 | export let multiLine = false; 7 | </script> 8 | 9 | {#if editable} 10 | <PlainTextEditor bind:content {multiLine} /> 11 | {:else} 12 | {@html content} 13 | {/if} 14 | -------------------------------------------------------------------------------- /src/lib/components/PlainTextEditor.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { onMount, onDestroy } from 'svelte'; 3 | import { toHTML, fromHTML } from '$lib/prosemirrorUtil'; 4 | import { singleLinePlainTextSchema, multiLinePlainTextSchema } from '$lib/prosemirrorSchemas'; 5 | import { activeEditorView } from '$lib/stores'; 6 | import { EditorState, Plugin } from 'prosemirror-state'; 7 | import { EditorView } from 'prosemirror-view'; 8 | import { history } from 'prosemirror-history'; 9 | import { keymap } from 'prosemirror-keymap'; 10 | import { baseKeymap } from 'prosemirror-commands'; 11 | import { buildKeymap } from '$lib/prosemirrorKeymap'; 12 | 13 | export let content = ''; 14 | export let multiLine = false; 15 | 16 | let editorChange = false; 17 | let prosemirrorNode, editorView, editorState; 18 | 19 | $: schema = multiLine ? multiLinePlainTextSchema : singleLinePlainTextSchema; 20 | 21 | $: { 22 | const doc = fromHTML(schema, content); 23 | editorState = EditorState.create({ 24 | doc, 25 | schema, 26 | plugins: [keymap(buildKeymap(schema)), keymap(baseKeymap), history(), onUpdatePlugin] 27 | }); 28 | // Only if there is already an editorView and the content change was external 29 | // update editorView with the new editorState 30 | if (!editorChange) { 31 | editorView?.updateState(editorState); 32 | } else { 33 | editorChange = false; 34 | } 35 | } 36 | 37 | function dispatchTransaction(transaction) { 38 | const editorState = this.state.apply(transaction); 39 | this.updateState(editorState); 40 | if (transaction.docChanged) { 41 | content = toHTML(editorState); 42 | // Leave a hint so we know the last content update came 43 | // from the editor (not the parent) 44 | editorChange = true; 45 | } 46 | this.state = editorState; 47 | } 48 | 49 | const onUpdatePlugin = new Plugin({ 50 | view() { 51 | return { 52 | update(updatedView) { 53 | activeEditorView.set(updatedView); 54 | } 55 | }; 56 | } 57 | }); 58 | 59 | onMount(() => { 60 | editorView = new EditorView(prosemirrorNode, { 61 | state: editorState, 62 | dispatchTransaction 63 | }); 64 | activeEditorView.set(editorView); 65 | }); 66 | 67 | onDestroy(() => { 68 | // Guard on server side 69 | if (editorView) { 70 | editorView.destroy(); 71 | } 72 | }); 73 | </script> 74 | 75 | <div id="prosemirror-editor" bind:this={prosemirrorNode} /> 76 | 77 | <style> 78 | :global(#prosemirror-editor .ProseMirror) { 79 | outline: none; 80 | white-space: pre-wrap; 81 | word-wrap: break-word; 82 | } 83 | </style> 84 | -------------------------------------------------------------------------------- /src/lib/components/PrimaryButton.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import BaseButton from '$lib/components/BaseButton.svelte'; 3 | export let disabled = undefined; 4 | export let type = 'button'; 5 | export let size = undefined; 6 | export let href = undefined; 7 | </script> 8 | 9 | <BaseButton 10 | {type} 11 | {size} 12 | {disabled} 13 | {href} 14 | styles="font-medium hover:bg-gray-800 focus:ring-gray-900 border-2 border-transparent bg-gray-900 text-white" 15 | on:click 16 | > 17 | <slot /> 18 | </BaseButton> 19 | -------------------------------------------------------------------------------- /src/lib/components/RichText.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import RichTextEditor from './RichTextEditor.svelte'; 3 | 4 | export let editable; 5 | export let content; 6 | export let multiLine = false; 7 | </script> 8 | 9 | {#if editable} 10 | <RichTextEditor {multiLine} bind:content /> 11 | {:else} 12 | <div> 13 | {@html content} 14 | </div> 15 | {/if} 16 | -------------------------------------------------------------------------------- /src/lib/components/RichTextEditor.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { onMount, onDestroy } from 'svelte'; 3 | import { toHTML, fromHTML } from '$lib/prosemirrorUtil'; 4 | import { singleLineRichTextSchema, multiLineRichTextSchema } from '$lib/prosemirrorSchemas'; 5 | import { activeEditorView } from '$lib/stores'; 6 | import { EditorState, Plugin } from 'prosemirror-state'; 7 | import { EditorView } from 'prosemirror-view'; 8 | import { history } from 'prosemirror-history'; 9 | import { keymap } from 'prosemirror-keymap'; 10 | import { baseKeymap } from 'prosemirror-commands'; 11 | import { buildKeymap } from '$lib/prosemirrorKeymap'; 12 | import { buildInputRules } from '$lib/prosemirrorInputrules'; 13 | 14 | export let content = '<p>Enter text.</p>'; 15 | export let multiLine = false; 16 | let editorChange = false; 17 | let prosemirrorNode, editorView, editorState; 18 | 19 | $: schema = multiLine ? multiLineRichTextSchema : singleLineRichTextSchema; 20 | $: { 21 | const doc = fromHTML(schema, content); 22 | editorState = EditorState.create({ 23 | doc, 24 | schema, 25 | plugins: [ 26 | buildInputRules(schema), 27 | keymap(buildKeymap(schema)), 28 | keymap(baseKeymap), 29 | history(), 30 | onUpdatePlugin 31 | ] 32 | }); 33 | // Only if there is already an editorView and the content change was external 34 | // update editorView with the new editorState 35 | if (!editorChange) { 36 | editorView?.updateState(editorState); 37 | } else { 38 | editorChange = false; 39 | } 40 | } 41 | 42 | function dispatchTransaction(transaction) { 43 | const editorState = this.state.apply(transaction); 44 | this.updateState(editorState); 45 | if (transaction.docChanged) { 46 | content = toHTML(editorState); 47 | // Leave a hint so we know the last content update came 48 | // from the editor (not the parent) 49 | editorChange = true; 50 | } 51 | this.state = editorState; 52 | } 53 | 54 | const onUpdatePlugin = new Plugin({ 55 | view() { 56 | return { 57 | update(updatedView) { 58 | activeEditorView.set(updatedView); 59 | } 60 | }; 61 | } 62 | }); 63 | 64 | onMount(() => { 65 | editorView = new EditorView(prosemirrorNode, { 66 | state: editorState, 67 | dispatchTransaction 68 | }); 69 | activeEditorView.set(editorView); 70 | }); 71 | onDestroy(() => { 72 | // Guard on server side 73 | if (editorView) { 74 | editorView.destroy(); 75 | } 76 | }); 77 | </script> 78 | 79 | <div id="prosemirror-editor" bind:this={prosemirrorNode} /> 80 | 81 | <style> 82 | :global(#prosemirror-editor .ProseMirror) { 83 | outline: none; 84 | white-space: pre-wrap; 85 | word-wrap: break-word; 86 | } 87 | </style> 88 | -------------------------------------------------------------------------------- /src/lib/components/Search.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { onMount } from 'svelte'; 3 | import { debounce, classNames } from '$lib/util'; 4 | import { SHORTCUTS } from '$lib/constants'; 5 | import { goto } from '$app/navigation'; 6 | 7 | export let showSearch; 8 | let value; 9 | let result = SHORTCUTS; 10 | let selectedResult = 0; 11 | let input; 12 | let resultsEl; 13 | 14 | onMount(() => { 15 | input.focus(); 16 | }); 17 | 18 | async function search() { 19 | if (value) { 20 | const response = await fetch(`/api/search?q=${value}`); 21 | result = await response.json(); 22 | } else { 23 | result = SHORTCUTS; 24 | } 25 | selectedResult = 0; 26 | } 27 | 28 | function navigate() { 29 | const currentResult = result[selectedResult]; 30 | if (currentResult) { 31 | goto(currentResult.url); 32 | } 33 | showSearch = false; 34 | } 35 | 36 | function prevResult() { 37 | if (selectedResult > 0) { 38 | selectedResult -= 1; 39 | } 40 | scrollIntoViewIfNeeded(); 41 | } 42 | 43 | function nextResult() { 44 | if (selectedResult < result.length - 1) { 45 | selectedResult += 1; 46 | } 47 | scrollIntoViewIfNeeded(); 48 | } 49 | 50 | function scrollIntoViewIfNeeded() { 51 | let node = resultsEl.childNodes[selectedResult]; 52 | if (node.scrollIntoViewIfNeeded) { 53 | node.scrollIntoViewIfNeeded(); 54 | } 55 | } 56 | 57 | function onKeyDown(e) { 58 | switch (e.keyCode) { 59 | case 38: // up 60 | prevResult(); 61 | e.preventDefault(); 62 | break; 63 | case 40: // down 64 | nextResult(); 65 | e.preventDefault(); 66 | break; 67 | case 13: 68 | navigate(); 69 | break; 70 | } 71 | } 72 | </script> 73 | 74 | <div class="relative border-b border-gray-100 flex space-x-4 items-center px-4 sm:px-6 py-2"> 75 | <svg 76 | xmlns="http://www.w3.org/2000/svg" 77 | fill="none" 78 | viewBox="0 0 24 24" 79 | stroke-width="1.5" 80 | stroke="currentColor" 81 | class="w-6 h-6" 82 | > 83 | <path 84 | stroke-linecap="round" 85 | stroke-linejoin="round" 86 | d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" 87 | /> 88 | </svg> 89 | <input 90 | bind:this={input} 91 | bind:value 92 | use:debounce={{ value, func: search, duration: 50 }} 93 | autocomplete="off" 94 | id="search" 95 | name="search" 96 | class="block w-full border-none bg-transparent px-0 py-2 placeholder-gray-300 focus:border-black focus:text-gray-900 focus:placeholder-gray-400 focus:outline-none focus:ring-0" 97 | placeholder="Search website ..." 98 | type="text" 99 | /> 100 | <button 101 | class="bg-gray-100 rounded-md px-4 py-2 text-xs font-bold text-gray-600 hover:text-gray-900" 102 | on:click={() => (showSearch = false)}>ESC</button 103 | > 104 | </div> 105 | 106 | {#if result.length > 0} 107 | <div class="font-bold text-sm px-4 sm:px-6 py-4 border-b border-gray-100"> 108 | {value ? 'BEST MATCHES' : 'SHORTCUTS'} 109 | </div> 110 | {/if} 111 | <div class="overflow-y-auto" bind:this={resultsEl}> 112 | {#each result as item, i} 113 | <a 114 | on:click={() => (showSearch = false)} 115 | class={classNames( 116 | 'block px-4 sm:px-6 py-3 border-b border-gray-100 text-gray-600 hover:text-black', 117 | selectedResult === i ? 'bg-gray-100' : '' 118 | )} 119 | href={item.url}>{item.name}</a 120 | > 121 | {/each} 122 | </div> 123 | 124 | <svelte:window on:keydown={onKeyDown} /> 125 | -------------------------------------------------------------------------------- /src/lib/components/SecondaryButton.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import BaseButton from '$lib/components/BaseButton.svelte'; 3 | export let disabled = undefined; 4 | export let type = 'button'; 5 | export let size = undefined; 6 | export let href = undefined; 7 | </script> 8 | 9 | <BaseButton 10 | {href} 11 | {type} 12 | {size} 13 | {disabled} 14 | styles="font-medium hover:bg-gray-100 focus:ring-gray-100 border-2 border-gray-100 bg-white" 15 | on:click 16 | > 17 | <slot /> 18 | </BaseButton> 19 | -------------------------------------------------------------------------------- /src/lib/components/Testimonial.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import PlainText from './PlainText.svelte'; 3 | import { classNames } from '$lib/util'; 4 | import Image from './Image.svelte'; 5 | import { createEventDispatcher } from 'svelte'; 6 | 7 | const dispatch = createEventDispatcher(); 8 | 9 | export let testimonial; 10 | export let currentUser; 11 | export let editable; 12 | export let firstEntry = false; 13 | export let lastEntry = false; 14 | </script> 15 | 16 | <div class={classNames(firstEntry ? 'pt-2 pb-8 sm:pb-12' : 'py-8 sm:py-12')}> 17 | <div class="max-w-screen-md mx-auto px-6 flex space-x-6 sm:space-x-8 relative"> 18 | <Image 19 | class="flex-shrink-0 w-14 h-14 sm:w-20 sm:h-20 rounded-full" 20 | maxWidth={160} 21 | maxHeight={160} 22 | quality={0.8} 23 | {editable} 24 | {currentUser} 25 | bind:src={testimonial.image} 26 | alt={testimonial.name} 27 | /> 28 | <div class="flex-1"> 29 | <div class="text-lg sm:text-2xl italic"> 30 | <PlainText {editable} bind:content={testimonial.text} /> 31 | </div> 32 | <div class="mt-4 md:text-xl font-medium"> 33 | <PlainText {editable} bind:content={testimonial.name} /> 34 | </div> 35 | </div> 36 | {#if editable} 37 | <div class="space-y-2 flex flex-col"> 38 | <button 39 | class="w-6 h-6 p-1 rounded-full bg-gray-900 hover:bg-gray-800 text-white" 40 | on:click={() => dispatch('delete')} 41 | > 42 | <svg 43 | xmlns="http://www.w3.org/2000/svg" 44 | fill="none" 45 | viewBox="0 0 24 24" 46 | stroke-width="1.5" 47 | stroke="currentColor" 48 | class="w-4 h-4" 49 | > 50 | <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> 51 | </svg> 52 | </button> 53 | <button 54 | class={classNames( 55 | 'w-6 h-6 p-1 rounded-full hover:bg-gray-100', 56 | firstEntry ? 'opacity-20' : '' 57 | )} 58 | on:click={() => dispatch('up')} 59 | > 60 | <svg 61 | xmlns="http://www.w3.org/2000/svg" 62 | fill="none" 63 | viewBox="0 0 24 24" 64 | stroke-width="1.5" 65 | stroke="currentColor" 66 | class="w-4 h-4" 67 | > 68 | <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> 69 | </svg> 70 | </button> 71 | <button 72 | class={classNames( 73 | 'w-6 h-6 p-1 rounded-full hover:bg-gray-100', 74 | lastEntry ? 'opacity-20' : '' 75 | )} 76 | on:click={() => dispatch('down')} 77 | > 78 | <svg 79 | xmlns="http://www.w3.org/2000/svg" 80 | fill="none" 81 | viewBox="0 0 24 24" 82 | stroke-width="1.5" 83 | stroke="currentColor" 84 | class="w-4 h-4" 85 | > 86 | <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> 87 | </svg> 88 | </button> 89 | </div> 90 | {/if} 91 | </div> 92 | </div> 93 | -------------------------------------------------------------------------------- /src/lib/components/Toggle.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | export let checked = false; 4 | export let size = 'default'; 5 | $: className = classNames( 6 | 'relative inline-flex flex-shrink-0 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600', 7 | checked ? 'bg-indigo-600' : 'bg-slate-300', 8 | size === 'default' ? 'h-5 w-10' : 'w-8' 9 | ); 10 | $: innerClassName = classNames( 11 | checked ? (size === 'default' ? 'translate-x-5' : 'translate-x-4') : 'translate-x-0', 12 | 'pointer-events-none inline-block rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200', 13 | size === 'default' ? 'h-4 w-4' : 'h-3 w-3' 14 | ); 15 | function toggle() { 16 | checked = !checked; 17 | } 18 | </script> 19 | 20 | <div class="inline-flex items-center"> 21 | <button class={className} on:click={toggle}> 22 | <div class={innerClassName} /> 23 | </button> 24 | <div class="px-2"><slot /></div> 25 | </div> 26 | -------------------------------------------------------------------------------- /src/lib/components/WebsiteNav.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import Modal from './Modal.svelte'; 4 | import NotEditable from './NotEditable.svelte'; 5 | import Search from './Search.svelte'; 6 | export let editable = false; 7 | export let currentUser; 8 | 9 | // TODO: Replace with a globally managed context menu implementation (only one active) 10 | export let showUserMenu = undefined; 11 | export let showSearch = undefined; 12 | 13 | function onKeyDown(e) { 14 | // Close modals 15 | if (e.key === 'Escape') { 16 | showSearch = false; 17 | showUserMenu = false; 18 | } 19 | // Trigger the search panel 20 | if (e.key === 'k' && e.metaKey) { 21 | showSearch = true; 22 | } 23 | // Turn on editing 24 | if (e.key === 'e' && e.metaKey) { 25 | editable = true; 26 | } 27 | } 28 | </script> 29 | 30 | {#if showSearch} 31 | <Modal position="top" on:close={() => (showSearch = false)}> 32 | <Search bind:showSearch /> 33 | </Modal> 34 | {/if} 35 | 36 | <div 37 | class={classNames( 38 | 'backdrop-blur-sm bg-white bg-opacity-95 transition-colors duration-500 z-10 text-sm', 39 | !editable ? 'sticky top-0' : '' 40 | )} 41 | > 42 | <div class="max-w-xs mx-auto py-4"> 43 | <NotEditable {editable}> 44 | <div class="flex items-center relative"> 45 | <div class="flex-1" /> 46 | <button class={classNames('mr-6 hover:text-black')} on:click={() => (showSearch = true)}> 47 | <svg 48 | xmlns="http://www.w3.org/2000/svg" 49 | fill="none" 50 | viewBox="0 0 24 24" 51 | stroke-width="1.5" 52 | stroke="currentColor" 53 | class="w-6 h-6" 54 | > 55 | <path 56 | stroke-linecap="round" 57 | stroke-linejoin="round" 58 | d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" 59 | /> 60 | </svg> 61 | </button> 62 | <a class="mr-4 font-medium px-2 py-1 rounded-md hover:text-black" href="/"> About </a> 63 | <a class="mr-4 font-medium px-2 py-1 rounded-md hover:text-black" href="/blog"> Blog </a> 64 | <a class="mr-4 font-medium px-2 py-1 rounded-md hover:text-black" href="/#contact"> 65 | Contact 66 | </a> 67 | <div class="flex-1" /> 68 | {#if currentUser} 69 | <button 70 | on:click={() => (showUserMenu = !showUserMenu)} 71 | class="ml-0 hover:text-black" 72 | title={currentUser.name} 73 | > 74 | <svg 75 | xmlns="http://www.w3.org/2000/svg" 76 | fill="none" 77 | viewBox="0 0 24 24" 78 | stroke-width="1.5" 79 | stroke="currentColor" 80 | class="w-6 h-6" 81 | > 82 | <path 83 | stroke-linecap="round" 84 | stroke-linejoin="round" 85 | d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z" 86 | /> 87 | </svg> 88 | </button> 89 | {/if} 90 | <div class="flex-1" /> 91 | </div> 92 | </NotEditable> 93 | </div> 94 | </div> 95 | 96 | <svelte:window on:keydown={onKeyDown} /> 97 | -------------------------------------------------------------------------------- /src/lib/components/tools/CreateLink.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import { toggleMark } from 'prosemirror-commands'; 4 | import { createLink } from '$lib/prosemirrorCommands'; 5 | 6 | export let editorView; 7 | export let editorState; 8 | 9 | $: schema = editorState.schema; 10 | $: disabled = !createLink(editorState, null, editorView); 11 | 12 | function handleClick() { 13 | let url = prompt('Enter link URL', 'https://example.com'); 14 | if (url) { 15 | toggleMark(schema.marks.link, { href: url })(editorState, editorView.dispatch); 16 | editorView.focus(); 17 | } 18 | } 19 | </script> 20 | 21 | <button 22 | on:click={handleClick} 23 | {disabled} 24 | class={classNames('disabled:opacity-30 rounded-full p-2 sm:mx-1 hover:bg-gray-100')} 25 | > 26 | <slot /> 27 | </button> 28 | -------------------------------------------------------------------------------- /src/lib/components/tools/InsertImage.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames, resizeImage, getDimensions } from '$lib/util'; 3 | import uuid from '$lib/uuid'; 4 | import uploadAsset from '$lib/uploadAsset'; 5 | import { insertImage } from '$lib/prosemirrorCommands'; 6 | 7 | const ASSET_PATH = import.meta.env.VITE_ASSET_PATH; 8 | 9 | export let editorView; 10 | export let editorState; 11 | export let currentUser; 12 | 13 | let fileInput; // for uploading an image 14 | let progress = undefined; // file upload progress 15 | 16 | $: schema = editorState.schema; 17 | $: disabled = !insertImage(editorState, null, editorView); 18 | 19 | async function uploadImage() { 20 | const file = fileInput.files[0]; 21 | 22 | // We convert all uploads to the WEBP image format 23 | const extension = 'webp'; 24 | const path = [['editable-website', 'images', uuid()].join('/'), extension].join('.'); 25 | 26 | const maxWidth = 1440; 27 | const maxHeight = 1440; 28 | const quality = 0.8; 29 | 30 | const resizedBlob = await resizeImage(file, maxWidth, maxHeight, quality); 31 | const resizedFile = new File([resizedBlob], `${file.name.split('.')[0]}.webp`, { 32 | type: 'image/webp' 33 | }); 34 | 35 | const { width, height } = await getDimensions(resizedFile); 36 | const src = currentUser ? `${ASSET_PATH}/${path}` : URL.createObjectURL(resizedFile); 37 | 38 | progress = 0; 39 | try { 40 | if (currentUser) { 41 | await uploadAsset(resizedFile, path, p => { 42 | progress = p; 43 | }); 44 | } 45 | 46 | editorView.dispatch( 47 | editorState.tr.replaceSelectionWith( 48 | schema.nodes.image.createAndFill({ 49 | src, 50 | width, 51 | height 52 | }) 53 | ) 54 | ); 55 | editorView.focus(); 56 | progress = undefined; 57 | } catch (err) { 58 | console.error(err); 59 | progress = undefined; 60 | } 61 | fileInput.value = null; 62 | } 63 | </script> 64 | 65 | <input 66 | class="w-px h-px opacity-0 fixed -top-40" 67 | type="file" 68 | accept="image/*" 69 | name="imagefile" 70 | multiple 71 | bind:this={fileInput} 72 | on:change={uploadImage} 73 | /> 74 | <button 75 | on:click={() => fileInput.click()} 76 | {disabled} 77 | class={classNames('hover:bg-gray-100 sm:mx-1 rounded-full p-2 disabled:opacity-30')} 78 | > 79 | <slot /> 80 | {progress || ''} 81 | </button> 82 | -------------------------------------------------------------------------------- /src/lib/components/tools/ToggleAICompletion.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import { anythingSelected } from '$lib/prosemirrorCommands.js'; 4 | 5 | export let editorView; 6 | export let editorState; 7 | export let isPromptOpen; 8 | $: disabled = !anythingSelected(editorState); 9 | 10 | $: schema = editorState.schema; 11 | 12 | function handleClick() { 13 | isPromptOpen = !isPromptOpen; 14 | editorView.focus(); 15 | } 16 | </script> 17 | 18 | <button 19 | on:click={handleClick} 20 | {disabled} 21 | class={classNames( 22 | isPromptOpen ? 'bg-gray-900 text-white' : 'hover:bg-gray-100', 23 | 'sm:mx-1 rounded-full p-2 disabled:opacity-30' 24 | )} 25 | > 26 | <slot /> 27 | </button> 28 | -------------------------------------------------------------------------------- /src/lib/components/tools/ToggleBlockquote.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import { wrapIn } from 'prosemirror-commands'; 4 | 5 | export let editorView; 6 | export let editorState; 7 | 8 | $: schema = editorState.schema; 9 | $: disabled = !wrapIn(schema.nodes.blockquote)(editorView.state); 10 | 11 | function handleClick() { 12 | wrapIn(schema.nodes.blockquote)(editorState, editorView.dispatch); 13 | editorView.focus(); 14 | } 15 | </script> 16 | 17 | <button 18 | on:click={handleClick} 19 | {disabled} 20 | class={classNames('disabled:opacity-30 rounded-full sm:mx-1 p-2 hover:bg-gray-100')} 21 | > 22 | <slot /> 23 | </button> 24 | -------------------------------------------------------------------------------- /src/lib/components/tools/ToggleBulletList.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import { wrapInList } from 'prosemirror-schema-list'; 4 | 5 | export let editorView; 6 | export let editorState; 7 | 8 | $: schema = editorState.schema; 9 | $: disabled = !wrapInList(schema.nodes.bullet_list)(editorView.state); 10 | 11 | function handleClick() { 12 | wrapInList(schema.nodes.bullet_list)(editorState, editorView.dispatch); 13 | editorView.focus(); 14 | } 15 | </script> 16 | 17 | <button 18 | on:click={handleClick} 19 | {disabled} 20 | class={classNames('disabled:opacity-30 rounded-full p-2 sm:mx-1 hover:bg-gray-100')} 21 | > 22 | <slot /> 23 | </button> 24 | -------------------------------------------------------------------------------- /src/lib/components/tools/ToggleHeading.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import { setBlockType } from 'prosemirror-commands'; 4 | import { blockTypeActive } from '../../prosemirrorUtil'; 5 | 6 | export let editorView; 7 | export let editorState; 8 | 9 | $: schema = editorState.schema; 10 | $: disabled = 11 | !setBlockType(schema.nodes.heading)(editorState) && 12 | !setBlockType(schema.nodes.paragraph)(editorState); 13 | $: active = blockTypeActive(schema.nodes.heading, { level: 1 })(editorState); 14 | 15 | function handleClick() { 16 | if (active) { 17 | setBlockType(schema.nodes.paragraph)(editorState, editorView.dispatch); 18 | } else { 19 | setBlockType(schema.nodes.heading, { level: 1 })(editorState, editorView.dispatch); 20 | } 21 | editorView.focus(); 22 | } 23 | </script> 24 | 25 | <button 26 | on:click={handleClick} 27 | {disabled} 28 | class={classNames( 29 | active ? 'bg-gray-900 text-white' : 'hover:bg-gray-100', 30 | 'sm:mx-1 rounded-full p-2 disabled:opacity-30' 31 | )} 32 | > 33 | <slot /> 34 | </button> 35 | -------------------------------------------------------------------------------- /src/lib/components/tools/ToggleMark.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { toggleMark } from 'prosemirror-commands'; 3 | import { markActive } from '$lib/prosemirrorUtil'; 4 | import { classNames } from '$lib/util'; 5 | 6 | export let editorView; 7 | export let editorState; 8 | export let type; 9 | 10 | $: schema = editorState.schema; 11 | $: markType = schema.marks[type]; 12 | 13 | $: command = toggleMark(markType); 14 | $: disabled = !markType || !command(editorState, null); 15 | $: active = markActive(markType)(editorState); 16 | 17 | function handleClick() { 18 | command(editorState, editorView.dispatch, editorView); 19 | editorView.focus(); 20 | } 21 | </script> 22 | 23 | <button 24 | on:click={handleClick} 25 | {disabled} 26 | class={classNames( 27 | active ? 'bg-gray-900 text-white' : 'hover:bg-gray-100', 28 | 'sm:mx-1 rounded-full p-2 disabled:opacity-30' 29 | )} 30 | > 31 | <slot /> 32 | </button> 33 | -------------------------------------------------------------------------------- /src/lib/components/tools/ToggleOrderedList.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { classNames } from '$lib/util'; 3 | import { wrapInList } from 'prosemirror-schema-list'; 4 | 5 | export let editorView; 6 | export let editorState; 7 | 8 | $: schema = editorState.schema; 9 | $: disabled = !wrapInList(schema.nodes.ordered_list)(editorView.state); 10 | 11 | function handleClick() { 12 | wrapInList(schema.nodes.ordered_list)(editorState, editorView.dispatch); 13 | editorView.focus(); 14 | } 15 | </script> 16 | 17 | <button 18 | on:click={handleClick} 19 | {disabled} 20 | class={classNames('disabled:opacity-30 rounded-full p-2 sm:mx-1 hover:bg-gray-100')} 21 | > 22 | <slot /> 23 | </button> 24 | -------------------------------------------------------------------------------- /src/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const SHORTCUTS = [ 2 | { name: 'About', url: '/' }, 3 | { name: 'Blog', url: '/blog' }, 4 | { name: 'Contact', url: '/#contact' }, 5 | { name: 'Imprint', url: '/imprint' }, 6 | { name: 'Login', url: '/login' } 7 | ]; 8 | -------------------------------------------------------------------------------- /src/lib/prosemirrorCommands.js: -------------------------------------------------------------------------------- 1 | import { markApplies, canInsert } from '$lib/prosemirrorUtil'; 2 | 3 | export function createLink(state /*, dispatch, cb*/) { 4 | const schema = state.schema; 5 | const markType = schema.marks.link; 6 | if (!markType) return false; 7 | const { $cursor, ranges, from, to } = state.selection; 8 | const allowed = markApplies(state.doc, ranges, markType); 9 | const hasLink = state.doc.rangeHasMark(from, to, markType); 10 | // Disable if either the cursor is collapsed, the mark does not apply or is already present 11 | if ($cursor || !allowed || hasLink) return false; 12 | return true; 13 | } 14 | 15 | export function insertImage(state /*, dispatch, editorView, src*/) { 16 | const nodeType = state.schema.nodes.image; 17 | if (!nodeType) return false; 18 | if (!canInsert(state, nodeType)) return false; 19 | return true; 20 | } 21 | 22 | export function anythingSelected(state) { 23 | return !state.selection.empty; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/prosemirrorInputrules.js: -------------------------------------------------------------------------------- 1 | import { 2 | inputRules, 3 | wrappingInputRule, 4 | textblockTypeInputRule, 5 | smartQuotes, 6 | emDash, 7 | ellipsis 8 | } from 'prosemirror-inputrules'; 9 | 10 | // Given a blockquote node type, returns an input rule that turns `"> "` 11 | // at the start of a textblock into a blockquote. 12 | export function blockQuoteRule(nodeType) { 13 | return wrappingInputRule(/^\s*>\s$/, nodeType); 14 | } 15 | 16 | // Given a list node type, returns an input rule that turns a number 17 | // followed by a dot at the start of a textblock into an ordered list. 18 | export function orderedListRule(nodeType) { 19 | return wrappingInputRule( 20 | /^(\d+)\.\s$/, 21 | nodeType, 22 | match => ({ order: +match[1] }), 23 | (match, node) => node.childCount + node.attrs.order == +match[1] 24 | ); 25 | } 26 | 27 | // Given a list node type, returns an input rule that turns a bullet 28 | // (dash, plush, or asterisk) at the start of a textblock into a 29 | // bullet list. 30 | export function bulletListRule(nodeType) { 31 | return wrappingInputRule(/^\s*([-+*])\s$/, nodeType); 32 | } 33 | 34 | // Given a code block node type, returns an input rule that turns a 35 | // textblock starting with three backticks into a code block. 36 | export function codeBlockRule(nodeType) { 37 | return textblockTypeInputRule(/^```$/, nodeType); 38 | } 39 | 40 | // Given a node type and a maximum level, creates an input rule that 41 | // turns up to that number of `#` characters followed by a space at 42 | // the start of a textblock into a heading whose level corresponds to 43 | // the number of `#` signs. 44 | export function headingRule(nodeType, maxLevel) { 45 | return textblockTypeInputRule(new RegExp('^(#{1,' + maxLevel + '})\\s$'), nodeType, match => ({ 46 | level: match[1].length 47 | })); 48 | } 49 | 50 | // A set of input rules for creating the basic block quotes, lists, 51 | // code blocks, and heading. 52 | export function buildInputRules(schema) { 53 | let rules = smartQuotes.concat(ellipsis, emDash), 54 | type; 55 | if ((type = schema.nodes.blockquote)) rules.push(blockQuoteRule(type)); 56 | if ((type = schema.nodes.ordered_list)) rules.push(orderedListRule(type)); 57 | if ((type = schema.nodes.bullet_list)) rules.push(bulletListRule(type)); 58 | if ((type = schema.nodes.code_block)) rules.push(codeBlockRule(type)); 59 | if ((type = schema.nodes.heading)) rules.push(headingRule(type, 6)); 60 | return inputRules({ rules }); 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/prosemirrorKeymap.js: -------------------------------------------------------------------------------- 1 | import { 2 | wrapIn, 3 | setBlockType, 4 | chainCommands, 5 | toggleMark, 6 | exitCode, 7 | joinUp, 8 | joinDown, 9 | lift, 10 | selectParentNode 11 | } from 'prosemirror-commands'; 12 | import { wrapInList, splitListItem } from 'prosemirror-schema-list'; 13 | import { undo, redo } from 'prosemirror-history'; 14 | import { undoInputRule } from 'prosemirror-inputrules'; 15 | 16 | const mac = typeof navigator != 'undefined' ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : false; 17 | 18 | /// Inspect the given schema looking for marks and nodes from the 19 | /// basic schema, and if found, add key bindings related to them. 20 | /// This will add: 21 | /// 22 | /// * **Mod-b** for toggling [strong](#schema-basic.StrongMark) 23 | /// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark) 24 | /// * **Mod-`** for toggling [code font](#schema-basic.CodeMark) 25 | /// * **Ctrl-Shift-0** for making the current textblock a paragraph 26 | /// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current 27 | /// textblock a heading of the corresponding level 28 | /// * **Ctrl-Shift-Backslash** to make the current textblock a code block 29 | /// * **Ctrl-Shift-8** to wrap the selection in an ordered list 30 | /// * **Ctrl-Shift-9** to wrap the selection in a bullet list 31 | /// * **Ctrl->** to wrap the selection in a block quote 32 | /// * **Enter** to split a non-empty textblock in a list item while at 33 | /// the same time splitting the list item 34 | /// * **Mod-Enter** to insert a hard break 35 | /// * **Mod-_** to insert a horizontal rule 36 | /// * **Backspace** to undo an input rule 37 | /// * **Alt-ArrowUp** to `joinUp` 38 | /// * **Alt-ArrowDown** to `joinDown` 39 | /// * **Mod-BracketLeft** to `lift` 40 | /// * **Escape** to `selectParentNode` 41 | /// 42 | /// You can suppress or map these bindings by passing a `mapKeys` 43 | /// argument, which maps key names (say `"Mod-B"` to either `false`, to 44 | /// remove the binding, or a new key name string. 45 | export function buildKeymap(schema, mapKeys) { 46 | let keys = {}, 47 | type; 48 | function bind(key, cmd) { 49 | if (mapKeys) { 50 | let mapped = mapKeys[key]; 51 | if (mapped === false) return; 52 | if (mapped) key = mapped; 53 | } 54 | keys[key] = cmd; 55 | } 56 | 57 | bind('Mod-z', undo); 58 | bind('Shift-Mod-z', redo); 59 | bind('Backspace', undoInputRule); 60 | if (!mac) bind('Mod-y', redo); 61 | 62 | bind('Alt-ArrowUp', joinUp); 63 | bind('Alt-ArrowDown', joinDown); 64 | bind('Mod-BracketLeft', lift); 65 | bind('Escape', selectParentNode); 66 | 67 | if ((type = schema.marks.strong)) { 68 | bind('Mod-b', toggleMark(type)); 69 | bind('Mod-B', toggleMark(type)); 70 | } 71 | if ((type = schema.marks.em)) { 72 | bind('Mod-i', toggleMark(type)); 73 | bind('Mod-I', toggleMark(type)); 74 | } 75 | if ((type = schema.marks.code)) bind('Mod-`', toggleMark(type)); 76 | 77 | if ((type = schema.nodes.bullet_list)) bind('Shift-Ctrl-8', wrapInList(type)); 78 | if ((type = schema.nodes.ordered_list)) bind('Shift-Ctrl-9', wrapInList(type)); 79 | if ((type = schema.nodes.blockquote)) bind('Ctrl->', wrapIn(type)); 80 | if ((type = schema.nodes.hard_break)) { 81 | let br = type, 82 | cmd = chainCommands(exitCode, (state, dispatch) => { 83 | if (dispatch) dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView()); 84 | return true; 85 | }); 86 | bind('Mod-Enter', cmd); 87 | bind('Shift-Enter', cmd); 88 | if (mac) bind('Ctrl-Enter', cmd); 89 | } 90 | if ((type = schema.nodes.list_item)) { 91 | bind('Enter', splitListItem(type)); 92 | } 93 | if ((type = schema.nodes.paragraph)) bind('Shift-Ctrl-0', setBlockType(type)); 94 | if ((type = schema.nodes.code_block)) bind('Shift-Ctrl-\\', setBlockType(type)); 95 | if ((type = schema.nodes.heading)) 96 | for (let i = 1; i <= 6; i++) bind('Shift-Ctrl-' + i, setBlockType(type, { level: i })); 97 | if ((type = schema.nodes.horizontal_rule)) { 98 | let hr = type; 99 | bind('Mod-_', (state, dispatch) => { 100 | if (dispatch) dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); 101 | return true; 102 | }); 103 | } 104 | 105 | return keys; 106 | } 107 | -------------------------------------------------------------------------------- /src/lib/prosemirrorSchemas.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'prosemirror-model'; 2 | 3 | const pDOM = ['p', 0]; 4 | const blockquoteDOM = ['blockquote', 0]; 5 | const brDOM = ['br']; 6 | const olDOM = ['ol', 0]; 7 | const ulDOM = ['ul', 0]; 8 | const liDOM = ['li', 0]; 9 | const emDOM = ['em', 0]; 10 | const strongDOM = ['strong', 0]; 11 | 12 | // :: Object 13 | // [Specs](#model.NodeSpec) for the nodes defined in this schema. 14 | 15 | // :: Object [Specs](#model.MarkSpec) for the marks in the schema. 16 | export const marks = { 17 | // :: MarkSpec A link. Has `href` and `title` attributes. `title` 18 | // defaults to the empty string. Rendered and parsed as an `<a>` 19 | // element. 20 | link: { 21 | attrs: { 22 | href: {}, 23 | title: { default: null } 24 | }, 25 | inclusive: false, 26 | parseDOM: [ 27 | { 28 | tag: 'a[href]', 29 | getAttrs(dom) { 30 | return { href: dom.getAttribute('href'), title: dom.getAttribute('title') }; 31 | } 32 | } 33 | ], 34 | toDOM(node) { 35 | const { href, title } = node.attrs; 36 | return ['a', { href, title }, 0]; 37 | } 38 | }, 39 | 40 | // :: MarkSpec An emphasis mark. Rendered as an `<em>` element. 41 | // Has parse rules that also match `<i>` and `font-style: italic`. 42 | em: { 43 | parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], 44 | toDOM() { 45 | return emDOM; 46 | } 47 | }, 48 | 49 | // :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules 50 | // also match `<b>` and `font-weight: bold`. 51 | strong: { 52 | parseDOM: [ 53 | { tag: 'strong' }, 54 | // This works around a Google Docs misbehavior where 55 | // pasted content will be inexplicably wrapped in `<b>` 56 | // tags with a font-weight normal. 57 | { tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null }, 58 | { style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null } 59 | ], 60 | toDOM() { 61 | return strongDOM; 62 | } 63 | } 64 | }; 65 | 66 | /** 67 | * Schema to represent a single line of plain text 68 | * @type {Schema} 69 | */ 70 | export const singleLinePlainTextSchema = new Schema({ 71 | nodes: { 72 | doc: { content: 'text*' }, 73 | text: { inline: true } 74 | } 75 | }); 76 | 77 | /** 78 | * Schema to represent a single line of plain text 79 | * @type {Schema} 80 | */ 81 | export const singleLineRichTextSchema = new Schema({ 82 | nodes: { 83 | doc: { content: 'text*' }, 84 | text: { inline: true } 85 | }, 86 | marks 87 | }); 88 | 89 | /** 90 | * Schema to represent rich text 91 | * @type {Schema} 92 | */ 93 | export const multiLineRichTextSchema = new Schema({ 94 | nodes: { 95 | // :: NodeSpec The top level document node. 96 | doc: { 97 | content: 'block+' 98 | }, 99 | 100 | // :: NodeSpec A plain paragraph textblock. Represented in the DOM 101 | // as a `<p>` element. 102 | paragraph: { 103 | content: 'inline*', 104 | group: 'block', 105 | parseDOM: [{ tag: 'p' }], 106 | toDOM() { 107 | return pDOM; 108 | } 109 | }, 110 | 111 | // :: NodeSpec 112 | // An ordered list [node spec](#model.NodeSpec). Has a single 113 | // attribute, `order`, which determines the number at which the list 114 | // starts counting, and defaults to 1. Represented as an `<ol>` 115 | // element. 116 | ordered_list: { 117 | content: 'list_item+', 118 | group: 'block', 119 | attrs: { order: { default: 1 } }, 120 | parseDOM: [ 121 | { 122 | tag: 'ol', 123 | getAttrs(dom) { 124 | return { order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1 }; 125 | } 126 | } 127 | ], 128 | toDOM(node) { 129 | return node.attrs.order === 1 ? olDOM : ['ol', { start: node.attrs.order }, 0]; 130 | } 131 | }, 132 | 133 | // :: NodeSpec 134 | // A bullet list node spec, represented in the DOM as `<ul>`. 135 | bullet_list: { 136 | content: 'list_item+', 137 | group: 'block', 138 | parseDOM: [{ tag: 'ul' }], 139 | toDOM() { 140 | return ulDOM; 141 | } 142 | }, 143 | 144 | // :: NodeSpec 145 | // A list item (`<li>`) spec. 146 | list_item: { 147 | content: 'paragraph+', // only allow one or more paragraphs 148 | // content: 'paragraph (orderedList | bulletList | paragraph)*', 149 | parseDOM: [{ tag: 'li' }], 150 | toDOM() { 151 | return liDOM; 152 | }, 153 | defining: true 154 | }, 155 | 156 | // :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks. 157 | blockquote: { 158 | content: 'paragraph+', 159 | group: 'block', 160 | defining: true, 161 | parseDOM: [{ tag: 'blockquote' }], 162 | toDOM() { 163 | return blockquoteDOM; 164 | } 165 | }, 166 | 167 | // :: NodeSpec A heading textblock, with a `level` attribute that 168 | // should hold the number 1 to 6. Parsed and serialized as `<h1>` to 169 | // `<h6>` elements. 170 | heading: { 171 | attrs: { level: { default: 1 } }, 172 | content: 'inline*', 173 | marks: '', 174 | group: 'block', 175 | defining: true, 176 | parseDOM: [ 177 | { 178 | tag: 'h2', 179 | getAttrs() { 180 | return { level: 1 }; 181 | } 182 | }, 183 | { 184 | tag: 'h3', 185 | getAttrs() { 186 | return { level: 2 }; 187 | } 188 | }, 189 | { 190 | tag: 'h4', 191 | getAttrs() { 192 | return { level: 3 }; 193 | } 194 | } 195 | ], 196 | toDOM(node) { 197 | return ['h' + (parseInt(node.attrs.level) + 1), {}, 0]; 198 | } 199 | }, 200 | 201 | // :: NodeSpec The text node. 202 | text: { 203 | group: 'inline' 204 | }, 205 | 206 | // :: NodeSpec An inline image (`<img>`) node. Supports `src`, 207 | // `alt`, and `href` attributes. The latter two default to the empty 208 | // string. 209 | image: { 210 | // inline: true, 211 | attrs: { 212 | src: {}, 213 | width: {}, 214 | height: {} 215 | }, 216 | group: 'block', 217 | draggable: true, 218 | parseDOM: [ 219 | { 220 | tag: 'img', 221 | getAttrs(dom) { 222 | return { 223 | src: dom.getAttribute('src'), 224 | width: dom.getAttribute('width'), 225 | height: dom.getAttribute('height') 226 | }; 227 | } 228 | } 229 | ], 230 | toDOM(node) { 231 | const { src, width, height } = node.attrs; 232 | return [ 233 | 'img', 234 | { 235 | src: src, 236 | width: width, 237 | height: height 238 | } 239 | ]; 240 | } 241 | }, 242 | 243 | // :: NodeSpec A hard line break, represented in the DOM as `<br>`. 244 | hard_break: { 245 | inline: true, 246 | group: 'inline', 247 | selectable: false, 248 | parseDOM: [{ tag: 'br' }], 249 | toDOM() { 250 | return brDOM; 251 | } 252 | } 253 | }, 254 | marks 255 | }); 256 | 257 | /** 258 | * Schema to represent rich text 259 | * @type {Schema} 260 | */ 261 | export const multiLinePlainTextSchema = new Schema({ 262 | nodes: { 263 | // :: NodeSpec The top level document node. 264 | doc: { 265 | content: 'block+' 266 | }, 267 | 268 | // :: NodeSpec A plain paragraph textblock. Represented in the DOM 269 | // as a `<p>` element. 270 | paragraph: { 271 | content: 'inline*', 272 | group: 'block', 273 | parseDOM: [{ tag: 'p' }], 274 | toDOM() { 275 | return pDOM; 276 | } 277 | }, 278 | 279 | // :: NodeSpec The text node. 280 | text: { 281 | group: 'inline' 282 | }, 283 | 284 | // :: NodeSpec A hard line break, represented in the DOM as `<br>`. 285 | hard_break: { 286 | inline: true, 287 | group: 'inline', 288 | selectable: false, 289 | parseDOM: [{ tag: 'br' }], 290 | toDOM() { 291 | return brDOM; 292 | } 293 | } 294 | } 295 | }); 296 | -------------------------------------------------------------------------------- /src/lib/prosemirrorUtil.js: -------------------------------------------------------------------------------- 1 | import { DOMSerializer, DOMParser } from 'prosemirror-model'; 2 | 3 | /** 4 | * Converts editor state to an HTML string 5 | * @param editorState 6 | * @returns {string} 7 | */ 8 | export function toHTML(editorState) { 9 | const serializer = DOMSerializer.fromSchema(editorState.schema); 10 | const fragment = serializer.serializeFragment(editorState.doc.content); 11 | const node = document.createElement('div'); 12 | node.append(fragment); 13 | return node.innerHTML; 14 | } 15 | 16 | /** 17 | * Converts the editor state to plain text 18 | * @param editorState 19 | * @return {string} 20 | */ 21 | export function toPlainText(editorState) { 22 | if (editorState.doc.childCount === 0) { 23 | return ''; 24 | } else if (editorState.doc.childCount === 1) { 25 | return editorState.doc.textContent; 26 | } else { 27 | let paragraphs = []; 28 | for (let i = 0; i < editorState.doc.childCount; i++) { 29 | paragraphs.push(editorState.doc.child(i).textContent); 30 | } 31 | return paragraphs.join('\n'); 32 | } 33 | } 34 | 35 | export function fromHTML(schema, content) { 36 | const doc = document.implementation.createHTMLDocument(); 37 | doc.body.innerHTML = content; 38 | return DOMParser.fromSchema(schema).parse(doc.body); 39 | } 40 | 41 | export function markActive(type) { 42 | return function (state) { 43 | const { from, $from, to, empty } = state.selection; 44 | if (!type) return false; // mark might not be available in current schema 45 | if (empty) return type.isInSet(state.storedMarks || $from.marks()); 46 | else return state.doc.rangeHasMark(from, to, type); 47 | }; 48 | } 49 | 50 | export function canInsert(state, nodeType) { 51 | const $from = state.selection.$from; 52 | for (let d = $from.depth; d >= 0; d--) { 53 | const index = $from.index(d); 54 | if ($from.node(d).canReplaceWith(index, index, nodeType)) return true; 55 | } 56 | return false; 57 | } 58 | 59 | export function markApplies(doc, ranges, type) { 60 | for (let i = 0; i < ranges.length; i++) { 61 | const { $from, $to } = ranges[i]; 62 | let can = $from.depth === 0 ? doc.type.allowsMarkType(type) : false; 63 | doc.nodesBetween($from.pos, $to.pos, node => { 64 | if (can) return false; 65 | can = node.inlineContent && node.type.allowsMarkType(type); 66 | }); 67 | if (can) return true; 68 | } 69 | return false; 70 | } 71 | 72 | // Returns true when cursor (collapsed or not) is inside a link 73 | export function linkActive(type) { 74 | return function (state) { 75 | const { from, to } = state.selection; 76 | return state.doc.rangeHasMark(from, to, type); 77 | }; 78 | } 79 | 80 | export function blockTypeActive(type, attrs) { 81 | return function (state) { 82 | // HACK: we fill in the id attribute if present, so the comparison works 83 | const dynAttrs = Object.assign({}, attrs); 84 | const { $from, to, node } = state.selection; 85 | if (node) { 86 | if (node.attrs.id) { 87 | dynAttrs.id = node.attrs.id; 88 | } 89 | return node.hasMarkup(type, dynAttrs); 90 | } 91 | if ($from.parent && $from.parent.attrs.id) { 92 | dynAttrs.id = $from.parent.attrs.id; 93 | } 94 | const result = to <= $from.end() && $from.parent.hasMarkup(type, dynAttrs); 95 | return result; 96 | }; 97 | } 98 | 99 | // Returns the first mark found for a given type 100 | // TODO: currently this doesn't covers the case where a link has just one character 101 | export function getMarkAtCurrentSelection(type) { 102 | return function (state) { 103 | const { $from } = state.selection; 104 | return $from.marks().find(m => m.type === type); 105 | }; 106 | } 107 | 108 | export function markExtend($start, mark) { 109 | let startIndex = $start.index(); 110 | let endIndex = $start.indexAfter(); 111 | 112 | while (startIndex > 0 && mark.isInSet($start.parent.child(startIndex - 1).marks)) { 113 | startIndex--; 114 | } 115 | while (endIndex < $start.parent.childCount && mark.isInSet($start.parent.child(endIndex).marks)) { 116 | endIndex++; 117 | } 118 | 119 | let startPos = $start.start(); 120 | let endPos = startPos; 121 | for (let i = 0; i < endIndex; i++) { 122 | const size = $start.parent.child(i).nodeSize; 123 | if (i < startIndex) startPos += size; 124 | endPos += size; 125 | } 126 | return { from: startPos, to: endPos }; 127 | } 128 | -------------------------------------------------------------------------------- /src/lib/stores.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const activeEditorView = writable(null); 4 | -------------------------------------------------------------------------------- /src/lib/uploadAsset.js: -------------------------------------------------------------------------------- 1 | async function getSignedUrl(path, type) { 2 | const response = await fetch(`/api/presignedurl?path=${path}&type=${type}`, { 3 | method: 'GET', 4 | headers: { 5 | Accept: 'application/json' 6 | } 7 | }); 8 | const { signedUrl } = await response.json(); 9 | return signedUrl; 10 | } 11 | 12 | function uploadS3(url, file, progressCallback) { 13 | return new Promise(function (resolve, reject) { 14 | const xhr = new XMLHttpRequest(); 15 | xhr.onreadystatechange = () => { 16 | if (xhr.readyState === 4) { 17 | if (xhr.status === 200) { 18 | resolve(xhr); 19 | } else { 20 | reject(xhr); 21 | } 22 | } 23 | }; 24 | 25 | if (progressCallback) { 26 | xhr.upload.onprogress = e => { 27 | if (e.lengthComputable) { 28 | const percentComplete = (e.loaded / file.size) * 100; 29 | progressCallback(parseInt(percentComplete, 10)); 30 | } 31 | }; 32 | } 33 | 34 | xhr.open('put', url); 35 | xhr.setRequestHeader('Content-Type', file.type); 36 | xhr.setRequestHeader('x-amz-acl', 'public-read'); 37 | xhr.send(file); 38 | }); 39 | } 40 | 41 | export default async function uploadAsset(file, path, onProgress) { 42 | const signedUrl = await getSignedUrl(path, file.type); 43 | await uploadS3(signedUrl, file, onProgress); 44 | return path; 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/util.js: -------------------------------------------------------------------------------- 1 | export function classNames(...classes) { 2 | return classes.filter(Boolean).join(' '); 3 | } 4 | 5 | /*! 6 | Math.uuid.js (v1.4) 7 | http://www.broofa.com 8 | mailto:robert@broofa.com 9 | Copyright (c) 2010 Robert Kieffer 10 | Dual licensed under the MIT and GPL licenses. 11 | */ 12 | 13 | /** 14 | * Generates a unique id. 15 | * 16 | * @param {String} [prefix] if provided the UUID will be prefixed. 17 | * @param {Number} [len] if provided a UUID with given length will be created. 18 | * @return A generated uuid. 19 | */ 20 | export function uuid(prefix, len) { 21 | if (prefix && prefix[prefix.length - 1] !== '-') { 22 | prefix = prefix.concat('-'); 23 | } 24 | const chars = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); 25 | const uuid = []; 26 | const radix = 16; 27 | let idx; 28 | len = len || 32; 29 | if (len) { 30 | // Compact form 31 | for (idx = 0; idx < len; idx++) uuid[idx] = chars[0 | (Math.random() * radix)]; 32 | } else { 33 | // rfc4122, version 4 form 34 | let r; 35 | // rfc4122 requires these characters 36 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; 37 | uuid[14] = '4'; 38 | // Fill in random data. At i==19 set the high bits of clock sequence as 39 | // per rfc4122, sec. 4.1.5 40 | for (idx = 0; idx < 36; idx++) { 41 | if (!uuid[idx]) { 42 | r = 0 | (Math.random() * 16); 43 | uuid[idx] = chars[idx === 19 ? (r & 0x3) | 0x8 : r]; 44 | } 45 | } 46 | } 47 | return (prefix || '') + uuid.join(''); 48 | } 49 | 50 | export function formatDate(dateString, withTime) { 51 | const date = new Date(dateString); 52 | if (withTime) { 53 | if (date.toDateString() === new Date().toDateString()) { 54 | // on same day, only show the time 55 | return date.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }); 56 | } else { 57 | return date.toLocaleDateString('en-US', { 58 | month: 'short', 59 | day: 'numeric', 60 | year: 'numberic', 61 | hour: 'numeric', 62 | minute: 'numeric', 63 | hour12: true 64 | }); 65 | } 66 | } else { 67 | return date.toLocaleDateString('en-US', { 68 | month: 'short', 69 | day: 'numeric', 70 | year: 'numeric' 71 | }); 72 | } 73 | } 74 | 75 | export function debounce(node, params) { 76 | let timer; 77 | 78 | return { 79 | update() { 80 | clearTimeout(timer); 81 | timer = setTimeout(params.func, params.duration); 82 | }, 83 | destroy() { 84 | clearTimeout(timer); 85 | } 86 | }; 87 | } 88 | 89 | export function extractTeaser(body) { 90 | const teaser = [...body.querySelectorAll('p')].map(n => n.textContent).join(' '); 91 | if (teaser.length > 512) { 92 | return teaser.slice(0, 512).concat('…'); 93 | } else { 94 | return teaser; 95 | } 96 | } 97 | 98 | export function resizeImage(file, maxWidth, maxHeight, quality) { 99 | const reader = new FileReader(); 100 | reader.readAsDataURL(file); 101 | return new Promise((resolve, reject) => { 102 | reader.onload = event => { 103 | const image = new Image(); 104 | image.src = event.target.result; 105 | image.onload = () => { 106 | let width = image.width; 107 | let height = image.height; 108 | let newWidth = width; 109 | let newHeight = height; 110 | if (width > maxWidth) { 111 | newWidth = maxWidth; 112 | newHeight = (height * maxWidth) / width; 113 | } 114 | if (newHeight > maxHeight) { 115 | newWidth = (newWidth * maxHeight) / newHeight; 116 | newHeight = maxHeight; 117 | } 118 | const canvas = document.createElement('canvas'); 119 | canvas.width = newWidth; 120 | canvas.height = newHeight; 121 | const context = canvas.getContext('2d'); 122 | context.drawImage(image, 0, 0, newWidth, newHeight); 123 | canvas.toBlob( 124 | blob => { 125 | resolve(blob); 126 | }, 127 | file.type, 128 | quality 129 | ); 130 | }; 131 | image.onerror = error => { 132 | reject(error); 133 | }; 134 | }; 135 | reader.onerror = error => { 136 | reject(error); 137 | }; 138 | }); 139 | } 140 | /** 141 | * Get image dimensions from a file 142 | */ 143 | export async function getDimensions(file) { 144 | return new Promise((resolve, reject) => { 145 | const img = new window.Image(); 146 | img.onload = function () { 147 | resolve({ width: this.width, height: this.height }); 148 | }; 149 | img.onerror = function () { 150 | reject(img.error); 151 | }; 152 | img.src = URL.createObjectURL(file); 153 | }); 154 | } 155 | 156 | export async function fetchJSON(method, url, data = undefined) { 157 | const response = await fetch(url, { 158 | method: method, 159 | body: JSON.stringify(data), 160 | headers: { 161 | 'content-type': 'application/json' 162 | } 163 | }); 164 | if (!response.ok) throw new Error(response.statusText); 165 | const result = await response.json(); 166 | return result; 167 | } 168 | 169 | export async function askGPT(prompt, existingText) { 170 | const response = await fetch('https://api.openai.com/v1/chat/completions', { 171 | method: 'POST', 172 | body: JSON.stringify({ 173 | model: 'gpt-3.5-turbo', 174 | messages: [ 175 | { 176 | role: 'system', 177 | content: `You are a helpful assistant helping out writing copy for a website. User has selected the following text ${existingText} in a CMS editor. Please write a short paragraph that will replace the text based on the following prompt` 178 | }, 179 | { role: 'user', content: prompt } 180 | ] 181 | }), 182 | headers: { 183 | 'content-type': 'application/json', 184 | Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}` 185 | } 186 | }); 187 | if (!response.ok) throw new Error(response.statusText); 188 | const result = await response.json(); 189 | return result.choices[0].message.content; 190 | } 191 | -------------------------------------------------------------------------------- /src/lib/uuid.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Math.uuid.js (v1.4) 3 | http://www.broofa.com 4 | mailto:robert@broofa.com 5 | Copyright (c) 2010 Robert Kieffer 6 | Dual licensed under the MIT and GPL licenses. 7 | */ 8 | 9 | /** 10 | * Generates a unique id. 11 | * 12 | * @param {String} [prefix] if provided the UUID will be prefixed. 13 | * @param {Number} [len] if provided a UUID with given length will be created. 14 | * @return A generated uuid. 15 | */ 16 | export default function uuid(prefix, len) { 17 | if (prefix && prefix[prefix.length - 1] !== '-') { 18 | prefix = prefix.concat('-'); 19 | } 20 | const chars = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); 21 | const uuid = []; 22 | const radix = 16; 23 | let idx; 24 | len = len || 32; 25 | if (len) { 26 | // Compact form 27 | for (idx = 0; idx < len; idx++) uuid[idx] = chars[0 | (Math.random() * radix)]; 28 | } else { 29 | // rfc4122, version 4 form 30 | let r; 31 | // rfc4122 requires these characters 32 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; 33 | uuid[14] = '4'; 34 | // Fill in random data. At i==19 set the high bits of clock sequence as 35 | // per rfc4122, sec. 4.1.5 36 | for (idx = 0; idx < 36; idx++) { 37 | if (!uuid[idx]) { 38 | r = 0 | (Math.random() * 16); 39 | uuid[idx] = chars[idx === 19 ? (r & 0x3) | 0x8 : r]; 40 | } 41 | } 42 | } 43 | return (prefix || '') + uuid.join(''); 44 | } 45 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import '@fontsource/jost/400.css'; 3 | import '@fontsource/jost/500.css'; 4 | import '@fontsource/jost/600.css'; 5 | import '@fontsource/jost/700.css'; 6 | 7 | import '../app.css'; 8 | </script> 9 | 10 | <slot /> 11 | -------------------------------------------------------------------------------- /src/routes/+page.server.js: -------------------------------------------------------------------------------- 1 | import { getArticles, getPage } from '$lib/api'; 2 | 3 | export async function load({ locals }) { 4 | const currentUser = locals.user; 5 | const articles = await getArticles(); 6 | const page = await getPage('home'); 7 | 8 | return { 9 | currentUser, 10 | articles: articles.slice(0, 3), 11 | page 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import EditorToolbar from '$lib/components/EditorToolbar.svelte'; 3 | import PlainText from '$lib/components/PlainText.svelte'; 4 | import RichText from '$lib/components/RichText.svelte'; 5 | import { fetchJSON } from '$lib/util'; 6 | import PrimaryButton from '$lib/components/PrimaryButton.svelte'; 7 | import SecondaryButton from '$lib/components/SecondaryButton.svelte'; 8 | import WebsiteNav from '$lib/components/WebsiteNav.svelte'; 9 | import Modal from '$lib/components/Modal.svelte'; 10 | import LoginMenu from '$lib/components/LoginMenu.svelte'; 11 | import ArticleTeaser from '$lib/components/ArticleTeaser.svelte'; 12 | import Testimonial from '$lib/components/Testimonial.svelte'; 13 | import IntroStep from '$lib/components/IntroStep.svelte'; 14 | import Footer from '$lib/components/Footer.svelte'; 15 | import Image from '$lib/components/Image.svelte'; 16 | import NotEditable from '$lib/components/NotEditable.svelte'; 17 | 18 | export let data; 19 | $: currentUser = data.currentUser; 20 | 21 | // -------------------------------------------------------------------------- 22 | // DEFAULT PAGE CONTENT - AJDUST TO YOUR NEEDS 23 | // -------------------------------------------------------------------------- 24 | 25 | const EMAIL = 'michael@letsken.com'; 26 | 27 | // Can contain spaces but must not contain the + sign 28 | const PHONE_NUMBER = '43 664 1533015'; 29 | 30 | const FAQS_PLACEHOLDER = ` 31 | <h2>Question 1</h2> 32 | <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras mi lectus, pellentesque nec urna eget, pretium dictum arcu. In rutrum pretium leo, id efficitur nisl ullamcorper sit amet.</p> 33 | <h2>Question 2</h2> 34 | <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras mi lectus, pellentesque nec urna eget, pretium dictum arcu. In rutrum pretium leo, id efficitur nisl ullamcorper sit amet.</p> 35 | `; 36 | 37 | const BIO_PLACEHOLDER = ` 38 | <p>Modern tools, such as Svelte and Tailwind allow you to easily hand-craft fast and beautiful websites. What’s missing is the ability to <strong>make edits without changing the source code</strong>.</p> 39 | <p>With this <a href="https://github.com/michael/editable-website">open-source website template</a>, I want to fill that gap.</p> 40 | <p>If you have questions or need any help, contact me.</p> 41 | `; 42 | 43 | const TESTIMONIALS_PLACEHOLDER = [ 44 | { 45 | text: '“Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras mi lectus, pellentesque nec urna eget, pretium dictum arcu. In rutrum pretium leo, id efficitur nisl ullamcorper sit amet.”', 46 | image: '/images/person-placeholder.jpg', 47 | name: 'Jane Doe · jane-doe.org' 48 | } 49 | ]; 50 | 51 | let editable, 52 | title, 53 | testimonials, 54 | faqs, 55 | introStep1, 56 | introStep2, 57 | introStep3, 58 | introStep4, 59 | bioTitle, 60 | bioPicture, 61 | bio, 62 | showUserMenu; 63 | 64 | function initOrReset() { 65 | title = data.page?.title || 'Untitled Website'; 66 | faqs = data.page?.faqs || FAQS_PLACEHOLDER; 67 | 68 | // Make a deep copy 69 | testimonials = JSON.parse(JSON.stringify(data.page?.testimonials || TESTIMONIALS_PLACEHOLDER)); 70 | 71 | introStep1 = JSON.parse( 72 | JSON.stringify( 73 | data.page?.introStep1 || { 74 | label: 'THE PROBLEM', 75 | title: 'The problem statement', 76 | description: 'Describe the problem you are solving in a short sentence.' 77 | } 78 | ) 79 | ); 80 | introStep2 = JSON.parse( 81 | JSON.stringify( 82 | data.page?.introStep2 || { 83 | label: 'THE DREAM', 84 | title: 'This is how it should be.', 85 | description: 'Describe why it should be like that.' 86 | } 87 | ) 88 | ); 89 | introStep3 = JSON.parse( 90 | JSON.stringify( 91 | data.page?.introStep3 || { 92 | label: 'THE REALITY', 93 | title: 'A statement why it is not that easy.', 94 | description: 'Describe the reality a bit more.' 95 | } 96 | ) 97 | ); 98 | introStep4 = JSON.parse( 99 | JSON.stringify( 100 | data.page?.introStep4 || { 101 | label: 'THE PROMISE', 102 | title: 'Still the solution is worth it.', 103 | description: 'And why this is, should be described here.' 104 | } 105 | ) 106 | ); 107 | bioPicture = data.page?.bioPicture || '/images/person-placeholder.jpg'; 108 | bioTitle = data.page?.bioTitle || "Hi, I'm Michael — I want your website to be editable."; 109 | bio = data.page?.bio || BIO_PLACEHOLDER; 110 | editable = false; 111 | } 112 | 113 | // -------------------------------------------------------------------------- 114 | // Page logic 115 | // -------------------------------------------------------------------------- 116 | 117 | function toggleEdit() { 118 | editable = true; 119 | showUserMenu = false; 120 | } 121 | 122 | function addTestimonial() { 123 | testimonials.push({ 124 | text: '“Add a quote text here”', 125 | image: '/images/person-placeholder.jpg', 126 | name: 'Firstname Lastname · example.com' 127 | }); 128 | testimonials = testimonials; // trigger update 129 | } 130 | 131 | function deleteTestimonial(index) { 132 | testimonials.splice(index, 1); 133 | testimonials = testimonials; // trigger update 134 | } 135 | 136 | function moveTestimonial(index, direction) { 137 | let toIndex; 138 | if (direction === 'up' && index > 0) { 139 | toIndex = index - 1; 140 | } else if (direction === 'down' && index < testimonials.length - 1) { 141 | toIndex = index + 1; 142 | } else { 143 | return; // operation not possible 144 | } 145 | // Remove item from original position 146 | const element = testimonials.splice(index, 1)[0]; 147 | // Insert at new position 148 | testimonials.splice(toIndex, 0, element); 149 | testimonials = testimonials; // trigger update 150 | } 151 | 152 | async function savePage() { 153 | try { 154 | // Only persist the start page when logged in as an admin 155 | if (currentUser) { 156 | await fetchJSON('POST', '/api/save-page', { 157 | pageId: 'home', 158 | page: { 159 | title, 160 | faqs, 161 | testimonials, 162 | introStep1, 163 | introStep2, 164 | introStep3, 165 | introStep4, 166 | bioPicture, 167 | bioTitle, 168 | bio 169 | } 170 | }); 171 | } 172 | editable = false; 173 | } catch (err) { 174 | console.error(err); 175 | alert('There was an error. Please try again.'); 176 | } 177 | } 178 | 179 | initOrReset(); 180 | </script> 181 | 182 | <svelte:head> 183 | <title>Make your website editable</title> 184 | <meta name="description" content="Make changes to your website while browsing it." /> 185 | </svelte:head> 186 | 187 | {#if editable} 188 | <EditorToolbar {currentUser} on:cancel={initOrReset} on:save={savePage} /> 189 | {/if} 190 | 191 | <WebsiteNav bind:showUserMenu {currentUser} bind:editable /> 192 | 193 | {#if showUserMenu} 194 | <Modal on:close={() => (showUserMenu = false)}> 195 | <form class="w-full block" method="POST"> 196 | <div class="w-full flex flex-col space-y-4 p-4 sm:p-6"> 197 | <PrimaryButton on:click={toggleEdit}>Edit page</PrimaryButton> 198 | <LoginMenu {currentUser} /> 199 | </div> 200 | </form> 201 | </Modal> 202 | {/if} 203 | 204 | <div> 205 | <div class="max-w-screen-md mx-auto px-6 pt-12 sm:pt-24"> 206 | <NotEditable {editable}> 207 | <svg 208 | class="pb-8 w-14 sm:w-24 mx-auto" 209 | viewBox="0 0 200 200" 210 | fill="none" 211 | xmlns="http://www.w3.org/2000/svg" 212 | > 213 | <path d="M164 110L64 163.768V200L164 147.059V110Z" fill="#111827" /> 214 | <path d="M136 66L36 119.768V156L136 103.059V66Z" fill="#111827" /> 215 | <path d="M164 0L64 53.7684V90L164 37.0588V0Z" fill="#111827" /> 216 | </svg> 217 | </NotEditable> 218 | <h1 class="text-4xl md:text-7xl font-bold text-center"> 219 | <PlainText {editable} bind:content={title} /> 220 | </h1> 221 | <NotEditable {editable}> 222 | <div class="text-center pt-8 pb-4 bounce text-xl">↓</div> 223 | <div class="text-center"> 224 | <PrimaryButton size="lg" type="button" on:click={toggleEdit}>Edit</PrimaryButton> 225 | </div> 226 | </NotEditable> 227 | </div> 228 | </div> 229 | 230 | <div class="pt-12 md:pt-24 border-gray-100 border-b-2"> 231 | <div class="max-w-screen-md mx-auto px-6"> 232 | <div class="relative"> 233 | <div class="w-1 bg-gray-900 absolute inset-0 -top-8 bottom-12 mx-auto z-0"> 234 | <div class="w-4 h-4 rounded-full bg-gray-900 absolute -top-1 -left-[6px]" /> 235 | </div> 236 | <div class="z-10"> 237 | <IntroStep {editable} bind:intro={introStep1} /> 238 | <IntroStep {editable} bind:intro={introStep2} /> 239 | <IntroStep {editable} bind:intro={introStep3} /> 240 | <IntroStep {editable} bind:intro={introStep4} /> 241 | </div> 242 | </div> 243 | <div class="relative h-14"> 244 | <div class="w-1 bg-gray-900 absolute inset-0 -top-16 bottom-12 mx-auto z-0"> 245 | <div 246 | class="absolute -bottom-2 -left-[7px] h-0 w-0 border-x-[9px] border-x-transparent border-t-[10px] border-gray-900" 247 | /> 248 | </div> 249 | </div> 250 | <div class="text-center mb-32"> 251 | <PrimaryButton 252 | size="lg" 253 | type="button" 254 | on:click={() => 255 | document.getElementById('contact').scrollIntoView({ behavior: 'smooth', block: 'start' })} 256 | >Create an editable website</PrimaryButton 257 | > 258 | </div> 259 | </div> 260 | </div> 261 | 262 | <div class="bg-white pb-6 sm:pb-12"> 263 | <div class="max-w-screen-md mx-auto px-6"> 264 | <div class="font-bold text-sm sm:text-base py-12 sm:pt-24 pb-8">WHAT PEOPLE SAY</div> 265 | </div> 266 | {#each testimonials as testimonial, i} 267 | <Testimonial 268 | {editable} 269 | {currentUser} 270 | bind:testimonial 271 | firstEntry={i === 0} 272 | lastEntry={i === testimonials.length - 1} 273 | on:delete={() => deleteTestimonial(i)} 274 | on:up={() => moveTestimonial(i, 'up')} 275 | on:down={() => moveTestimonial(i, 'down')} 276 | /> 277 | {/each} 278 | 279 | {#if editable} 280 | <div class="text-center pb-12 border-b border-gray-100"> 281 | <SecondaryButton on:click={addTestimonial}>Add testimonial</SecondaryButton> 282 | </div> 283 | {/if} 284 | </div> 285 | 286 | {#if data.articles.length > 0} 287 | <NotEditable {editable}> 288 | <div class="bg-white border-t-2 border-gray-100 pb-10 sm:pb-16"> 289 | <div class="max-w-screen-md mx-auto px-6 pt-12 sm:pt-24"> 290 | <div class="font-bold text-sm sm:text-base">FROM THE BLOG</div> 291 | </div> 292 | {#each data.articles as article, i} 293 | <ArticleTeaser {article} firstEntry={i === 0} /> 294 | {/each} 295 | </div> 296 | </NotEditable> 297 | {/if} 298 | 299 | <!-- Bio --> 300 | <div id="contact" class="bg-white border-t-2 border-b-2 border-gray-100 pb-12 sm:pb-24"> 301 | <div class="max-w-screen-md mx-auto px-6"> 302 | <div class="pt-12 sm:pt-24 pb-12 text-center"> 303 | <Image 304 | class="inline-block w-48 h-48 md:w-72 md:h-72 rounded-full" 305 | maxWidth="384" 306 | maxHeight="384" 307 | quality="0.8" 308 | {editable} 309 | {currentUser} 310 | bind:src={bioPicture} 311 | alt="Michael Aufreiter" 312 | /> 313 | </div> 314 | <div class=""> 315 | <h1 class="text-3xl md:text-5xl font-bold"> 316 | <PlainText {editable} bind:content={bioTitle} /> 317 | </h1> 318 | </div> 319 | <div class="prose md:prose-xl pb-6"> 320 | <RichText multiLine {editable} bind:content={bio} /> 321 | </div> 322 | 323 | <NotEditable {editable}> 324 | <div class="flex flex-col sm:flex-row sm:space-x-6 md:space-x-8 space-y-4 sm:space-y-0"> 325 | <PrimaryButton size="lg" href={`mailto:${EMAIL}`}>Email</PrimaryButton> 326 | <SecondaryButton size="lg" href={`https://wa.me/${PHONE_NUMBER.replace(/\s+/g, '')}`}> 327 | WhatsApp (+{PHONE_NUMBER}) 328 | </SecondaryButton> 329 | </div> 330 | </NotEditable> 331 | </div> 332 | </div> 333 | 334 | <!-- FAQs --> 335 | <div class="bg-white"> 336 | <div class="max-w-screen-md mx-auto px-6"> 337 | <div class="font-bold text-sm sm:text-base pt-12 sm:pt-24 -mb-6 md:-mb-12">FAQs</div> 338 | <div class="prose md:prose-xl pb-12 sm:pb-24"> 339 | <RichText multiLine {editable} bind:content={faqs} /> 340 | </div> 341 | </div> 342 | </div> 343 | 344 | <Footer counter="/" {editable} /> 345 | -------------------------------------------------------------------------------- /src/routes/api/counter/+server.js: -------------------------------------------------------------------------------- 1 | import { createOrUpdateCounter } from '$lib/api'; 2 | import { json } from '@sveltejs/kit'; 3 | 4 | export async function GET({ url }) { 5 | const counterId = url.searchParams.get('c'); 6 | return json(await createOrUpdateCounter(counterId, true)); 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/api/create-article/+server.js: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import { createArticle } from '$lib/api'; 3 | 4 | export async function POST({ request, locals }) { 5 | const currentUser = locals.user; 6 | const { title, content, teaser } = await request.json(); 7 | const { slug } = await createArticle(title, content, teaser, currentUser); 8 | return json({ slug }); 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/api/delete-article/+server.js: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import { deleteArticle } from '$lib/api'; 3 | 4 | export async function POST({ request, locals }) { 5 | const user = locals.user; 6 | const { slug } = await request.json(); 7 | const result = await deleteArticle(slug, user); 8 | return json(result); 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/api/generate/+server.js: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import { askGPT } from '$lib/util.js'; 3 | 4 | export async function POST({ request, locals }) { 5 | const user = locals.user; 6 | if (!user) throw new Error('Not authorized'); 7 | const { prompt, existingText } = await request.json(); 8 | const completion = await askGPT(prompt, existingText); 9 | return json(completion); 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/api/presignedurl/+server.js: -------------------------------------------------------------------------------- 1 | const S3_ENDPOINT = import.meta.env.VITE_S3_ENDPOINT; 2 | const S3_ACCESS_KEY = import.meta.env.VITE_S3_ACCESS_KEY; 3 | const S3_SECRET_ACCESS_KEY = import.meta.env.VITE_S3_SECRET_ACCESS_KEY; 4 | const S3_BUCKET = import.meta.env.VITE_S3_BUCKET; 5 | 6 | import aws from 'aws-sdk'; 7 | import { json } from '@sveltejs/kit'; 8 | 9 | // Create a new S3 instance for interacting with our MinIO space. We use S3 because 10 | // the API is the same between MinIO and AWS S3. 11 | const spaces = new aws.S3({ 12 | endpoint: new aws.Endpoint(S3_ENDPOINT), 13 | accessKeyId: S3_ACCESS_KEY, 14 | secretAccessKey: S3_SECRET_ACCESS_KEY, 15 | // the two lines below need to be enabled to make it work with MinIO on Northflank 16 | signatureVersion: 'v4', 17 | s3ForcePathStyle: true 18 | }); 19 | 20 | export function GET({ url, locals }) { 21 | const currentUser = locals.user; 22 | const path = url.searchParams.get('path'); // e.g. 'nachmachen/test/meh.jpg' 23 | const type = url.searchParams.get('type'); // e.g. 'image/jpeg' 24 | if (!currentUser) throw new Error('Not authorized'); 25 | 26 | const params = { 27 | Bucket: S3_BUCKET, 28 | Key: path, 29 | Expires: 60 * 20, // Expires in 20 minutes 30 | ContentType: type, 31 | ACL: 'public-read' // Remove this to make the file private 32 | }; 33 | 34 | let signedUrl = spaces.getSignedUrl('putObject', params); 35 | // console.log('signedUrl', signedUrl); 36 | // console.log('S3_ACCESS_KEY', S3_ACCESS_KEY); 37 | // console.log('S3_SECRET_ACCESS_KEY', S3_SECRET_ACCESS_KEY); 38 | // console.log('S3_ENDPOINT', S3_ENDPOINT); 39 | // console.log('S3_BUCKET', S3_BUCKET); 40 | // Returns https://nachmachen.minio.nachmachen-assets--8j8fmgtqfmwp.addon.code.run/nachmachen/... 41 | return json({ signedUrl }); 42 | } 43 | -------------------------------------------------------------------------------- /src/routes/api/save-page/+server.js: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import { createOrUpdatePage } from '$lib/api'; 3 | 4 | export async function POST({ request, locals }) { 5 | const currentUser = locals.user; 6 | const { pageId, page } = await request.json(); 7 | await createOrUpdatePage(pageId, page, currentUser); 8 | return json({ pageId, status: 'ok' }); 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/api/search/+server.js: -------------------------------------------------------------------------------- 1 | import { search } from '$lib/api'; 2 | import { json } from '@sveltejs/kit'; 3 | 4 | export async function GET({ url, locals }) { 5 | const currentUser = locals.user; 6 | const searchQuery = url.searchParams.get('q') || ''; 7 | return json(await search(searchQuery, currentUser)); 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/api/update-article/+server.js: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import { updateArticle } from '$lib/api'; 3 | 4 | export async function POST({ request, locals }) { 5 | const currentUser = locals.user; 6 | const { slug, title, content, teaser } = await request.json(); 7 | await updateArticle(slug, title, content, teaser, currentUser); 8 | return json({ slug, status: 'ok' }); 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/blog/+page.server.js: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/api'; 2 | 3 | export async function load({ locals }) { 4 | const currentUser = locals.user; 5 | const articles = await getArticles(currentUser); 6 | 7 | return { 8 | currentUser, 9 | articles 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/routes/blog/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { goto } from '$app/navigation'; 3 | import PrimaryButton from '$lib/components/PrimaryButton.svelte'; 4 | import WebsiteNav from '$lib/components/WebsiteNav.svelte'; 5 | import Modal from '$lib/components/Modal.svelte'; 6 | import LoginMenu from '$lib/components/LoginMenu.svelte'; 7 | import ArticleTeaser from '$lib/components/ArticleTeaser.svelte'; 8 | import Footer from '$lib/components/Footer.svelte'; 9 | import EditableWebsiteTeaser from '$lib/components/EditableWebsiteTeaser.svelte'; 10 | 11 | export let data; 12 | let showUserMenu; 13 | 14 | $: currentUser = data.currentUser; 15 | </script> 16 | 17 | <svelte:head> 18 | <title>Blog</title> 19 | <meta name="description" content="What you always wanted to know about web development." /> 20 | </svelte:head> 21 | 22 | <WebsiteNav bind:showUserMenu {currentUser} /> 23 | 24 | {#if showUserMenu} 25 | <Modal on:close={() => (showUserMenu = false)}> 26 | <form class="w-full block" method="POST"> 27 | <div class="w-full flex flex-col space-y-4 p-4 sm:p-6"> 28 | <PrimaryButton type="button" on:click={() => goto('/blog/new')}> 29 | New blog post 30 | </PrimaryButton> 31 | <LoginMenu {currentUser} /> 32 | </div> 33 | </form> 34 | </Modal> 35 | {/if} 36 | 37 | <div class="pb-8"> 38 | <div class="max-w-screen-md mx-auto px-6 pt-12 sm:pt-24"> 39 | <div class="font-bold text-sm">LATEST ARTICLES</div> 40 | {#if data.articles.length === 0} 41 | <div class="md:text-xl py-4">No blog posts have been published so far.</div> 42 | {/if} 43 | </div> 44 | 45 | {#each data.articles as article, i} 46 | <ArticleTeaser {article} firstEntry={i === 0} /> 47 | {/each} 48 | </div> 49 | 50 | <EditableWebsiteTeaser /> 51 | 52 | <Footer counter="/blog" /> 53 | -------------------------------------------------------------------------------- /src/routes/blog/[slug]/+page.server.js: -------------------------------------------------------------------------------- 1 | import { getArticleBySlug, getNextArticle } from '$lib/api'; 2 | 3 | export async function load({ params, locals }) { 4 | const currentUser = locals.user; 5 | const data = await getArticleBySlug(params.slug); 6 | const articles = [await getNextArticle(params.slug)]; 7 | return { 8 | ...data, 9 | currentUser, 10 | articles 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/routes/blog/[slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import EditorToolbar from '$lib/components/EditorToolbar.svelte'; 3 | import { extractTeaser, fetchJSON } from '$lib/util'; 4 | import PrimaryButton from '$lib/components/PrimaryButton.svelte'; 5 | import WebsiteNav from '$lib/components/WebsiteNav.svelte'; 6 | import Modal from '$lib/components/Modal.svelte'; 7 | import LoginMenu from '$lib/components/LoginMenu.svelte'; 8 | import { goto } from '$app/navigation'; 9 | import Footer from '$lib/components/Footer.svelte'; 10 | import ArticleTeaser from '$lib/components/ArticleTeaser.svelte'; 11 | import EditableWebsiteTeaser from '$lib/components/EditableWebsiteTeaser.svelte'; 12 | import Article from '$lib/components/Article.svelte'; 13 | import NotEditable from '$lib/components/NotEditable.svelte'; 14 | 15 | export let data; 16 | 17 | let showUserMenu = false; 18 | let editable, title, teaser, content, publishedAt, updatedAt; 19 | 20 | $: currentUser = data.currentUser; 21 | 22 | $: { 23 | // HACK: To make sure this is only run when the parent passes in new data 24 | data = data; 25 | initOrReset(); 26 | } 27 | 28 | function initOrReset() { 29 | title = data.title; 30 | teaser = data.teaser; 31 | content = data.content; 32 | publishedAt = data.publishedAt; 33 | updatedAt = data.updatedAt; 34 | editable = false; 35 | } 36 | 37 | function toggleEdit() { 38 | editable = true; 39 | showUserMenu = false; 40 | } 41 | 42 | async function deleteArticle() { 43 | if (!currentUser) return alert('Sorry, you are not authorized.'); 44 | try { 45 | fetchJSON('POST', '/api/delete-article', { 46 | slug: data.slug 47 | }); 48 | goto('/blog'); 49 | } catch (err) { 50 | console.error(err); 51 | alert('Error deleting the article. Try again.'); 52 | window.location.reload(); 53 | } 54 | } 55 | 56 | async function saveArticle() { 57 | if (!currentUser) return alert('Sorry, you are not authorized.'); 58 | const teaser = extractTeaser(document.getElementById('article_content')); 59 | try { 60 | const result = await fetchJSON('POST', '/api/update-article', { 61 | slug: data.slug, 62 | title, 63 | content, 64 | teaser 65 | }); 66 | updatedAt = result.updatedAt; 67 | editable = false; 68 | } catch (err) { 69 | console.error(err); 70 | alert( 71 | 'There was an error. You can try again, but before that, please just copy and paste your article into a safe place.' 72 | ); 73 | } 74 | } 75 | </script> 76 | 77 | <svelte:head> 78 | <title>{title}</title> 79 | <meta name="description" content={teaser} /> 80 | </svelte:head> 81 | 82 | {#if editable} 83 | <EditorToolbar {currentUser} on:cancel={initOrReset} on:save={saveArticle} /> 84 | {/if} 85 | 86 | <WebsiteNav bind:editable bind:showUserMenu {currentUser} /> 87 | 88 | {#if showUserMenu} 89 | <Modal on:close={() => (showUserMenu = false)}> 90 | <form class="w-full block" method="POST"> 91 | <div class="w-full flex flex-col space-y-4 p-4 sm:p-6"> 92 | <PrimaryButton on:click={toggleEdit}>Edit post</PrimaryButton> 93 | <PrimaryButton type="button" on:click={deleteArticle}>Delete post</PrimaryButton> 94 | <LoginMenu {currentUser} /> 95 | </div> 96 | </form> 97 | </Modal> 98 | {/if} 99 | 100 | <Article bind:title bind:content bind:publishedAt {editable} /> 101 | 102 | {#if data.articles.length > 0} 103 | <NotEditable {editable}> 104 | <div class="border-t-2 border-gray-100"> 105 | <div class="max-w-screen-md mx-auto px-6 pt-8 sm:pt-12"> 106 | <div class="font-bold text-sm">READ NEXT</div> 107 | </div> 108 | {#each data.articles as article, i} 109 | <ArticleTeaser {article} firstEntry={i === 0} /> 110 | {/each} 111 | </div> 112 | </NotEditable> 113 | {/if} 114 | 115 | <NotEditable {editable}> 116 | <EditableWebsiteTeaser /> 117 | </NotEditable> 118 | 119 | <Footer counter={`/blog/${data.slug}`} /> 120 | -------------------------------------------------------------------------------- /src/routes/blog/new/+page.server.js: -------------------------------------------------------------------------------- 1 | export async function load({ locals }) { 2 | const currentUser = locals.user; 3 | return { 4 | currentUser 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/routes/blog/new/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import EditorToolbar from '$lib/components/EditorToolbar.svelte'; 3 | import { extractTeaser, fetchJSON } from '$lib/util'; 4 | import WebsiteNav from '$lib/components/WebsiteNav.svelte'; 5 | import { goto } from '$app/navigation'; 6 | import Footer from '$lib/components/Footer.svelte'; 7 | import EditableWebsiteTeaser from '$lib/components/EditableWebsiteTeaser.svelte'; 8 | import Article from '$lib/components/Article.svelte'; 9 | import NotEditable from '$lib/components/NotEditable.svelte'; 10 | 11 | export let data; 12 | 13 | let showUserMenu = false, 14 | editable = true, 15 | title = 'Untitled', 16 | content = 'Copy and paste your text here.'; 17 | 18 | $: currentUser = data.currentUser; 19 | 20 | async function createArticle() { 21 | if (!currentUser) { 22 | return alert('Sorry, you are not authorized to create new articles.'); 23 | } 24 | const teaser = extractTeaser(document.getElementById('article_content')); 25 | try { 26 | const { slug } = await fetchJSON('POST', '/api/create-article', { 27 | title, 28 | content, 29 | teaser 30 | }); 31 | goto(`/blog/${slug}`); 32 | } catch (err) { 33 | console.error(err); 34 | alert('A document with that title has already been published. Choose a different title.'); 35 | } 36 | } 37 | 38 | async function discardDraft() { 39 | goto('/blog'); 40 | } 41 | </script> 42 | 43 | <svelte:head> 44 | <title>New blog post</title> 45 | </svelte:head> 46 | 47 | {#if editable} 48 | <EditorToolbar {currentUser} on:cancel={discardDraft} on:save={createArticle} /> 49 | {/if} 50 | 51 | <WebsiteNav bind:editable bind:showUserMenu {currentUser} /> 52 | <Article bind:title bind:content {editable} /> 53 | 54 | <NotEditable {editable}> 55 | <EditableWebsiteTeaser /> 56 | </NotEditable> 57 | 58 | <Footer {editable} /> 59 | -------------------------------------------------------------------------------- /src/routes/imprint/+page.server.js: -------------------------------------------------------------------------------- 1 | import { getPage } from '$lib/api'; 2 | 3 | export async function load({ locals }) { 4 | const currentUser = locals.user; 5 | const page = await getPage('imprint'); 6 | return { 7 | currentUser, 8 | page 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/imprint/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import WebsiteNav from '$lib/components/WebsiteNav.svelte'; 3 | import Footer from '$lib/components/Footer.svelte'; 4 | import PlainText from '$lib/components/PlainText.svelte'; 5 | import RichText from '$lib/components/RichText.svelte'; 6 | import Modal from '$lib/components/Modal.svelte'; 7 | import LoginMenu from '$lib/components/LoginMenu.svelte'; 8 | import PrimaryButton from '$lib/components/PrimaryButton.svelte'; 9 | import EditorToolbar from '$lib/components/EditorToolbar.svelte'; 10 | import { fetchJSON } from '$lib/util'; 11 | 12 | export let data; 13 | $: currentUser = data.currentUser; 14 | 15 | let editable = false, 16 | showUserMenu = false, 17 | title, 18 | imprint; 19 | 20 | // -------------------------------------------------------------------------- 21 | // DEFAULT PAGE CONTENT - AJDUST TO YOUR NEEDS 22 | // -------------------------------------------------------------------------- 23 | 24 | function initOrReset() { 25 | title = data.page?.title || 'Imprint'; 26 | imprint = 27 | data.page?.imprint || 28 | [ 29 | ['Ken Experiences GmbH', 'Mozartstraße 56', '4020 Linz, Austria'].join('<br/>'), 30 | [ 31 | 'Managing Director: DI Michael Aufreiter', 32 | 'Register No: FN 408728x', 33 | 'Court: Linz', 34 | 'VAT ID: ATU68395257' 35 | ].join('<br/>') 36 | ] 37 | .map(text => `<p>${text}</p>`) 38 | .join('\n'); 39 | editable = false; 40 | } 41 | 42 | initOrReset(); 43 | 44 | function toggleEdit() { 45 | editable = true; 46 | showUserMenu = false; 47 | } 48 | 49 | async function savePage() { 50 | if (!currentUser) return alert('Sorry, you are not authorized.'); 51 | try { 52 | fetchJSON('POST', '/api/save-page', { 53 | pageId: 'imprint', 54 | page: { 55 | title, 56 | imprint 57 | } 58 | }); 59 | editable = false; 60 | } catch (err) { 61 | console.error(err); 62 | alert('There was an error. Please try again.'); 63 | } 64 | } 65 | </script> 66 | 67 | <svelte:head> 68 | <title>Imprint</title> 69 | </svelte:head> 70 | 71 | {#if showUserMenu} 72 | <Modal on:close={() => (showUserMenu = false)}> 73 | <div class="w-full flex flex-col space-y-4 p-4 sm:p-6"> 74 | <PrimaryButton on:click={toggleEdit}>Edit page</PrimaryButton> 75 | <LoginMenu {currentUser} /> 76 | </div> 77 | </Modal> 78 | {/if} 79 | 80 | {#if editable} 81 | <EditorToolbar on:cancel={initOrReset} on:save={savePage} /> 82 | {/if} 83 | 84 | <WebsiteNav bind:showUserMenu {currentUser} bind:editable /> 85 | 86 | <div class="py-12 sm:py-24"> 87 | <div class="max-w-screen-md mx-auto px-6 md:text-xl"> 88 | <h1 class="text-4xl md:text-7xl font-bold pb-8"> 89 | <PlainText {editable} bind:content={title} /> 90 | </h1> 91 | <div class="prose md:prose-xl pb-12 sm:pb-24"> 92 | <RichText multiLine {editable} bind:content={imprint} /> 93 | </div> 94 | </div> 95 | </div> 96 | 97 | <Footer counter="/imprint" /> 98 | -------------------------------------------------------------------------------- /src/routes/login/+page.server.js: -------------------------------------------------------------------------------- 1 | import { redirect, fail } from '@sveltejs/kit'; 2 | import { authenticate } from '$lib/api'; 3 | 4 | export const actions = { 5 | default: async ({ cookies, request }) => { 6 | const data = await request.formData(); 7 | const password = data.get('password'); 8 | const sessionTimeout = 60 * 24 * 7; // one week in minutes 9 | try { 10 | const { sessionId } = await authenticate(password, sessionTimeout); 11 | cookies.set('sessionid', sessionId); 12 | } catch (err) { 13 | console.error(err); 14 | return fail(400, { incorrect: true }); 15 | } 16 | throw redirect(303, '/'); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import Limiter from '$lib/components/Limiter.svelte'; 3 | import PrimaryButton from '$lib/components/PrimaryButton.svelte'; 4 | import Input from '$lib/components/Input.svelte'; 5 | export let form; 6 | </script> 7 | 8 | <svelte:head> 9 | <title>Login</title> 10 | </svelte:head> 11 | 12 | <Limiter> 13 | {#if form?.incorrect} 14 | <p class="p-4 bg-red-100 text-red-600 my-4 rounded-md">Login incorrect. Please try again.</p> 15 | {/if} 16 | <div class="w-full flex flex-col space-y-4 mt-12 mb-4"> 17 | <form method="POST" class="flex flex-col space-y-8"> 18 | <!-- <div class="flex flex-col"> 19 | <label for="email" class="font-semibold mb-2">E-Mail</label> 20 | <Input type="text" name="email" id="email" /> 21 | </div> --> 22 | <div class="flex flex-col"> 23 | <label for="password" class="font-semibold mb-2 text-2xl">Enter Admin password</label> 24 | <Input type="password" name="password" id="password" /> 25 | </div> 26 | <PrimaryButton type="submit">Login</PrimaryButton> 27 | <div><a class="underline" href="/">Return to the homepage</a></div> 28 | </form> 29 | </div> 30 | </Limiter> 31 | -------------------------------------------------------------------------------- /src/routes/logout/+page.server.js: -------------------------------------------------------------------------------- 1 | import { fail } from '@sveltejs/kit'; 2 | import { destroySession } from '$lib/api'; 3 | 4 | export async function load({ cookies }) { 5 | const sessionId = cookies.get('sessionid'); 6 | try { 7 | await destroySession(sessionId); 8 | cookies.delete('sessionid'); 9 | } catch (err) { 10 | console.error(err); 11 | return fail(400, { incorrect: true }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/logout/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import Limiter from '$lib/components/Limiter.svelte'; 3 | export let form; 4 | </script> 5 | 6 | <Limiter> 7 | <div class="pt-20"> 8 | {#if form?.incorrect} 9 | <p class="p-4 bg-red-100 text-red-600 my-4 rounded-md">Error while signing out.</p> 10 | {:else} 11 | Successfully logged out. <a class="underline" href="/">Continue</a>. 12 | {/if} 13 | </div> 14 | </Limiter> 15 | -------------------------------------------------------------------------------- /static/favicon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-144x144.png -------------------------------------------------------------------------------- /static/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-192x192.png -------------------------------------------------------------------------------- /static/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-256x256.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-384x384.png -------------------------------------------------------------------------------- /static/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-48x48.png -------------------------------------------------------------------------------- /static/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-512x512.png -------------------------------------------------------------------------------- /static/favicon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-72x72.png -------------------------------------------------------------------------------- /static/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon-96x96.png -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/favicon.png -------------------------------------------------------------------------------- /static/images/person-placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilskj/editable-website/54ef24b208438e72acef2157a06fded0d490052a/static/images/person-placeholder.jpg -------------------------------------------------------------------------------- /static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Make your website editable", 3 | "short_name": "Editable Website", 4 | "icons": [ 5 | { 6 | "src": "/favicon-48x48.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 7 | "sizes": "48x48", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/favicon-72x72.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 12 | "sizes": "72x72", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/favicon-96x96.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 17 | "sizes": "96x96", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/favicon-144x144.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 22 | "sizes": "144x144", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/favicon-192x192.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 27 | "sizes": "192x192", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "/favicon-256x256.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 32 | "sizes": "256x256", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "/favicon-384x384.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 37 | "sizes": "384x384", 38 | "type": "image/png" 39 | }, 40 | { 41 | "src": "/favicon-512x512.png?v=9d77a4ce079ca15fd3da4b420a2e7cf6", 42 | "sizes": "512x512", 43 | "type": "image/png" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | kit: { 7 | adapter: adapter(), 8 | csrf: { 9 | checkOrigin: false 10 | } 11 | }, 12 | preprocess: vitePreprocess() 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import forms from '@tailwindcss/forms'; 2 | import typography from '@tailwindcss/typography'; 3 | 4 | const round = num => 5 | num 6 | .toFixed(7) 7 | .replace(/(\.[0-9]+?)0+$/, '$1') 8 | .replace(/\.0$/, ''); 9 | const em = (px, base) => `${round(px / base)}em`; 10 | 11 | /** @type {import('tailwindcss').Config} */ 12 | export default { 13 | content: ['./src/**/*.{html,js,svelte,ts}'], 14 | theme: { 15 | fontFamily: { 16 | sans: ['Jost', 'system-ui'] 17 | }, 18 | extend: { 19 | typography: { 20 | DEFAULT: { 21 | css: { 22 | h2: { 23 | fontSize: em(20, 14), 24 | marginTop: em(32, 20), 25 | marginBottom: em(4, 20), 26 | lineHeight: round(28 / 20) 27 | }, 28 | blockquote: { 29 | fontWeight: 'normal', 30 | fontStyle: 'normal', 31 | color: '', 32 | borderLeftWidth: '0.25rem', 33 | borderLeftColor: 'var(--tw-prose-quote-borders)', 34 | quotes: '' 35 | }, 36 | 'blockquote p:first-of-type::before': { 37 | content: '' 38 | }, 39 | 'blockquote p:last-of-type::after': { 40 | content: '' 41 | } 42 | } 43 | }, 44 | lg: { 45 | css: { 46 | h2: { 47 | fontSize: em(30, 18), 48 | marginTop: em(56, 30), 49 | marginBottom: em(4, 20), 50 | lineHeight: round(40 / 30) 51 | } 52 | } 53 | }, 54 | xl: { 55 | css: { 56 | h2: { 57 | fontSize: em(30, 18), 58 | marginTop: em(56, 30), 59 | marginBottom: em(4, 20), 60 | lineHeight: round(40 / 30) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }, 67 | plugins: [forms, typography] 68 | }; 69 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | 3 | const config = { 4 | plugins: [sveltekit()] 5 | }; 6 | 7 | export default config; 8 | --------------------------------------------------------------------------------