├── .env.example ├── public ├── _redirects ├── favicon.ico ├── assets │ ├── image-192.png │ ├── note-192.png │ ├── icons │ │ ├── favicon.ico │ │ ├── apple-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── favicon-144x144.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ ├── apple-icon-precomposed.png │ │ └── safari-pinned-tab.svg │ ├── screenshots │ │ └── screenshot1.jpg │ └── dot.svg ├── browserconfig.xml ├── sw.js └── manifest.json ├── .gitignore ├── src ├── scss │ ├── _helpers.scss │ ├── _alert.scss │ ├── _mixin.scss │ ├── main.scss │ ├── _rating.scss │ ├── _constants.scss │ ├── _modal.scss │ ├── _base.scss │ └── _ui.scss ├── js │ ├── Pages │ │ ├── LogoutPage.js │ │ ├── HomePage.js │ │ ├── SharePage.js │ │ ├── AboutPage.js │ │ ├── SuccessPage.js │ │ ├── CallbackPage.js │ │ ├── LoginPage.js │ │ └── SettingsPage.js │ ├── Layouts │ │ ├── DefaultLayout.js │ │ ├── NoAuthLayout.js │ │ └── AuthLayout.js │ ├── Components │ │ ├── Header.js │ │ ├── Tile.js │ │ ├── Modal │ │ │ └── index.js │ │ ├── Box │ │ │ └── index.js │ │ ├── Alert.js │ │ ├── Rating.js │ │ ├── Footer.js │ │ └── Gallery.js │ ├── utils │ │ ├── index.js │ │ └── crypt.js │ ├── Controllers │ │ ├── Helpers.js │ │ └── Proxy.js │ ├── Models │ │ └── Store.js │ ├── Editors │ │ ├── Tiles.js │ │ ├── ImageEditor.js │ │ ├── MovieEditor.js │ │ └── index.js │ └── app.js └── functions │ ├── redirect.js │ ├── token.js │ ├── micropub.js │ ├── media.js │ ├── lib │ └── utils.js │ └── discover.js ├── netlify.toml ├── .eslintrc.json ├── LICENSE ├── package.json ├── README.md └── index.html /.env.example: -------------------------------------------------------------------------------- 1 | VITE_OMDB_API_KEY="1234abcd" -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .netlify 3 | build 4 | dist 5 | node_modules -------------------------------------------------------------------------------- /src/scss/_helpers.scss: -------------------------------------------------------------------------------- 1 | 2 | .text-center { 3 | text-align: center; 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/favicon.ico -------------------------------------------------------------------------------- /public/assets/image-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/image-192.png -------------------------------------------------------------------------------- /public/assets/note-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/note-192.png -------------------------------------------------------------------------------- /public/assets/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/favicon.ico -------------------------------------------------------------------------------- /public/assets/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon.png -------------------------------------------------------------------------------- /public/assets/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/assets/icons/favicon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/favicon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/assets/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/assets/screenshots/screenshot1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/screenshots/screenshot1.jpg -------------------------------------------------------------------------------- /public/assets/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /public/assets/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/sparkles/main/public/assets/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /src/scss/_alert.scss: -------------------------------------------------------------------------------- 1 | .sp-alert { 2 | position: fixed; 3 | top: 0; right: 0; 4 | padding: .5em; 5 | margin: .5em; 6 | background: var(--sprk-bg); 7 | border: 1px solid var(--sprk-border-color); 8 | } -------------------------------------------------------------------------------- /src/scss/_mixin.scss: -------------------------------------------------------------------------------- 1 | 2 | @mixin transition($transition...) { 3 | -moz-transition: $transition; 4 | -o-transition: $transition; 5 | -webkit-transition: $transition; 6 | transition: $transition; 7 | } 8 | -------------------------------------------------------------------------------- /src/scss/main.scss: -------------------------------------------------------------------------------- 1 | 2 | @import "./constants.scss"; 3 | @import "./base.scss"; 4 | @import "./helpers.scss"; 5 | @import "./mixin.scss"; 6 | @import "./alert.scss"; 7 | @import "./ui.scss"; 8 | @import "./rating.scss"; 9 | -------------------------------------------------------------------------------- /src/js/Pages/LogoutPage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Store from '../Models/Store' 4 | 5 | const LogoutPage = { 6 | oninit: () => { 7 | Store.clear() 8 | m.route.set('/login') 9 | }, 10 | view: () => m('p', 'logged out') 11 | } 12 | 13 | export default LogoutPage -------------------------------------------------------------------------------- /src/js/Layouts/DefaultLayout.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Header from '../Components/Header' 4 | import Footer from '../Components/Footer' 5 | 6 | const DefaultLayout = { 7 | view: v => [ 8 | m(Header), 9 | m('main', [ v.children ]), 10 | m(Footer) 11 | ] 12 | } 13 | 14 | export default DefaultLayout -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #e69bdd -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('fetch', event => { 2 | // console.log('fetch', event) 3 | }) 4 | 5 | self.addEventListener('install', event => { 6 | // console.log('install', event) 7 | self.skipWaiting() 8 | }) 9 | 10 | self.addEventListener('activate', event => { 11 | // console.log('activate', event) 12 | return self.clients.claim() 13 | }) 14 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [dev] 2 | command = "npm run start:vite" 3 | publish = "dist" 4 | targetPort = 5173 5 | port = 9000 6 | 7 | [build] 8 | command = "npm run build:vite" 9 | publish = "dist" 10 | 11 | [functions] 12 | directory = "./src/functions" 13 | node_bundler = "esbuild" 14 | 15 | [template.environment] 16 | VITE_OMDB_API_KEY = "OMDB API Key (optional)" -------------------------------------------------------------------------------- /src/js/Components/Header.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | const Header = { 4 | view: () => 5 | m('header', { hidden: true }, [ 6 | m('.h-x-app.h-app', [ 7 | m('a.u-url', { href: '/' }, [ 8 | m('img.u-logo.p-name', { 9 | src: '/assets/icons/favicon-144x144.png', 10 | alt: 'sparkles' 11 | }) 12 | ]) 13 | ]) 14 | ]) 15 | } 16 | 17 | export default Header -------------------------------------------------------------------------------- /src/js/Components/Tile.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | const Tile = { 4 | view: ({ attrs }) => 5 | m(m.route.Link, { 6 | href: attrs.href, 7 | selector: 'button.sp-block', 8 | disabled: attrs.disabled || false 9 | }, [ 10 | m('div', [ 11 | m(`i.${attrs.icon}`, { 'aria-hidden': 'true' }), 12 | m('.sp-block-title', attrs.name) 13 | ]) 14 | ]) 15 | } 16 | 17 | export default Tile 18 | -------------------------------------------------------------------------------- /src/js/Layouts/NoAuthLayout.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import DefaultLayout from './DefaultLayout' 4 | import Store from '../Models/Store' 5 | 6 | const NoAuthLayout = v => ({ 7 | onmatch: () => { 8 | if (Store.getSession('access_token')) { 9 | m.route.set('/home') 10 | } else { 11 | return v 12 | } 13 | }, 14 | render: vnode => m(DefaultLayout, vnode) 15 | }) 16 | 17 | export default NoAuthLayout -------------------------------------------------------------------------------- /src/functions/redirect.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { isValidURL, Error, Response } from './lib/utils' 3 | 4 | exports.handler = async e => { 5 | const { url } = e.queryStringParameters 6 | 7 | if (!isValidURL(url)) { 8 | return Response.error(Error.INVALID, 'Invalid URL') 9 | } 10 | 11 | const res = await fetch(url) 12 | return { 13 | statusCode: res.status, 14 | body: res.status === 200 ? 'success' : 'not found' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/js/Layouts/AuthLayout.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import DefaultLayout from './DefaultLayout' 4 | import Store from '../Models/Store' 5 | 6 | const AuthLayout = v => ({ 7 | onmatch: () => { 8 | if (!Store.getSession('access_token')) { 9 | m.route.set('/login') 10 | } else if (!v) { 11 | m.route.set('/home') 12 | } else { 13 | return v 14 | } 15 | }, 16 | render: vnode => m(DefaultLayout, vnode) 17 | }) 18 | 19 | export default AuthLayout -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "extends": "eslint:recommended", 12 | "rules": { 13 | "camelcase": ["error"], 14 | "comma-dangle": 0, 15 | "indent": [ 2, "tab" ], 16 | "no-trailing-spaces": "error", 17 | "semi": [2, "never"], 18 | "quotes": ["error", "single"], 19 | "guard-for-in": "error" 20 | } 21 | } -------------------------------------------------------------------------------- /src/js/utils/index.js: -------------------------------------------------------------------------------- 1 | const currentTime = () => Math.floor(Date.now() / 1000) 2 | 3 | const formatDate = t => (new Date(t * 1000)).toLocaleDateString() 4 | 5 | // https://indieauth.spec.indieweb.org/#url-canonicalization 6 | const canonicalURL = urlString => { 7 | let url 8 | try { 9 | url = new URL(urlString) 10 | } catch (_) { 11 | return null 12 | } 13 | return url && ['http:', 'https:'].includes(url.protocol) ? url.href : null 14 | } 15 | 16 | export { 17 | canonicalURL, 18 | currentTime, 19 | formatDate 20 | } -------------------------------------------------------------------------------- /src/js/Components/Modal/index.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | const Modal = content => { 4 | const view = [ 5 | m('.sp-modal-bg', { onclick: () => { 6 | modalContainer.classList.remove('show') 7 | setTimeout(() => { 8 | modalContainer.remove() 9 | }, 500) 10 | } }), 11 | // header 12 | m('.sp-modal-content', content) 13 | ] 14 | 15 | const modalContainer = document.createElement('div') 16 | modalContainer.className = 'sp-modal show' 17 | document.body.appendChild(modalContainer) 18 | m.render(modalContainer, view) 19 | } 20 | 21 | export { Modal } 22 | -------------------------------------------------------------------------------- /src/js/Components/Box/index.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | const BoxHeader = { 4 | closeButton: () => 5 | m('button', { 6 | onclick: () => history.length <= 2 ? m.route.set('/') : history.back() 7 | }, [ 8 | m('i.fas.fa-xmark', { title: 'close' }) 9 | ]), 10 | view: ({ attrs }) => 11 | m('.sp-box-header', [ 12 | BoxHeader.closeButton(), 13 | m('span.sp-box-header-title', [ 14 | m(`i${attrs.icon}`, { 'aria-hidden': 'true' }), 15 | m('span', ` ${attrs.name}`) 16 | ]), 17 | BoxHeader.closeButton() 18 | ]) 19 | } 20 | 21 | export { 22 | BoxHeader 23 | } -------------------------------------------------------------------------------- /src/scss/_rating.scss: -------------------------------------------------------------------------------- 1 | .rating { 2 | --star-default: lightgrey; 3 | --star-active: goldenrod; 4 | --star-hover: goldenrod; 5 | } 6 | 7 | .rating-group { 8 | display: inline-flex; 9 | i { 10 | pointer-events: none; 11 | } 12 | input { 13 | display: none; 14 | } 15 | label { 16 | cursor: pointer; 17 | padding: 0 0.1em; 18 | font-size: 1.4rem; 19 | &.half { 20 | padding-right: 0; 21 | margin-right: -0.6em; 22 | z-index: 2; 23 | width: .85rem; 24 | } 25 | } 26 | i { 27 | color: var(--star-active); 28 | } 29 | input:checked ~ label i { 30 | color: var(--star-default); 31 | } 32 | &:hover label i { 33 | color: var(--star-hover) !important; 34 | } 35 | input:hover ~ label i { 36 | color: var(--star-default) !important; 37 | } 38 | } -------------------------------------------------------------------------------- /src/js/Components/Alert.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | const Alert = { 4 | show: (type, content, timeout) => { 5 | const view = [ 6 | m('.sp-alert-content', content) 7 | ] 8 | 9 | const modalContainer = document.createElement('div') 10 | modalContainer.className = 'sp-alert show' 11 | document.body.appendChild(modalContainer) 12 | m.render(modalContainer, view) 13 | 14 | setTimeout(() => { 15 | modalContainer.remove() 16 | }, timeout || 3000) 17 | }, 18 | error: err => { 19 | let error 20 | if (err && err.response) { 21 | error = err.response.error_description || err.response.error 22 | } else if (err) { 23 | error = err.message || err 24 | } 25 | Alert.show('error', error || 'An unexpected error has occurred') 26 | } 27 | } 28 | 29 | export default Alert 30 | -------------------------------------------------------------------------------- /src/functions/token.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { Response } from './lib/utils' 3 | 4 | exports.handler = async e => { 5 | // eslint-disable-next-line camelcase 6 | const { code, client_id, redirect_uri, token_endpoint, code_verifier } = e.queryStringParameters 7 | 8 | // https://indieauth.spec.indieweb.org/#request 9 | const params = new URLSearchParams() 10 | params.append('grant_type', 'authorization_code') 11 | params.append('code', code) 12 | params.append('client_id', client_id) 13 | params.append('redirect_uri', redirect_uri) 14 | // eslint-disable-next-line camelcase 15 | code_verifier && params.append('code_verifier', code_verifier) 16 | 17 | const response = await fetch(token_endpoint, { 18 | method: 'POST', 19 | body: params, 20 | headers: { 21 | 'Accept': 'application/json', 22 | 'Content-Type': 'application/x-www-form-urlencoded' 23 | } 24 | }) 25 | const body = await response.json() 26 | 27 | return Response.success(body) 28 | } 29 | -------------------------------------------------------------------------------- /src/js/Components/Rating.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | // Starting point: 4 | // https://codepen.io/andreacrawford/pen/NvqJXW 5 | const Rating = { 6 | view: ({ attrs }) => 7 | m('div.rating', 8 | m('fieldset.rating-group', [ 9 | m('input', { 10 | id: 'rating-0', 11 | type: 'radio', 12 | value: 0, 13 | name: 'rating', 14 | checked: !attrs.value 15 | }), 16 | [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map(r => [ 17 | m('label', { 18 | title: `${r} stars`, 19 | class: r % 1 === 0 ? 'full' : 'half', 20 | for: `rating-${r}` 21 | }, m('i.fas' + (r % 1 === 0 ? '.fa-star' : '.fa-star-half'))), 22 | m('input', { 23 | id: `rating-${r}`, 24 | type: 'radio', 25 | value: r, 26 | name: 'rating', 27 | onchange: e => { 28 | attrs && attrs.onchange && attrs.onchange(e.target.value) 29 | }, 30 | checked: attrs.value == r 31 | }) 32 | ]) 33 | ])) 34 | } 35 | 36 | export default Rating -------------------------------------------------------------------------------- /src/scss/_constants.scss: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --sprk-fg-color: black; 4 | --sprk-bg-color: white; 5 | --sprk-fade-color: #a3a3a3; 6 | --sprk-dim-color: #d3d3d3; 7 | --sprk-highlight-color: #333; 8 | --sprk-error-color: #ff6961; 9 | --sprk-success-color: #3cb371; 10 | 11 | --input-border: var(--sprk-dim-color); 12 | --input-border-size: .1rem; 13 | --input-border-active: var(--sprk-highlight-color); 14 | 15 | --window-shadow: var(--sprk-fg-color); 16 | 17 | --sprk-bg: var(--sprk-bg-color); 18 | --sprk-fg: var(--sprk-fg-color); 19 | --sprk-border-size: 2px; 20 | --sprk-border-color: var(--sprk-fg-color); 21 | 22 | --sprk-icon-link: var(--sprk-fg-color); 23 | 24 | --sprk-disabled: var(--sprk-fade-color); 25 | 26 | --sprk-placeholder: var(--sprk-highlight-color); 27 | 28 | --sprk-shadow-size: .25rem; 29 | } 30 | 31 | [data-theme='dark'] { 32 | --sprk-fg-color: white; 33 | --sprk-bg-color: black; 34 | --sprk-fade-color: #666; 35 | --sprk-dim-color: #333; 36 | --sprk-highlight-color: #d3d3d3; 37 | } 38 | 39 | [data-ui='simple'] { 40 | --sprk-shadow-size: 0; 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Benji Encalada Mora 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sparkles", 3 | "version": "0.1.3", 4 | "description": "micropub client", 5 | "main": "./src/js/app.js", 6 | "scripts": { 7 | "build:vite": "vite build", 8 | "start:vite": "vite", 9 | "bump": "npm --no-git-tag-version version", 10 | "lint": "eslint src", 11 | "preview": "vite preview" 12 | }, 13 | "keywords": [ 14 | "indieweb", 15 | "micropub", 16 | "netlify", 17 | "lambda", 18 | "serverless" 19 | ], 20 | "author": "Benji Encalada Mora ", 21 | "license": "MIT", 22 | "homepage": "https://sparkles.sploot.com", 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/benjifs/sparkles.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/benjifs/sparkles/issues" 29 | }, 30 | "devDependencies": { 31 | "eslint": "^8.30.0", 32 | "sass": "^1.57.1", 33 | "vite": "^4.0.3" 34 | }, 35 | "dependencies": { 36 | "cheerio": "^1.0.0-rc.12", 37 | "encoding": "^0.1.13", 38 | "mithril": "^2.2.2", 39 | "node-fetch": "^3.3.0" 40 | }, 41 | "engines": { 42 | "node": "18.x", 43 | "npm": "8.x" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/js/Controllers/Helpers.js: -------------------------------------------------------------------------------- 1 | import Proxy from './Proxy' 2 | import Alert from '../Components/Alert' 3 | import Store from '../Models/Store' 4 | 5 | import { currentTime } from '../utils' 6 | 7 | const fetchMicropubConfig = async (force) => { 8 | const micropubConfigFetched = Store.getCache('micropubConfigFetched') 9 | if (micropubConfigFetched > 0 && !force) return 10 | try { 11 | const { response } = await Proxy.micropub({ 12 | params: { 13 | q: 'config' 14 | } 15 | }) 16 | Store.addToSession(response) 17 | Store.addToCache({ micropubConfigFetched: currentTime() }) 18 | } catch(err) { 19 | Alert.error(err) 20 | } 21 | } 22 | 23 | const fetchMediaConfig = async (force) => { 24 | const mediaConfigFetched = Store.getCache('mediaConfigFetched') 25 | if (mediaConfigFetched > 0 && !force) return 26 | try { 27 | const { response } = await Proxy.media({ 28 | params: { 29 | q: 'config' 30 | } 31 | }) 32 | Store.addToSession({ mediaConfig: response.q }) 33 | Store.addToCache({ mediaConfigFetched: currentTime() }) 34 | } catch(err) { 35 | Alert.error(err) 36 | } 37 | } 38 | 39 | export { 40 | fetchMediaConfig, 41 | fetchMicropubConfig 42 | } -------------------------------------------------------------------------------- /src/js/Pages/HomePage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import { fetchMicropubConfig } from '../Controllers/Helpers' 4 | import { 5 | NoteTile, 6 | ImageTile, 7 | ReplyTile, 8 | BookmarkTile, 9 | LikeTile, 10 | ArticleTile, 11 | RSVPTile, 12 | MovieTile 13 | } from '../Editors/Tiles' 14 | import Store from '../Models/Store' 15 | 16 | const OMDB_API_KEY = import.meta.env.VITE_OMDB_API_KEY 17 | 18 | const HomePage = () => { 19 | const me = Store.getMe() 20 | 21 | return { 22 | oninit: () => fetchMicropubConfig(), 23 | view: () => [ 24 | m('section.text-center', [ 25 | m('.sp-box', [ 26 | m('.sp-tiles.sp-box-content', [ 27 | m(NoteTile), 28 | m(ImageTile), 29 | m(ReplyTile), 30 | m(BookmarkTile), 31 | m(LikeTile), 32 | m(ArticleTile), 33 | m(RSVPTile), 34 | OMDB_API_KEY ? m(MovieTile) : null 35 | ]) 36 | ]) 37 | ]), 38 | m('section', [ 39 | m('p', [ 40 | 'Logged in as ', 41 | m('a', { href: me }, me), 42 | ' ', 43 | m(m.route.Link, { class: 'icon', href: '/logout' }, m('i.fas.fa-right-from-bracket', { title: 'logout' })) 44 | ]) 45 | ]) 46 | ] 47 | } 48 | } 49 | 50 | export default HomePage 51 | -------------------------------------------------------------------------------- /src/js/utils/crypt.js: -------------------------------------------------------------------------------- 1 | // https://docs.cotter.app/sdk-reference/api-for-other-mobile-apps/api-for-mobile-apps#step-1-create-a-code-verifier 2 | const dec2hex = dec => ('0' + dec.toString(16)).substr(-2) 3 | 4 | const generateRandomString = length => { 5 | const array = new Uint32Array(length / 2) 6 | window.crypto.getRandomValues(array) 7 | return Array.from(array, dec2hex).join('') 8 | } 9 | 10 | // https://docs.cotter.app/sdk-reference/api-for-other-mobile-apps/api-for-mobile-apps#step-1-b-create-a-code-challenge-from-code-verifier 11 | const sha256 = plain => { 12 | const encoder = new TextEncoder() 13 | const data = encoder.encode(plain) 14 | return window.crypto.subtle.digest('SHA-256', data) 15 | } 16 | 17 | const base64 = a => { 18 | let str = '' 19 | const bytes = new Uint8Array(a) 20 | for (const b of bytes) { 21 | str += String.fromCharCode(b) 22 | } 23 | 24 | return window.btoa(str) 25 | .replace(/\+/g, '-') 26 | .replace(/\//g, '_') 27 | .replace(/=+$/, '') 28 | } 29 | 30 | const generateCodeChallenge = async (verifier) => { 31 | const hashed = await sha256(verifier) 32 | return base64(hashed) 33 | } 34 | 35 | export { 36 | generateRandomString, 37 | generateCodeChallenge 38 | } -------------------------------------------------------------------------------- /src/scss/_modal.scss: -------------------------------------------------------------------------------- 1 | 2 | .sp-modal { 3 | display: flex; 4 | align-items: center; 5 | flex-direction: column; 6 | justify-content: center; 7 | position: fixed; 8 | z-index: 1000; 9 | top: 0; bottom: 0; 10 | left: 0; right: 0; 11 | 12 | .sp-modal-bg { 13 | position: absolute; 14 | top: 0; bottom: 0; 15 | left: 0; right: 0; 16 | background: rgba(0, 0, 0, .3); 17 | } 18 | .sp-modal-content { 19 | position: relative; 20 | } 21 | 22 | .sp-modal-bg { 23 | animation: fade-out .3s forwards; 24 | } 25 | .sp-modal-content { 26 | animation: scroll-fade-out .3s forwards; 27 | } 28 | &.show { 29 | .sp-modal-bg { 30 | animation: fade-in .3s forwards; 31 | } 32 | .sp-modal-content { 33 | animation: scroll-fade-in .3s forwards; 34 | } 35 | } 36 | } 37 | 38 | @keyframes scroll-fade-in { 39 | from { opacity: 0; transform: translateY(25%); } 40 | to { opacity: 1; transform: translateY(0); } 41 | } 42 | 43 | @keyframes scroll-fade-out { 44 | from { opacity: 1; transform: translateY(0); } 45 | to { opacity: 0; transform: translateY(25%); } 46 | } 47 | 48 | @keyframes fade-in { 49 | from { opacity: 0; } 50 | to { opacity: 1; } 51 | } 52 | 53 | @keyframes fade-out { 54 | from { opacity: 1; } 55 | to { opacity: 0; } 56 | } -------------------------------------------------------------------------------- /src/functions/micropub.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { Error, Response } from './lib/utils' 3 | 4 | exports.handler = async e => { 5 | const endpoint = e.headers['x-micropub-endpoint'] 6 | if (!endpoint) { 7 | return Response.error(Error.INVALID, 'Missing micropub endpoint') 8 | } 9 | 10 | const authorization = e.headers.authorization 11 | const params = new URLSearchParams(e.queryStringParameters) 12 | 13 | let body 14 | try { 15 | body = e.body ? JSON.parse(e.body) : null 16 | } catch (err) { 17 | // return Response.error(Error.INVALID, 'Could not parse request body') 18 | } 19 | 20 | const res = await fetch(endpoint + (Array.from(params).length > 0 ? '?' + params : ''), { 21 | method: e.httpMethod, 22 | ...(body && { body: JSON.stringify(body) }), 23 | headers: { 24 | ...(authorization && { 'Authorization': authorization }), 25 | 'Content-Type': e.headers['content-type'] 26 | } 27 | }) 28 | 29 | console.log(`⇒ [${res.status}]`, res.headers) 30 | const location = res.headers.get('location') 31 | const contentType = res.headers.get('content-type') 32 | 33 | return { 34 | statusCode: res.status, 35 | headers: { 36 | ...Response.DEFAULT_HEADERS, 37 | 'Content-Type': contentType, 38 | ...(location && { 'Location': location }) 39 | }, 40 | body: await res.text() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/js/Components/Footer.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Store from '../Models/Store' 4 | 5 | const Footer = () => { 6 | const session = Store.getSession() 7 | let theme = Store.getSettings('theme') || 'light' 8 | document.documentElement.setAttribute('data-theme', theme) 9 | 10 | let ui = Store.getSettings('ui') 11 | if (ui) { 12 | document.documentElement.setAttribute('data-ui', ui) 13 | } 14 | 15 | const toggleTheme = () => { 16 | theme = theme === 'light' ? 'dark' : 'light' 17 | Store.addToSettings({ theme }) 18 | document.documentElement.setAttribute('data-theme', theme) 19 | } 20 | 21 | return { 22 | view: () => 23 | m('footer', [ 24 | null !== session && m(m.route.Link, { 25 | class: 'icon', 26 | href: '/settings', 27 | disabled: ['/settings'].includes(m.route.get()) 28 | }, [ 29 | m('i.fas.fa-gear', { title: 'settings' }) 30 | ]), 31 | m('a.icon', { onclick: toggleTheme }, theme === 'light' ? 32 | m('i.fas.fa-moon', { title: 'dark mode' }) 33 | : 34 | m('i.fas.fa-sun', { title: 'light mode' }) 35 | ), 36 | m(m.route.Link, { 37 | class: 'icon', 38 | href: '/about', 39 | disabled: ['/about'].includes(m.route.get()) 40 | }, [ 41 | m('i.far.fa-circle-question', { title: 'about' }) 42 | ]) 43 | ]) 44 | } 45 | } 46 | 47 | export default Footer -------------------------------------------------------------------------------- /src/functions/media.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { Error, Response } from './lib/utils' 3 | 4 | exports.handler = async e => { 5 | const endpoint = e.headers['x-media-endpoint'] 6 | if (!endpoint) { 7 | return Response.error(Error.INVALID, 'Missing media-endpoint') 8 | } 9 | 10 | const authorization = e.headers.authorization 11 | const params = new URLSearchParams(e.queryStringParameters) 12 | 13 | let body 14 | if (e.body) { 15 | if (e.headers['content-type'] === 'application/json') { 16 | try { 17 | body = e.body ? JSON.parse(e.body) : null 18 | } catch (err) { 19 | // return Response.error(Error.INVALID, 'Could not parse request body') 20 | } 21 | } else if (e.headers['content-type'].indexOf('multipart/form-data') >= 0) { 22 | body = Buffer.from(e.body, 'base64') 23 | } 24 | } 25 | 26 | const res = await fetch(endpoint + (Array.from(params).length > 0 ? '?' + params : ''), { 27 | method: e.httpMethod, 28 | body: body, 29 | headers: { 30 | ...(authorization && { 'Authorization': authorization }), 31 | ...(body && { 'Content-Type': e.headers['content-type'] }) 32 | } 33 | }) 34 | 35 | console.log(`⇒ [${res.status}]`, res.headers) 36 | 37 | return { 38 | statusCode: res.status, 39 | headers: { 40 | ...Response.DEFAULT_HEADERS, 41 | ...res.headers 42 | }, 43 | body: await res.text() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/functions/lib/utils.js: -------------------------------------------------------------------------------- 1 | const isValidURL = urlString => { 2 | let url 3 | try { 4 | url = new URL(urlString) 5 | } catch (_) { 6 | return false 7 | } 8 | return url && ['http:', 'https:'].includes(url.protocol) 9 | } 10 | 11 | const Error = { 12 | INVALID: { 'statusCode': 400, 'error': 'invalid_request' }, 13 | NOT_SUPPORTED: { 'statusCode': 400, 'error': 'invalid_response' }, 14 | UNAUTHORIZED: { 'statusCode': 401, 'error': 'unauthorized' }, 15 | FORBIDDEN: { 'statusCode': 403, 'error': 'forbidden' }, 16 | SCOPE: { 'statusCode': 403, 'error': 'insufficient_scope' }, 17 | NOT_FOUND: { 'statusCode': 404, 'error': 'not_found' }, 18 | NOT_ALLOWED: { 'statusCode': 405, 'error': 'method_not_allowed' } 19 | } 20 | 21 | const Response = { 22 | DEFAULT_HEADERS: { 23 | 'Access-Control-Allow-Origin': '*', 24 | 'Access-Control-Allow-Headers': 'Content-Type, authorization', 25 | 'Access-Control-Expose-Headers': 'Location' 26 | }, 27 | send: (status, body, headers) => ({ 28 | 'statusCode': status, 29 | 'headers': { 30 | ...Response.DEFAULT_HEADERS, 31 | ...(body && { 'Content-Type': 'application/json' }), 32 | ...(headers ? headers : {}) 33 | }, 34 | 'body': body ? JSON.stringify(body) : 'success' 35 | }), 36 | error: ({ statusCode, error }, description) => 37 | Response.send(statusCode, { 38 | 'error': error, 39 | 'error_description': description 40 | }), 41 | success: body => Response.send(200, body) 42 | } 43 | 44 | export { isValidURL, Error, Response } 45 | -------------------------------------------------------------------------------- /src/js/Pages/SharePage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import { BoxHeader } from '../Components/Box' 4 | import { 5 | LikeTile, 6 | ReplyTile, 7 | RSVPTile, 8 | BookmarkTile 9 | } from '../Editors/Tiles' 10 | 11 | const SharePage = () => { 12 | const parameterList = new URLSearchParams(window.location.search) 13 | const params = { 14 | title: parameterList.get('title'), 15 | text: parameterList.get('text'), 16 | url: parameterList.get('url') 17 | } 18 | 19 | if (!parameterList.has('url')) { 20 | parameterList.set('url', parameterList.get('text')) 21 | parameterList.delete('text') 22 | } 23 | 24 | return { 25 | view: () => 26 | m('section.sp-content.text-center', [ 27 | m('.sp-box', [ 28 | m(BoxHeader, { 29 | icon: '.fas.fa-share-nodes', 30 | name: 'Share Target' 31 | }), 32 | m('.sp-box-content.text-center', [ 33 | m('p', [ 34 | 'How would you like to share this content?', 35 | m('br'), 36 | m('b', 'title:'), 37 | params.title, 38 | m('br'), 39 | m('b', 'text:'), 40 | params.text, 41 | m('br'), 42 | m('b', 'url:'), 43 | params.url 44 | ]), 45 | m('.sp-tiles', [ 46 | m(ReplyTile, { params: parameterList.toString() }), 47 | m(BookmarkTile, { params: parameterList.toString() }), 48 | m(LikeTile, { params: parameterList.toString() }), 49 | m(RSVPTile, { params: parameterList.toString() }) 50 | ]) 51 | ]) 52 | ]) 53 | ]) 54 | } 55 | } 56 | 57 | export default SharePage -------------------------------------------------------------------------------- /src/js/Pages/AboutPage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import { BoxHeader } from '../Components/Box' 4 | 5 | const AboutPage = { 6 | view: () => 7 | m('section.sp-content.text-center', [ 8 | m('.sp-box', [ 9 | m(BoxHeader, { 10 | icon: '.far.fa-question-circle', 11 | name: 'About' 12 | }), 13 | m('.sp-box-content.text-center', [ 14 | m('p', [ 15 | 'sparkles is a ', 16 | m('a', { href: 'https://micropub.net/', target: '_blank' }, 'micropub'), 17 | ' client. You can create posts from here to add to your micropub compatible website.' 18 | ]), 19 | m('p', [ 20 | 'For more detailed information about sparkles, read the ', 21 | m('a', { href: 'https://benji.dog/articles/sparkles/', target: '_blank' }, 'announcement'), 22 | ' post.' 23 | ]), 24 | m('p', [ 25 | 'Built with ', 26 | m('a', { href: 'https://mithriljs.org', target: '_blank' }, 'MithrilJS'), 27 | ', ', 28 | m('a', { href: 'https://sass-lang.com', target: '_blank' }, 'SCSS'), 29 | ', and ', 30 | m('a', { href: 'https://fontawesome.com', target: '_blank' }, 'FontAwesome'), 31 | '. Deployed to ', 32 | m('a', { href: 'https://netlify.app', target: '_blank' }, 'Netlify'), 33 | '.' 34 | ]), 35 | m('p', [ 36 | 'Source Code and Issues: ', 37 | m('a.icon', { href: 'https://github.com/benjifs/sparkles', target: '_blank' }, m('i.fab.fa-github', { title: 'Github' })) 38 | ]), 39 | m('p', [ 40 | 'By ', 41 | m('a', { href: 'https://benji.dog', target: '_blank' }, 'benji') 42 | ]) 43 | ]) 44 | ]) 45 | ]) 46 | } 47 | 48 | export default AboutPage -------------------------------------------------------------------------------- /src/functions/discover.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import * as cheerio from 'cheerio' 3 | import { isValidURL, Response, Error } from './lib/utils' 4 | 5 | const requiredRels = ['authorization_endpoint', 'token_endpoint', 'micropub'] 6 | 7 | const getRelURL = ($, rel) => $ && rel ? $(`[rel='${rel}']`).attr('href') : null 8 | const absoluteURL = (url, baseURL) => url && !url.match(/^https?:\/\//) ? `${baseURL}${url}` : url 9 | 10 | exports.handler = async e => { 11 | const urlString = e.queryStringParameters.url 12 | if (!isValidURL(urlString)) { 13 | return Response.error(Error.INVALID, 'Invalid URL') 14 | } 15 | 16 | try { 17 | const response = await fetch(urlString) 18 | const body = await response.text() 19 | const $ = cheerio.load(body) 20 | 21 | let json 22 | // https://indieauth.spec.indieweb.org/#discovery-by-clients 23 | const metadataURL = absoluteURL(getRelURL($, 'indieauth-metadata'), urlString) 24 | if (metadataURL) { 25 | const res = await fetch(metadataURL) 26 | json = await res.json() 27 | } else { 28 | json = { 29 | 'authorization_endpoint': absoluteURL(getRelURL($, 'authorization_endpoint'), urlString), 30 | 'token_endpoint': absoluteURL(getRelURL($, 'token_endpoint'), urlString), 31 | } 32 | } 33 | if (json) { 34 | json['micropub'] = absoluteURL(getRelURL($, 'micropub'), urlString) 35 | } 36 | 37 | for (const k of requiredRels) { 38 | if (!json || !json[k]) { 39 | return Response.error(Error.INVALID, `Could not find rel=${k}`) 40 | } 41 | } 42 | 43 | return Response.success(json) 44 | } catch (err) { 45 | console.error('[ERROR]', err && err.message) 46 | } 47 | 48 | return Response.error(Error.NOT_FOUND) 49 | } 50 | -------------------------------------------------------------------------------- /src/js/Pages/SuccessPage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Proxy from '../Controllers/Proxy' 4 | import Store from '../Models/Store' 5 | 6 | const RETRY_TIMEOUT = 5 // in seconds 7 | const MAX_CHECKS = 5 8 | 9 | const SuccessPage = () => { 10 | let timeout, count = 0, found = false 11 | let url = (new URLSearchParams(window.location.search)).get('url') 12 | const baseURL = Store.getMe() 13 | // Just in case the url received is not an absolute URL 14 | try { 15 | url = new URL(url, baseURL).href 16 | } catch (e) { 17 | console.error(e) 18 | } 19 | 20 | const checkURL = async () => { 21 | if (!url) { 22 | return m.route.set('/') 23 | } 24 | count++ 25 | const found = await Proxy.redirect(url) 26 | if (found) { 27 | window.location.href = url 28 | } else if (count < MAX_CHECKS) { 29 | timeout = setTimeout(() => { 30 | checkURL(url) 31 | }, RETRY_TIMEOUT * 1000) 32 | } 33 | } 34 | 35 | return { 36 | oninit: () => checkURL(), 37 | onremove: () => timeout && clearTimeout(timeout), 38 | view: () => 39 | m('section.sp-content.text-center', [ 40 | m('.sp-box', [ 41 | m('.sp-box-content.text-center', [ 42 | m('p', 'Post created successfully '), 43 | m('a', { href: url }, url), 44 | !found && m('p', [ 45 | 'Post is not live. ', 46 | count >= MAX_CHECKS && 'Exceeded amount of automatic checks. Post might be taking longer to show up.', 47 | count < MAX_CHECKS && `Checking again in ${RETRY_TIMEOUT} seconds `, 48 | count < MAX_CHECKS && m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) 49 | ]), 50 | m(m.route.Link, { href: '/home' }, 'Go home') 51 | ]) 52 | ]) 53 | ]) 54 | } 55 | } 56 | 57 | export default SuccessPage -------------------------------------------------------------------------------- /src/js/Pages/CallbackPage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Alert from '../Components/Alert' 4 | import Proxy from '../Controllers/Proxy' 5 | import Store from '../Models/Store' 6 | 7 | const CallbackPage = { 8 | oninit: async () => { 9 | // https://indieauth.spec.indieweb.org/#authorization-response 10 | const parameterList = new URLSearchParams(window.location.search) 11 | const params = { 12 | code: parameterList.get('code'), 13 | state: parameterList.get('state'), 14 | // indieauth.com does not return `iss` 15 | iss: parameterList.get('iss') 16 | } 17 | 18 | try { 19 | const state = Store.getSession('state') 20 | if (params.state != state) throw new Error('"state" value does not match') 21 | if (!params.code) throw new Error('missing "code" param') 22 | 23 | // From the spec, this should be checked and fail 24 | // to support legacy, skip this check for now 25 | /* eslint-disable camelcase */ 26 | // const authorization_endpoint = Store.getSession('authorization_endpoint') 27 | // if (params.iss != authorization_endpoint) throw new Error('"iss" does not match "authorization_endpoint"') 28 | 29 | // https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code 30 | const { access_token, scope, token_type } = await Proxy.validate(params) 31 | // https://indieauth.spec.indieweb.org/#access-token-response 32 | Store.addToSession({ access_token, scope, token_type }) 33 | /* eslint-enable camelcase */ 34 | m.route.set('/home') 35 | } catch(err) { 36 | Alert.error(err) 37 | m.route.set('/login') 38 | } 39 | }, 40 | view: () => 41 | m('p', [ 42 | 'Validating token ', 43 | m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) 44 | ]) 45 | } 46 | 47 | export default CallbackPage -------------------------------------------------------------------------------- /src/js/Models/Store.js: -------------------------------------------------------------------------------- 1 | 2 | const Store = { 3 | get: (key, prop) => { 4 | const data = localStorage.getItem(key) 5 | const json = data && data.length ? JSON.parse(data) : null 6 | return json && prop ? json[prop] : json 7 | }, 8 | set: (key, data) => localStorage.setItem(key, JSON.stringify(data)), 9 | add: (key, data) => Store.set(key, { ...Store.get(key), ...data }), 10 | clear: () => localStorage.clear(), 11 | // 12 | sessionKey: '_mp', 13 | getSession: prop => Store.get(Store.sessionKey, prop), 14 | setSession: data => Store.set(Store.sessionKey, data), 15 | addToSession: data => Store.add(Store.sessionKey, data), 16 | clearSession: () => localStorage.removeItem(Store.sessionKey), 17 | getMe: () => Store.getSession('issuer') || Store.getSession('me'), 18 | // 19 | settingKey: '_sprk', 20 | defaultSettings: { 21 | theme: 'light', 22 | advanced: false 23 | }, 24 | getSettings: prop => { 25 | const settings = Store.get(Store.settingKey) 26 | if (settings) return prop ? settings[prop] : settings 27 | Store.setSettings(Store.defaultSettings) 28 | return Store.get(Store.settingKey, prop) 29 | }, 30 | setSettings: data => Store.set(Store.settingKey, data), 31 | addToSettings: data => Store.add(Store.settingKey, data), 32 | // 33 | cacheKey: '_cache', 34 | defaultCache: { 35 | media: [], 36 | mediaFetched: 0, 37 | mediaConfigFetched: 0, 38 | micropubConfigFetched: 0 39 | }, 40 | getCache: prop => { 41 | const cache = Store.get(Store.cacheKey) 42 | if (cache) return prop ? cache[prop] : cache 43 | Store.setCache(Store.defaultCache) 44 | return Store.get(Store.cacheKey, prop) 45 | }, 46 | setCache: data => Store.set(Store.cacheKey, data), 47 | addToCache: data => Store.add(Store.cacheKey, data), 48 | clearCache: () => Store.setCache(Store.defaultCache) 49 | } 50 | 51 | export default Store 52 | -------------------------------------------------------------------------------- /src/js/Editors/Tiles.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Tile from '../Components/Tile' 4 | 5 | const NoteTile = { 6 | view: () => m(Tile, { 7 | href: '/new/note', 8 | icon: '.far.fa-note-sticky', 9 | name: 'Note' 10 | }) 11 | } 12 | 13 | const ArticleTile = { 14 | view: () => m(Tile, { 15 | href: '/new/article', 16 | icon: '.fas.fa-newspaper', 17 | name: 'Article' 18 | }) 19 | } 20 | 21 | const LikeTile = { 22 | view: ({ attrs }) => 23 | m(Tile, { 24 | href: `/new/like${attrs.params ? '?' + attrs.params : ''}`, 25 | icon: '.fas.fa-heart', 26 | name: 'Like' 27 | }) 28 | } 29 | 30 | const ReplyTile = { 31 | view: ({ attrs }) => 32 | m(Tile, { 33 | href: `/new/reply${attrs.params ? '?' + attrs.params : ''}`, 34 | icon: '.fas.fa-reply', 35 | name: 'Reply' 36 | }) 37 | } 38 | 39 | const ImageTile = { 40 | view: () => m(Tile, { 41 | href: '/new/image', 42 | icon: '.far.fa-image', 43 | name: 'Image' 44 | }) 45 | } 46 | 47 | const RSVPTile = { 48 | view: ({ attrs }) => m(Tile, { 49 | href: `/new/rsvp${attrs.params ? '?' + attrs.params : ''}`, 50 | icon: '.far.fa-calendar-check', 51 | name: 'RSVP' 52 | }) 53 | } 54 | 55 | const BookmarkTile = { 56 | view: ({ attrs }) => 57 | m(Tile, { 58 | href: `/new/bookmark${attrs.params ? '?' + attrs.params : ''}`, 59 | icon: '.far.fa-bookmark', 60 | name: 'Bookmark' 61 | }) 62 | } 63 | 64 | const RecipeTile = { 65 | view: () => m(Tile, { 66 | href: '/new/recipe', 67 | icon: '.fas.fa-utensils', 68 | name: 'Recipe', 69 | disabled: true 70 | }) 71 | } 72 | 73 | const MovieTile = { 74 | view: () => m(Tile, { 75 | href: '/new/movie', 76 | icon: '.fas.fa-film', 77 | name: 'Movie' 78 | }) 79 | } 80 | 81 | export { 82 | NoteTile, 83 | ArticleTile, 84 | ReplyTile, 85 | BookmarkTile, 86 | LikeTile, 87 | ImageTile, 88 | RSVPTile, 89 | RecipeTile, 90 | MovieTile 91 | } 92 | -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import AuthLayout from './Layouts/AuthLayout' 4 | import NoAuthLayout from './Layouts/NoAuthLayout' 5 | import DefaultLayout from './Layouts/DefaultLayout' 6 | 7 | import AboutPage from './Pages/AboutPage' 8 | import CallbackPage from './Pages/CallbackPage' 9 | import HomePage from './Pages/HomePage' 10 | import LoginPage from './Pages/LoginPage' 11 | import LogoutPage from './Pages/LogoutPage' 12 | import SettingsPage from './Pages/SettingsPage' 13 | import SharePage from './Pages/SharePage' 14 | import SuccessPage from './Pages/SuccessPage' 15 | 16 | import { 17 | ArticleEditor, 18 | BookmarkEditor, 19 | LikeEditor, 20 | NoteEditor, 21 | ReplyEditor, 22 | RSVPEditor 23 | } from './Editors' 24 | import ImageEditor from './Editors/ImageEditor' 25 | import MovieEditor from './Editors/MovieEditor' 26 | 27 | import Store from './Models/Store' 28 | 29 | import '../scss/main.scss' 30 | 31 | m.route.prefix = '' 32 | 33 | m.route(document.body, '/', { 34 | '/': { 35 | // https://mithril.js.org/route.html#redirection 36 | onmatch: () => { 37 | !Store.getSession('access_token') ? m.route.set('/login') : m.route.set('/home') 38 | } 39 | }, 40 | '/callback': NoAuthLayout(CallbackPage), 41 | '/about': { render: () => m(DefaultLayout, m(AboutPage)) }, 42 | '/login': NoAuthLayout(LoginPage), 43 | '/logout': AuthLayout(LogoutPage), 44 | '/home': AuthLayout(HomePage), 45 | '/share': AuthLayout(SharePage), 46 | '/success': AuthLayout(SuccessPage), 47 | '/settings': AuthLayout(SettingsPage), 48 | // editors 49 | '/new/note': AuthLayout(NoteEditor), 50 | '/new/image': AuthLayout(ImageEditor), 51 | '/new/article': AuthLayout(ArticleEditor), 52 | '/new/bookmark': AuthLayout(BookmarkEditor), 53 | '/new/reply': AuthLayout(ReplyEditor), 54 | '/new/like': AuthLayout(LikeEditor), 55 | '/new/rsvp': AuthLayout(RSVPEditor), 56 | '/new/movie': AuthLayout(MovieEditor) 57 | }) 58 | -------------------------------------------------------------------------------- /public/assets/dot.svg: -------------------------------------------------------------------------------- 1 | 5 | 10 | 11 | 12 | 17 | 18 | 19 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/js/Components/Gallery.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import { fetchMediaConfig } from '../Controllers/Helpers' 4 | import Proxy from '../Controllers/Proxy' 5 | import Store from '../Models/Store' 6 | import { currentTime } from '../utils' 7 | 8 | const PAGE_SIZE = 10 9 | 10 | const Gallery = () => { 11 | const cache = Store.getCache() 12 | let loading = false, page = 0, images = cache.media || [] 13 | const mediaEndpoint = Store.getSession('media-endpoint') 14 | let mediaConfig = Store.getSession('mediaConfig') 15 | 16 | const loadGallery = async (force) => { 17 | if (cache.mediaFetched > 0 && cache.mediaFetched > currentTime() - 1800 && force !== true) return 18 | 19 | loading = true 20 | 21 | await fetchMediaConfig() 22 | mediaConfig = Store.getSession('mediaConfig') 23 | 24 | if (mediaConfig && mediaConfig.includes('source')) { 25 | const { response } = await Proxy.media({ 26 | params: { q: 'source' } 27 | }) 28 | images = (response && response.items) || [] 29 | Store.addToCache({ media: images, mediaFetched: currentTime() }) 30 | } 31 | loading = false 32 | } 33 | 34 | return { 35 | oninit: () => loadGallery(), 36 | view: () => { 37 | if (!mediaEndpoint) return null 38 | if (loading && !images.length) return m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) 39 | if (!mediaConfig || !mediaConfig.includes('source')) return m('h5', 'q=source for media-endpoint not found') 40 | 41 | return [ 42 | m('button', { 43 | title: 'refresh', 44 | type: 'button', 45 | onclick: () => loadGallery(true) 46 | }, loading ? 47 | m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) 48 | : 49 | m('i.fas.fa-rotate-right')), 50 | m('.sp-gallery', [ 51 | images.slice(0, (page + 1) * PAGE_SIZE).map(i => m('img', { src: i.url })) 52 | ]), 53 | images.length > (page + 1) * PAGE_SIZE && m('button', { type: 'button', onclick: () => page++ }, 'load more') 54 | ] 55 | } 56 | } 57 | } 58 | 59 | export default Gallery -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # sparkles 3 |

4 | sparkles icon 5 |

6 | 7 |
8 | 9 | Netlify Status 10 | 11 |
12 |
13 | 14 | Project License 15 | 16 | 17 | Latest Version 18 | 19 | 20 | Latest Commit 21 | 22 |
23 | 24 | [sparkles](https://sparkles.sploot.com) is a [Micropub](https://micropub.spec.indieweb.org/) client. It supports [IndieAuth](https://indieauth.net/) for login and expects a [micropub endpoint](https://indieweb.org/Micropub/Servers) to communicate with to publish posts. It supports basic micropub content types and you can also add movies you have watched. 25 | 26 | sparkles can also be installed as a [Progressive Web App (PWA)](https://web.dev/progressive-web-apps/) on supported devices which will add the app as a **share target** and also add some quick action options. 27 | 28 | You can read more about this project [here](https://benji.dog/articles/sparkles/) and try it for yourself at: https://sparkles.sploot.com 29 | 30 | ## Development 31 | 32 | ### Requirements 33 | * `node 18.12.1` 34 | * `npm 8.19.2` 35 | * `npm install -g netlify-cli` 36 | 37 | ### Environment Variables 38 | | name | description | required | 39 | | --- | --- | --- | 40 | | VITE_OMDB_API_KEY | [OMDB API Key](https://www.omdbapi.com/) | optional | 41 | 42 | ### Build 43 | * Clone this repository 44 | * `npm install` 45 | * Run `netlify dev` to test locally 46 | * Frontend: `http://localhost:5173` 47 | * Functions: `http://localhost:9000` 48 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sparkles", 3 | "short_name": "sparkles", 4 | "start_url": "/", 5 | "scope": "/", 6 | "display": "standalone", 7 | "background_color": "#E69BDD", 8 | "theme_color": "#E69BDD", 9 | "homepage_url": "https://sparkles.sploot.com", 10 | "icons": [ 11 | { 12 | "src": "/assets/icons/android-icon-36x36.png", 13 | "sizes": "36x36", 14 | "type": "image/png", 15 | "density": "0.75" 16 | }, 17 | { 18 | "src": "/assets/icons/android-icon-48x48.png", 19 | "sizes": "48x48", 20 | "type": "image/png", 21 | "density": "1.0" 22 | }, 23 | { 24 | "src": "/assets/icons/android-icon-72x72.png", 25 | "sizes": "72x72", 26 | "type": "image/png", 27 | "density": "1.5" 28 | }, 29 | { 30 | "src": "/assets/icons/android-icon-96x96.png", 31 | "sizes": "96x96", 32 | "type": "image/png", 33 | "density": "2.0" 34 | }, 35 | { 36 | "src": "/assets/icons/android-icon-144x144.png", 37 | "sizes": "144x144", 38 | "type": "image/png", 39 | "density": "3.0" 40 | }, 41 | { 42 | "src": "/assets/icons/android-icon-192x192.png", 43 | "sizes": "192x192", 44 | "type": "image/png", 45 | "density": "4.0" 46 | } 47 | ], 48 | "share_target": { 49 | "action": "/share", 50 | "method": "GET", 51 | "enctype": "application/x-www-form-urlencoded", 52 | "params": { 53 | "title": "title", 54 | "text": "text", 55 | "url": "url" 56 | } 57 | }, 58 | "shortcuts": [ 59 | { 60 | "name": "New note", 61 | "short_name": "note", 62 | "description": "Add a new note", 63 | "url": "/new/note", 64 | "icons": [{ "src": "/assets/note-192.png", "sizes": "192x192" }] 65 | }, 66 | { 67 | "name": "New image", 68 | "short_name": "image", 69 | "description": "Upload a new image", 70 | "url": "/new/image", 71 | "icons": [{ "src": "/assets/image-192.png", "sizes": "192x192" }] 72 | } 73 | ], 74 | "description": "A micropub client... hopefully", 75 | "screenshots": [ 76 | { 77 | "src": "/assets/screenshots/screenshot1.jpg", 78 | "type": "image/jpg", 79 | "sizes": "495x730" 80 | } 81 | ], 82 | "categories": [ 83 | "micropub" 84 | ] 85 | } -------------------------------------------------------------------------------- /src/scss/_base.scss: -------------------------------------------------------------------------------- 1 | 2 | *, ::after, ::before { 3 | box-sizing: inherit; 4 | } 5 | 6 | html { 7 | box-sizing: border-box; 8 | height: 100%; 9 | } 10 | 11 | body { 12 | position: relative; 13 | height: 100%; 14 | margin: 0; 15 | 16 | font-family: 'Courier New', Courier, monospace; 17 | line-height: 1.4; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | text-rendering: optimizeLegibility; 21 | } 22 | 23 | body { 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | 28 | background-color: var(--sprk-bg); 29 | background-repeat: repeat; 30 | background-attachment: fixed; 31 | background-image: url("/assets/dot.svg"); 32 | background-size: 75px; 33 | } 34 | 35 | body, input, button, textarea { 36 | color: var(--sprk-fg); 37 | } 38 | 39 | main, header, footer { 40 | padding: .5em; 41 | } 42 | 43 | footer { 44 | width: 100%; 45 | text-align: right; 46 | } 47 | 48 | main { 49 | flex: 1; 50 | width: 100%; 51 | max-width: 550px; 52 | display: flex; 53 | flex-direction: column; 54 | justify-content: center; 55 | align-items: center; 56 | 57 | position: relative; 58 | } 59 | 60 | section { 61 | width: 100%; 62 | padding: .5em; 63 | } 64 | 65 | [role="button"], button { 66 | cursor: pointer; 67 | } 68 | 69 | input, textarea { 70 | &::placeholder { 71 | opacity: .5; 72 | color: var(--sprk-placeholder); 73 | } 74 | } 75 | 76 | // Validation 77 | input, textarea { 78 | &[required] { 79 | background-size: 1em 1em; 80 | background-position: right top; 81 | background-repeat: no-repeat; 82 | &:placeholder-shown { 83 | background-image: radial-gradient(var(--sprk-error-color) 40%, transparent 50%); 84 | } 85 | &:not(:placeholder-shown) { 86 | &:valid { 87 | background-image: radial-gradient(var(--sprk-success-color) 40%, transparent 50%); 88 | } 89 | &:not(:valid) { 90 | background-image: radial-gradient(var(--sprk-error-color) 40%, transparent 50%); 91 | } 92 | } 93 | } 94 | &:not(:valid):not(:placeholder-shown) { 95 | border-color: var(--sprk-error-color); 96 | } 97 | } 98 | 99 | fieldset { 100 | border: none; 101 | padding: 0; 102 | margin: 0; 103 | } 104 | 105 | a { 106 | text-decoration: none; 107 | color: var(--sprk-fg); 108 | &:not(.icon) { 109 | border-bottom: 1px dashed var(--sprk-fg); 110 | } 111 | // handle link wrap if overflows screen 112 | overflow-wrap: break-word; 113 | word-wrap: break-word; 114 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | s p a r k l e s 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/js/Pages/LoginPage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Alert from '../Components/Alert' 4 | import Proxy from '../Controllers/Proxy' 5 | import Store from '../Models/Store' 6 | import { canonicalURL } from '../utils' 7 | import { generateRandomString, generateCodeChallenge } from '../utils/crypt' 8 | 9 | const CLIENT = window.location.origin 10 | 11 | const Login = () => { 12 | let loading = false 13 | let urlString = '' 14 | 15 | const canSubmit = () => urlString && urlString !== '' 16 | 17 | const onLogin = async e => { 18 | e.preventDefault() 19 | loading = true 20 | 21 | try { 22 | const url = canonicalURL(urlString) 23 | if (!url) throw new Error('could not convert to canonical URL') 24 | 25 | const data = await Proxy.discover(url) 26 | 27 | /* eslint-disable camelcase */ 28 | const { issuer, authorization_endpoint, token_endpoint, code_challenge_methods_supported, micropub } = data 29 | if (!(authorization_endpoint && token_endpoint && micropub)) throw Error(`Missing rels for ${url}`) 30 | 31 | const state = generateRandomString(23) 32 | const verifier = generateRandomString(56) 33 | Store.setSession({ authorization_endpoint, token_endpoint, micropub, me: issuer || url, state, verifier }) 34 | 35 | // https://indieauth.spec.indieweb.org/#authorization-request 36 | const code_challenge_method = Array.isArray(code_challenge_methods_supported) ? code_challenge_methods_supported[0] : 'S256' 37 | const code_challenge = await generateCodeChallenge(verifier) 38 | 39 | const params = new URLSearchParams({ 40 | 'response_type': 'code', 41 | 'client_id': `${CLIENT}/`, 42 | 'redirect_uri': `${CLIENT}/callback`, 43 | 'state': state, 44 | 'code_challenge': code_challenge, 45 | 'code_challenge_method': code_challenge_method, 46 | 'scope': 'create', 47 | 'me': issuer || url 48 | }) 49 | 50 | window.location.href = `${authorization_endpoint}?${params.toString()}` 51 | /* eslint-enable camelcase */ 52 | } catch(err) { 53 | Alert.error(err) 54 | } 55 | 56 | loading = false 57 | } 58 | 59 | Store.clearSession() 60 | Store.clearCache() 61 | 62 | return { 63 | view: () => 64 | m('section', [ 65 | m('.sp-title', 'sparkles'), 66 | m('.sp-box', [ 67 | m('form.sp-box-content.text-center', { 68 | onsubmit: onLogin 69 | }, [ 70 | m('input', { 71 | type: 'url', 72 | placeholder: 'https://', 73 | oninput: e => urlString = e.target.value, 74 | value: urlString 75 | }), 76 | m('button', { 77 | type: 'submit', 78 | disabled: !canSubmit() || loading 79 | }, loading ? m('i.fas.fa-spinner.fa-spin') : 'login') 80 | ]) 81 | ]) 82 | ]) 83 | } 84 | } 85 | 86 | export default Login 87 | -------------------------------------------------------------------------------- /src/js/Controllers/Proxy.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Store from '../Models/Store' 4 | 5 | const FUNCTIONS = window.location.host === 'localhost:5173' ? 'http://localhost:9000' : '' 6 | const CLIENT = window.location.origin 7 | 8 | const Proxy = { 9 | discover: url => 10 | m 11 | .request({ 12 | method: 'GET', 13 | url: `${FUNCTIONS}/.netlify/functions/discover`, 14 | params: { url: url } 15 | }), 16 | validate: params => { 17 | const session = Store.getSession() 18 | if (!session) throw new Error('session not found') 19 | const { code } = params 20 | if (!code) throw new Error('missing "code"') 21 | 22 | return m 23 | .request({ 24 | method: 'GET', 25 | url: `${FUNCTIONS}/.netlify/functions/token`, 26 | params: { 27 | 'token_endpoint': session.token_endpoint, 28 | 'code': code, 29 | 'client_id': `${CLIENT}/`, 30 | 'redirect_uri': `${CLIENT}/callback`, 31 | // eslint-disable-next-line camelcase 32 | ...(session.verifier && { 'code_verifier': session.verifier }) 33 | } 34 | }) 35 | }, 36 | micropub: ({ method, params, body }) => { 37 | const session = Store.getSession() 38 | if (!session) throw new Error('session not found') 39 | if (!session.access_token) throw new Error('access_token not found') 40 | 41 | return m 42 | .request({ 43 | method: method || 'GET', 44 | url: `${FUNCTIONS}/.netlify/functions/micropub`, 45 | headers: { 46 | // 'Content-Type': 'application/json', 47 | 'Authorization': `Bearer ${session.access_token}`, 48 | 'x-micropub-endpoint': session.micropub 49 | }, 50 | params: params, 51 | body: body || null, 52 | extract: Proxy.extractResponse 53 | }) 54 | }, 55 | media: ({ method, params, body }) => { 56 | const session = Store.getSession() 57 | 58 | return m 59 | .request({ 60 | method: method || 'GET', 61 | url: `${FUNCTIONS}/.netlify/functions/media`, 62 | headers: { 63 | 'Authorization': `Bearer ${session.access_token}`, 64 | 'x-media-endpoint': session['media-endpoint'] 65 | }, 66 | params: params, 67 | body: body || null, 68 | extract: Proxy.extractResponse 69 | }) 70 | }, 71 | extractResponse: xhr => { 72 | let responseBody 73 | try { 74 | responseBody = JSON.parse(xhr.responseText) 75 | } catch(err) { 76 | responseBody = xhr.responseText 77 | } 78 | return { 79 | status: xhr.status, 80 | headers: { 81 | location: xhr.getResponseHeader('location') 82 | }, 83 | response: responseBody 84 | } 85 | }, 86 | redirect: async url => { 87 | try { 88 | await m.request({ 89 | method: 'GET', 90 | url: `${FUNCTIONS}/.netlify/functions/redirect?url=${url}` 91 | }) 92 | return true 93 | } catch (err) { 94 | console.error(`could not fetch ${url}`) 95 | } 96 | 97 | return false 98 | } 99 | } 100 | 101 | export default Proxy -------------------------------------------------------------------------------- /src/js/Editors/ImageEditor.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Alert from '../Components/Alert' 4 | import { BoxHeader } from '../Components/Box' 5 | import Gallery from '../Components/Gallery' 6 | import Proxy from '../Controllers/Proxy' 7 | import Store from '../Models/Store' 8 | import { currentTime } from '../utils' 9 | 10 | const ImageEditor = () => { 11 | let loading = false, image, preview, uploaded 12 | const mediaEndpoint = Store.getSession('media-endpoint') 13 | 14 | const loadImage = e => { 15 | const [ file ] = e.target.files 16 | if (file) { 17 | if (file.size > 2621440) { 18 | preview = image = null 19 | return Alert.error('max file size is 2MB') 20 | } 21 | image = file 22 | preview = URL.createObjectURL(file) 23 | uploaded = null 24 | } 25 | } 26 | 27 | const uploadImage = async () => { 28 | if (!image.name) { 29 | // maybe not necessary? 30 | image.name = `${currentTime()}.${image.type.split('/').pop()}` 31 | } 32 | 33 | loading = true 34 | const formData = new FormData() 35 | formData.append('photo', image) 36 | const res = await Proxy.media({ 37 | method: 'POST', 38 | body: formData 39 | }) 40 | if (res && res.status === 201) { 41 | if (res.response && res.response.url) { 42 | uploaded = res.response.url 43 | let media = Store.getCache('media') || [] 44 | media.unshift({ url: uploaded }) 45 | Store.addToCache({ media }) 46 | } else { 47 | uploaded = null 48 | Alert.error('media-endpoint returned 201 but URL not found') 49 | } 50 | preview = null 51 | } else if (!res || res.status >= 400) { 52 | uploaded = null 53 | Alert.error(res) 54 | } 55 | loading = false 56 | } 57 | 58 | return { 59 | view: () => 60 | m('section.sp-content.text-center', [ 61 | m('.sp-box', [ 62 | m(BoxHeader, { 63 | icon: '.far.fa-image', 64 | name: 'Image' 65 | }), 66 | m('form.sp-box-content', 67 | mediaEndpoint ? [ 68 | m('input', { 69 | type: 'file', 70 | onchange: loadImage, 71 | accept: 'image/*' 72 | }), 73 | preview && m('div', [ 74 | m('img', { src: preview }), 75 | m('div', [ 76 | m('button', { 77 | type: 'button', 78 | onclick: uploadImage, 79 | disabled: loading 80 | }, loading ? m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) : [ 81 | 'upload', 82 | m('i.fas.fa-upload', { 'aria-hidden': 'true' }) 83 | ]) 84 | ]) 85 | ]), 86 | uploaded && m('div', [ 87 | m('div', 'Image uploaded successfully'), 88 | m('img', { src: uploaded }), 89 | m('div', m('a', { href: uploaded, target: '_blank' }, uploaded)), 90 | m('div', [ 91 | m(m.route.Link, { 92 | href: `/new/note?image=${uploaded}`, 93 | selector: 'button' 94 | }, 'post image') 95 | ]) 96 | ]), 97 | m('hr'), 98 | m(Gallery) 99 | ] 100 | : 101 | m('h5', 'media-endpoint not found') 102 | ) 103 | ]) 104 | ]) 105 | } 106 | } 107 | 108 | export default ImageEditor -------------------------------------------------------------------------------- /src/js/Pages/SettingsPage.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import { BoxHeader } from '../Components/Box' 4 | import { fetchMediaConfig, fetchMicropubConfig } from '../Controllers/Helpers' 5 | import Store from '../Models/Store' 6 | 7 | import { formatDate } from '../utils' 8 | 9 | const SettingsPage = () => { 10 | let micropubConfigFetched, 11 | syndicateTargets, 12 | mediaConfigFetched, 13 | mediaEndpoint, 14 | mediaConfig 15 | 16 | const loadFetchedValues = () => { 17 | micropubConfigFetched = Store.getCache('micropubConfigFetched') 18 | syndicateTargets = Store.getSession('syndicate-to') 19 | 20 | mediaConfig = Store.getSession('mediaConfig') 21 | mediaConfigFetched = Store.getCache('mediaConfigFetched') 22 | mediaEndpoint = Store.getSession('media-endpoint') 23 | } 24 | 25 | loadFetchedValues() 26 | 27 | let ui = Store.getSettings('ui') 28 | if (ui) { 29 | document.documentElement.setAttribute('data-ui', ui) 30 | } 31 | 32 | const loadMicropubConfig = async () => { 33 | await fetchMicropubConfig(true) 34 | loadFetchedValues() 35 | } 36 | 37 | const loadMediaConfig = async () => { 38 | await fetchMediaConfig(true) 39 | loadFetchedValues() 40 | } 41 | 42 | const updateUI = e => { 43 | ui = e && e.target && e.target.checked ? 'simple' : null 44 | if (ui) { 45 | document.documentElement.setAttribute('data-ui', 'simple') 46 | } else { 47 | document.documentElement.removeAttribute('data-ui') 48 | } 49 | Store.addToSettings({ ui }) 50 | } 51 | 52 | return { 53 | view: () => 54 | m('section.sp-content.text-center', [ 55 | m('.sp-box', [ 56 | m(BoxHeader, { 57 | icon: '.fas.fa-gear', 58 | name: 'Settings' 59 | }), 60 | m('.sp-box-content', [ 61 | m('h5', [ 62 | m('i.fas.fa-triangle-exclamation'), 63 | ' testing ', 64 | m('i.fas.fa-triangle-exclamation') 65 | ]), 66 | m('ul', [ 67 | m('li', [ 68 | m('span', `Config loaded: ${formatDate(micropubConfigFetched)}`), 69 | m('button', { onclick: loadMicropubConfig }, 'refresh') 70 | ]), 71 | !mediaEndpoint && m('li', 72 | m('div', [ 73 | m('code', 'media-endpoint'), 74 | ' not found' 75 | ]) 76 | ), 77 | mediaEndpoint && m('li', [ 78 | m('span', `Media Config: ${mediaConfigFetched > 0 ? formatDate(mediaConfigFetched) : 'Not fetched'}`), 79 | m('button', { onclick: loadMediaConfig }, 'refresh') 80 | ]), 81 | mediaConfig && mediaConfig.includes('source') && m('li', m('div', [ 82 | m('code', 'media-endpoint?q=source'), 83 | ' is available' 84 | ])), 85 | syndicateTargets && syndicateTargets.length && [ 86 | m('li', m('h5', 'Syndication Targets')), 87 | syndicateTargets.map(s => 88 | m('li', [ 89 | m('label', [ 90 | s.name 91 | ]) 92 | ])) 93 | ], 94 | m('hr'), 95 | m('li', m('h5', 'General Settings')), 96 | m('li', [ 97 | m('label', [ 98 | 'simple ui', 99 | m('input', { type: 'checkbox', onchange: updateUI, checked: ui === 'simple' }) 100 | ]) 101 | ]) 102 | ]) 103 | ]) 104 | ]) 105 | ]) 106 | } 107 | } 108 | 109 | export default SettingsPage -------------------------------------------------------------------------------- /public/assets/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/scss/_ui.scss: -------------------------------------------------------------------------------- 1 | 2 | .sp-title { 3 | text-align: center; 4 | font-size: 1.3em; 5 | padding: .5em; 6 | letter-spacing: .3em; 7 | } 8 | 9 | .sp-box { 10 | border: var(--sprk-border-size) solid var(--sprk-border-color); 11 | border-radius: .7em; 12 | background-color: var(--sprk-bg); 13 | filter: drop-shadow(var(--sprk-shadow-size) var(--sprk-shadow-size) 0 var(--window-shadow)); 14 | 15 | .sp-box-header { 16 | // position: relative; 17 | text-align: center; 18 | border-bottom: var(--sprk-border-size) solid var(--sprk-border-color); 19 | 20 | display: flex; 21 | align-items: center; 22 | justify-content: space-between; 23 | position: relative; 24 | 25 | padding: .3em; 26 | 27 | button { 28 | &:first-child { 29 | // Make this optional in case user wants to have close icon on left 30 | opacity: 0; 31 | pointer-events: none; 32 | } 33 | 34 | border: none; background: none; 35 | padding: .3em; 36 | 37 | &:hover { 38 | color: var(--sprk-fg); 39 | } 40 | } 41 | span { 42 | text-transform: uppercase; 43 | letter-spacing: .2em; 44 | } 45 | } 46 | .sp-box-content { 47 | padding: 1em; 48 | } 49 | } 50 | 51 | .sp-tiles { 52 | display: grid; 53 | grid-template-columns: repeat(auto-fit, minmax(75px, 1fr)); 54 | grid-gap: .75em; 55 | 56 | button { 57 | display: flex; 58 | align-items: center; 59 | justify-content: center; 60 | aspect-ratio: 1 / 1; 61 | letter-spacing: 1px; 62 | i { 63 | font-size: 1.3em; 64 | padding-bottom: .4em; 65 | } 66 | } 67 | } 68 | 69 | form { 70 | > * { 71 | margin-bottom: 1rem; 72 | &:last-child { 73 | margin-bottom: 0; 74 | } 75 | } 76 | } 77 | 78 | textarea, input, select { 79 | background-color: transparent; 80 | border: var(--input-border-size) solid var(--input-border); 81 | border-radius: .3rem; 82 | box-shadow: none; 83 | 84 | padding: .5rem 1rem; 85 | &:not([type="checkbox"]) { 86 | width: 100%; 87 | } 88 | 89 | @include transition(border .3s ease-in-out); 90 | 91 | &:focus { 92 | border-color: var(--input-border-active); 93 | outline: 0; 94 | } 95 | } 96 | 97 | input[type="checkbox"] { 98 | cursor: inherit; 99 | } 100 | 101 | textarea { 102 | resize: none; 103 | } 104 | 105 | button { 106 | background: none; 107 | border: var(--sprk-border-size) solid var(--sprk-border-color); 108 | padding: .5rem 1.5rem; 109 | 110 | text-decoration: none; 111 | border-radius: .4rem; 112 | 113 | @include transition(all .3s ease-in-out); 114 | 115 | &:hover { 116 | background: var(--sprk-border-color); 117 | color: var(--sprk-bg); 118 | } 119 | 120 | &:disabled { 121 | border-color: var(--input-border); 122 | background: var(--input-border); 123 | pointer-events: none; 124 | opacity: .7; 125 | } 126 | 127 | letter-spacing: .3em; 128 | 129 | i { 130 | letter-spacing: initial; 131 | } 132 | } 133 | 134 | a { 135 | cursor: pointer; 136 | i { 137 | color: var(--sprk-icon-link); 138 | } 139 | &[disabled] { 140 | pointer-events: none; 141 | i { 142 | color: var(--sprk-disabled); 143 | } 144 | } 145 | } 146 | 147 | .sp-gallery { 148 | width: 100%; 149 | max-height: 70vh; 150 | overflow-y: auto; 151 | 152 | display: grid; 153 | grid-template-columns: repeat(3, 1fr); 154 | grid-gap: .75em; 155 | 156 | img { 157 | object-fit: cover; 158 | width: 100%; 159 | max-height: 150px; 160 | aspect-ratio: 1 / 1; 161 | } 162 | } 163 | 164 | footer { 165 | a { 166 | padding: .5em; 167 | } 168 | } 169 | 170 | ul { 171 | margin: 0; 172 | padding: 0; 173 | li { 174 | display: flex; 175 | align-items: center; 176 | justify-content: space-between; 177 | margin-bottom: .5rem; 178 | } 179 | } 180 | 181 | img { 182 | max-width: 100%; 183 | max-height: 200px; 184 | } 185 | 186 | details { 187 | text-align: left; 188 | } 189 | summary { 190 | cursor: pointer; 191 | } 192 | 193 | h5 { 194 | margin: 0.5rem 0; 195 | } 196 | 197 | label { 198 | cursor: pointer; 199 | display: flex; 200 | align-content: center; 201 | justify-content: space-between; 202 | width: 100%; 203 | } 204 | 205 | .movie-list { 206 | display: grid; 207 | grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); 208 | grid-gap: 0.75em; 209 | padding: .75em 0; 210 | .movie-tile { 211 | 212 | } 213 | .movie { 214 | max-width: 150px; 215 | margin: 0 auto; 216 | padding: .4rem; 217 | h4 { 218 | margin: 0; 219 | } 220 | img { 221 | max-width: 100%; 222 | } 223 | cursor: pointer; 224 | border: 1px solid var(--sprk-bg-color); 225 | @include transition(border-color .3s ease-in-out); 226 | &:hover { 227 | border-color: var(--sprk-fg-color); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/js/Editors/MovieEditor.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Alert from '../Components/Alert' 4 | import { BoxHeader } from '../Components/Box' 5 | import Rating from '../Components/Rating' 6 | import Proxy from '../Controllers/Proxy' 7 | 8 | const OMDB_API_KEY = import.meta.env.VITE_OMDB_API_KEY 9 | 10 | const MovieEditor = () => { 11 | if (!OMDB_API_KEY) { 12 | return { 13 | view: () => 14 | m('p', [ 15 | 'missing environment variable: ', 16 | m('code', 'VITE_OMDB_API_KEY') 17 | ]) 18 | } 19 | } 20 | 21 | let state = {} 22 | 23 | const ratingToStars = () => state.rate ? '★'.repeat(state.rating) + (state.rating % 1 != 0 ? '½' : '') : '' 24 | 25 | const post = async (e) => { 26 | e.preventDefault() 27 | 28 | if (state.rate && !state.rating) { 29 | return Alert.error('missing "rating"') 30 | } 31 | 32 | const rating = ratingToStars() 33 | const properties = { 34 | summary: [ `${state.rewatched ? 'Rewatched' : 'Watched'} ${state.movie.Title}, (${state.movie.Year})${rating ? ' - ' + rating : ''}` ], 35 | 'u-watch-of': [ 36 | { 37 | 'type': [ 'h-cite' ], 38 | 'properties': { 39 | title: [ state.movie.Title ], 40 | year: [ state.movie.Year ], 41 | imdbID: [ state.movie.imdbID ], 42 | poster: [ state.movie.Poster ], 43 | rewatch: state.rewatched === true ? true : false, 44 | ...(state.rate && { rating: state.rating }) 45 | } 46 | } 47 | ] 48 | } 49 | 50 | state.submitting = true 51 | 52 | const res = await Proxy.micropub({ 53 | method: 'POST', 54 | body: { 55 | type: [ 'h-entry' ], 56 | properties: properties 57 | } 58 | }) 59 | 60 | state.submitting = false 61 | if (res && res.status === 201) { 62 | if (res.headers.location) { 63 | m.route.set('/success?url=' + res.headers.location) 64 | } else { 65 | Alert.error('location header missing') 66 | } 67 | } else if (!res || res.status >= 400) { 68 | Alert.error(res) 69 | } 70 | } 71 | 72 | let timeout, search = [] 73 | const submitMovieSearch = async (e) => { 74 | e && e.preventDefault() 75 | 76 | timeout && clearTimeout(timeout) 77 | 78 | if (!state.search || state.search.trim().length < 3) return 79 | 80 | state.searching = true 81 | state.searched = false 82 | 83 | const res = await m.request({ 84 | method: 'GET', 85 | url: `https://www.omdbapi.com/?apikey=${OMDB_API_KEY}&s=${state.search}&type=movie` 86 | }) 87 | 88 | if (res && res.Response === 'True') { 89 | search = res.Search 90 | } else { 91 | Alert.error(res && res.Error) 92 | search = null 93 | } 94 | 95 | state.searching = false 96 | state.searched = true 97 | } 98 | 99 | const movieInputChange = async (e) => { 100 | state.search = e.target.value 101 | timeout && clearTimeout(timeout) 102 | timeout = setTimeout(submitMovieSearch, 2000) 103 | } 104 | 105 | return { 106 | view: () => 107 | m('section.sp-content.text-center', [ 108 | m('.sp-box', [ 109 | m(BoxHeader, { 110 | icon: '.fas.fa-film', 111 | name: 'Movie' 112 | }), 113 | m('.sp-box-content.text-center', [ 114 | m('form', { 115 | onsubmit: submitMovieSearch 116 | }, [ 117 | m('input', { 118 | type: 'text', 119 | placeholder: 'Search', 120 | oninput: e => movieInputChange(e), 121 | value: state.search || '' 122 | }) 123 | ]), 124 | m('div.movie-list', [ 125 | state.searching && m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }), 126 | state.searched && search && search.length > 0 && 127 | search.map(mv => 128 | m('div.movie-tile', { 129 | onclick: () => state.movie = state.movie ? null : mv, 130 | hidden: state.movie && state.movie.imdbID != mv.imdbID 131 | }, m('div.movie' + (state.movie && state.movie.imdbID == mv.imdbID ? '.selected' : ''), [ 132 | m('h4', `${mv.Title} (${mv.Year})`), 133 | m('img', { src: mv.Poster }) 134 | ]))), 135 | state.searched && (!search || search.length === 0) && m('div', 'No results found') 136 | ]), 137 | state.movie && m('form', { 138 | onsubmit: post 139 | }, [ 140 | m('label', [ 141 | 'Rewatched', 142 | m('input', { 143 | type: 'checkbox', 144 | onchange: e => state.rewatched = e && e.target && e.target.checked, 145 | checked: state.rewatched 146 | }) 147 | ]), 148 | m('label', [ 149 | 'Rate', 150 | m('input', { 151 | type: 'checkbox', 152 | onchange: e => state.rate = e && e.target && e.target.checked, 153 | checked: state.rate 154 | }) 155 | ]), 156 | state.rate && m(Rating, { 157 | onchange: val => state.rating = val, 158 | value: state.rating 159 | }), 160 | m('button', { 161 | type: 'submit', 162 | disabled: state.submitting 163 | }, state.submitting ? m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) : 'Post') 164 | ]) 165 | ]) 166 | ]) 167 | ]) 168 | } 169 | } 170 | 171 | export default MovieEditor 172 | -------------------------------------------------------------------------------- /src/js/Editors/index.js: -------------------------------------------------------------------------------- 1 | import m from 'mithril' 2 | 3 | import Alert from '../Components/Alert' 4 | import { BoxHeader } from '../Components/Box' 5 | import Proxy from '../Controllers/Proxy' 6 | import Store from '../Models/Store' 7 | 8 | const EditorTypes = { 9 | Note: { 10 | title: 'Note', 11 | icon: '.far.fa-note-sticky', 12 | components: [ 13 | // SAMPLE 14 | // { type: 'content', label: 'change label', required: true } 15 | { type: 'content', required: true }, 16 | { type: 'category' } 17 | ] 18 | }, 19 | Article: { 20 | title: 'Article', 21 | icon: '.fas.fa-newspaper', 22 | components: [ 23 | { type: 'name', required: true }, 24 | { type: 'content', required: true }, 25 | { type: 'category' } 26 | ] 27 | }, 28 | Bookmark: { 29 | title: 'Bookmark', 30 | icon: '.far.fa-bookmark', 31 | components: [ 32 | { type: 'bookmark-of', required: true }, 33 | { type: 'name', required: true }, 34 | { type: 'content' }, 35 | { type: 'category' } 36 | ] 37 | }, 38 | Reply: { 39 | title: 'Reply', 40 | icon: '.fas.fa-reply', 41 | components: [ 42 | { type: 'in-reply-to', required: true }, 43 | { type: 'content', required: true }, 44 | { type: 'category' } 45 | ] 46 | }, 47 | RSVP: { 48 | title: 'RSVP', 49 | icon: '.far.fa-calendar-check', 50 | components: [ 51 | { type: 'in-reply-to', label: 'RSVP to', required: true }, 52 | { type: 'rsvp', required: true }, 53 | { type: 'content' }, 54 | { type: 'category' } 55 | ] 56 | }, 57 | Like: { 58 | title: 'Like', 59 | icon: '.fas.fa-heart', 60 | components: [ 61 | { type: 'like-of', required: true }, 62 | { type: 'category' } 63 | ] 64 | } 65 | } 66 | 67 | const Editor = ({ attrs }) => { 68 | const parameterList = new URLSearchParams(window.location.search) 69 | const params = { 70 | title: parameterList.get('title'), 71 | text: parameterList.get('text'), 72 | url: parameterList.get('url'), 73 | image: parameterList.get('image') 74 | } 75 | 76 | const syndicateTo = Store.getSession('syndicate-to') || [] 77 | 78 | let state = {} 79 | // Init state 80 | for (const c of attrs.components) { 81 | if (c.type === 'name') { 82 | state[c.type] = params.title || '' 83 | } else if (['bookmark-of', 'in-reply-to', 'like-of'].includes(c.type)) { 84 | state[c.type] = params.url || '' 85 | } 86 | } 87 | if (params.image) { 88 | state.content = `![](${params.image.replace(' ', '%20')})` 89 | } 90 | 91 | const updateSyndicateTo = (e, syndicateTarget) => { 92 | if (e && e.target && e.target.checked) { 93 | state['mp-syndicate-to'] = [...(state['mp-syndicate-to'] || []), syndicateTarget.uid] 94 | } else { 95 | state['mp-syndicate-to'] = (state['mp-syndicate-to'] || []).filter(e => e != syndicateTarget.uid) 96 | } 97 | } 98 | 99 | const post = async (e) => { 100 | e.preventDefault() 101 | 102 | let properties = {} 103 | for (const [key, value] of Object.entries(state)) { 104 | if (key != 'category' && value && value.length) { 105 | properties[key] = Array.isArray(value) ? value : [ value ] 106 | } 107 | } 108 | if (state.category) { 109 | // Split by comma, trim whitespace and get rid of empty items from array 110 | const categories = state.category.split(',').map(c => c.trim()).filter(c => c) 111 | if (categories && categories.length > 0) { 112 | properties.category = categories 113 | } 114 | } 115 | 116 | // Just in case? 117 | for (const c of attrs.components) { 118 | if (c.required && !properties[c.type]) { 119 | return Alert.error(`missing "${c.type}"`) 120 | } 121 | } 122 | 123 | state.submitting = true 124 | 125 | const res = await Proxy.micropub({ 126 | method: 'POST', 127 | body: { 128 | type: [ 'h-entry' ], 129 | properties: properties 130 | } 131 | }) 132 | 133 | state.submitting = false 134 | if (res && res.status === 201) { 135 | if (res.headers.location) { 136 | m.route.set('/success?url=' + res.headers.location) 137 | } else { 138 | Alert.error('location header missing') 139 | } 140 | } else if (!res || res.status >= 400) { 141 | Alert.error(res) 142 | } 143 | } 144 | 145 | return { 146 | view: () => 147 | m('section.sp-content.text-center', [ 148 | m('.sp-box', [ 149 | m(BoxHeader, { 150 | icon: attrs.icon, //'.far.fa-note-sticky', 151 | name: attrs.title //'Note' 152 | }), 153 | m('form.sp-box-content.text-center', { 154 | onsubmit: post 155 | }, [ 156 | attrs.components && attrs.components.map(c => { 157 | switch(c.type) { 158 | case 'name': 159 | return m('input', { 160 | type: 'text', 161 | placeholder: c.label || 'Title', 162 | oninput: e => state[c.type] = e.target.value, 163 | value: state[c.type] || '', 164 | required: c.required 165 | }) 166 | case 'content': 167 | return m('textarea', { 168 | rows: 5, 169 | placeholder: c.label || 'Content goes here...', 170 | oninput: e => state[c.type] = e.target.value, 171 | value: state[c.type] || '', 172 | required: c.required 173 | }) 174 | case 'bookmark-of': 175 | return m('input', { 176 | type: 'url', 177 | placeholder: c.label || 'Bookmark of', 178 | oninput: e => state[c.type] = e.target.value, 179 | value: state[c.type] || '', 180 | required: c.required 181 | }) 182 | case 'in-reply-to': 183 | return m('input', { 184 | type: 'url', 185 | placeholder: c.label || 'Reply to', 186 | oninput: e => state[c.type] = e.target.value, 187 | value: state[c.type] || '', 188 | required: c.required 189 | }) 190 | case 'like-of': 191 | return m('input', { 192 | type: 'url', 193 | placeholder: c.label || 'Like of', 194 | oninput: e => state[c.type] = e.target.value, 195 | value: state[c.type] || '', 196 | required: c.required 197 | }) 198 | case 'rsvp': 199 | return m('select', { 200 | oninput: e => state[c.type] = e.target.value, 201 | value: state[c.type] = state[c.type] || 'yes', 202 | required: c.required 203 | }, [ 204 | ['yes', 'no', 'maybe', 'interested'] 205 | .map(o => m('option', { value: o }, o)) 206 | ]) 207 | case 'category': 208 | return m('input', { 209 | type: 'text', 210 | placeholder: 'Tags', 211 | oninput: e => state[c.type] = e.target.value, 212 | value: state[c.type] || '', 213 | required: c.required 214 | }) 215 | } 216 | }), 217 | syndicateTo && syndicateTo.length && m('details', 218 | m('summary', 'Advanced'), 219 | m('h5', 'Syndication Targets'), 220 | m('ul', [ 221 | syndicateTo.map(s => 222 | m('li', [ 223 | m('label', [ 224 | s.name, 225 | m('input', { type: 'checkbox', onchange: e => updateSyndicateTo(e, s) }) 226 | ]) 227 | ])) 228 | ]) 229 | ), 230 | m('button', { 231 | type: 'submit', 232 | disabled: state.submitting 233 | }, state.submitting ? m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) : 'Post') 234 | ]) 235 | ]) 236 | ]) 237 | } 238 | } 239 | 240 | const NoteEditor = { view: () => m(Editor, EditorTypes.Note) } 241 | const ArticleEditor = { view: () => m(Editor, EditorTypes.Article) } 242 | const BookmarkEditor = { view: () => m(Editor, EditorTypes.Bookmark) } 243 | const ReplyEditor = { view: () => m(Editor, EditorTypes.Reply) } 244 | const RSVPEditor = { view: () => m(Editor, EditorTypes.RSVP) } 245 | const LikeEditor = { view: () => m(Editor, EditorTypes.Like) } 246 | 247 | export { 248 | NoteEditor, 249 | ArticleEditor, 250 | BookmarkEditor, 251 | ReplyEditor, 252 | RSVPEditor, 253 | LikeEditor 254 | } 255 | --------------------------------------------------------------------------------