├── .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 |
--------------------------------------------------------------------------------
/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 |
5 |
6 |
7 |
12 |
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 |
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 = `})`
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 |
--------------------------------------------------------------------------------