diff --git a/.env.example b/.env.example index f922b29..7a26eac 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ -VITE_OMDB_API_KEY="1234abcd" \ No newline at end of file +OMDB_API_KEY="1234abcd" +LASTFM_API_KEY="abcd1234" +GIANTBOMB_API_KEY="wxyz7890" +# Only use in development +VITE_MICROPUB_ENDPOINT="http://localhost:1234/micropub" \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index d52dd9a..d74e4f6 100644 --- a/netlify.toml +++ b/netlify.toml @@ -2,7 +2,9 @@ command = "npm run start:vite" publish = "dist" targetPort = 5173 + functionsPort = 5174 port = 9000 + framework = "#custom" [build] command = "npm run build:vite" @@ -13,9 +15,15 @@ node_bundler = "esbuild" [[redirects]] - from = "/*" + from = "/api/*" to = "/.netlify/functions/:splat" status = 200 +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + [template.environment] OMDB_API_KEY = "OMDB API Key" + LASTFM_API_KEY = "last.fm API key" diff --git a/package-lock.json b/package-lock.json index d7070eb..379e9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparkles", - "version": "0.6.2", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparkles", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "dependencies": { "cheerio": "^1.0.0-rc.12", @@ -15,7 +15,7 @@ "node-fetch": "^3.3.2" }, "devDependencies": { - "eslint": "^8.54.0", + "eslint": "^8.55.0", "sass": "^1.69.5", "vite": "^4.5.0" }, @@ -410,9 +410,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", @@ -433,9 +433,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz", - "integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz", + "integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -961,15 +961,15 @@ } }, "node_modules/eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz", - "integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", + "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.54.0", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.55.0", "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -1331,9 +1331,9 @@ } }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", "dev": true, "engines": { "node": ">= 4" diff --git a/package.json b/package.json index 3ab2c45..fa40222 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparkles", - "version": "0.6.2", + "version": "0.7.0", "description": "micropub client", "main": "./src/js/app.js", "scripts": { @@ -28,7 +28,7 @@ "url": "https://github.com/benjifs/sparkles/issues" }, "devDependencies": { - "eslint": "^8.54.0", + "eslint": "^8.55.0", "sass": "^1.69.5", "vite": "^4.5.0" }, diff --git a/public/_redirects b/public/_redirects deleted file mode 100644 index 50a4633..0000000 --- a/public/_redirects +++ /dev/null @@ -1 +0,0 @@ -/* /index.html 200 \ No newline at end of file diff --git a/src/functions/micropub.js b/src/functions/micropub.js index 570cf9e..bfa084f 100644 --- a/src/functions/micropub.js +++ b/src/functions/micropub.js @@ -17,6 +17,7 @@ exports.handler = async e => { // return Response.error(Error.INVALID, 'Could not parse request body') } + console.log(`⇒ ${e.httpMethod}: ${endpoint}`, body) const res = await fetch(endpoint + (Array.from(params).length > 0 ? '?' + params : ''), { method: e.httpMethod, ...(body && { body: JSON.stringify(body) }), @@ -27,7 +28,8 @@ exports.handler = async e => { } }) - console.log(`⇒ [${res.status}]`, res.headers) + const response = await res.text() + console.log(` [${res.status}]`, res.headers, response) const location = res.headers.get('location') const contentType = res.headers.get('content-type') @@ -38,6 +40,6 @@ exports.handler = async e => { 'Content-Type': contentType, ...(location && { 'Location': location }) }, - body: await res.text() + body: response } } diff --git a/src/functions/search.js b/src/functions/search.js new file mode 100644 index 0000000..2014c17 --- /dev/null +++ b/src/functions/search.js @@ -0,0 +1,114 @@ +import fetch from 'node-fetch' + +import { Error, Response } from './lib/utils' + +const types = { + movie: { + url: 'https://www.omdbapi.com', + buildParams: ({ query, year, page }) => ({ + apikey: process.env.OMDB_API_KEY, + type: 'movie', + s: query, + year: year, + page: page + }), + buildError: ({ status, response }) => Response.error({ statusCode: status }, response.Error), + parseResponse: res => ({ + totalResults: res?.totalResults || 0, + results: res?.Search?.map(m => ({ + id: `imdb:${m.imdbID}`, + title: m.Title, + image: m.Poster, + year: m.Year, + url: `https://imdb.com/title/${m.imdbID}` + })) + }) + }, + book: { + url: 'https://openlibrary.org/search.json', + buildParams: ({ query, page }) => ({ + limit: 10, + q: query, + page: page + }), + buildError: ({ status, response }) => Response.error({ statusCode: status }, response.error), + parseResponse: res => ({ + totalResults: res?.num_found || 0, + results: res?.docs.map(b => ({ + id: `olid:${b.key.replace('/works/', '')}`, + title: b.title, + author: b.author_name ? b.author_name.join(', ') : '', + image: `https://covers.openlibrary.org/b/id/${b.cover_i}-M.jpg`, + year: b.first_publish_year, + url: `https://openlibrary.org${b.key}` + })) + }) + }, + music: { + url: 'https://ws.audioscrobbler.com/2.0/', + buildParams: ({ type, query, page }) => ({ + api_key: process.env.LASTFM_API_KEY, + limit: 10, + format: 'json', + method: `${type}.search`, + [type]: query, + page: page + }), + buildError: ({ status, response }) => Response.error({ + statusCode: status, + error: response.error + }, response.message), + parseResponse: (res, category) => ({ + totalResults: parseInt(res?.results['opensearch:totalResults'] || 0), + results: res.results[`${category}matches`][category] + .map(r => ({ + ...(r.mbid && { id: `mbid:${r.mbid}` }), + title: r.name, + author: r.artist, + ...(category == 'album' && { image: r.image?.pop()['#text'] }), + url: r.url + })) + }) + }, + game: { + url: 'https://www.giantbomb.com/api/search/', + buildParams: ({ query, page }) => ({ + api_key: process.env.GIANTBOMB_API_KEY, + limit: 10, + format: 'json', + resources: 'game', + query: query, + page: page + }), + buildError: ({ status, response }) => Response.error({ statusCode: status }, response.error), + parseResponse: res => ({ + totalResults: res?.number_of_total_results || 0, + results: res?.results.map(g => ({ + id: `gbid:${g.guid}`, + title: g.name, + image: g.image.original_url, + year: g.original_release_date ? new Date(g.original_release_date).getFullYear() : null, + url: g.site_detail_url + })) + }) + }, +} + +export const handler = async (e) => { + const { type } = e.queryStringParameters + if (!['movie', 'book', 'artist', 'album', 'track', 'game'].includes(type)) { + return Response.error(Error.NOT_SUPPORTED, 'Search type not supported') + } + + const opts = types[['artist', 'album', 'track'].includes(type) ? 'music' : type] + const params = new URLSearchParams(opts.buildParams(e.queryStringParameters)) + const res = await fetch(`${opts.url}?${params.toString()}`) + const response = await res.json() + if (res.status !== 200) { + return opts.buildError({ status: res.status, response }) + } else if (type == 'game' && response.error != 'OK') { + return opts.buildError({ status: 400, response}) + } + + return Response.success(opts.parseResponse(response, type)) +} diff --git a/src/js/Controllers/Proxy.js b/src/js/Controllers/Proxy.js index 2aae302..9397153 100644 --- a/src/js/Controllers/Proxy.js +++ b/src/js/Controllers/Proxy.js @@ -2,7 +2,6 @@ import m from 'mithril' import Store from '../Models/Store' -const FUNCTIONS = window.location.host === 'localhost:5173' ? 'http://localhost:9000' : '' const CLIENT = window.location.origin const Proxy = { @@ -10,7 +9,7 @@ const Proxy = { m .request({ method: 'GET', - url: `${FUNCTIONS}/.netlify/functions/discover`, + url: '/.netlify/functions/discover', params: { url: url } }), validate: params => { @@ -22,7 +21,7 @@ const Proxy = { return m .request({ method: 'GET', - url: `${FUNCTIONS}/.netlify/functions/token`, + url: '/.netlify/functions/token', params: { 'token_endpoint': session.token_endpoint, 'code': code, @@ -41,7 +40,7 @@ const Proxy = { return m .request({ method: method || 'GET', - url: `${FUNCTIONS}/.netlify/functions/micropub`, + url: '/.netlify/functions/micropub', headers: { // 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, @@ -58,7 +57,7 @@ const Proxy = { return m .request({ method: method || 'GET', - url: `${FUNCTIONS}/.netlify/functions/media`, + url: '/.netlify/functions/media', headers: { 'Authorization': `Bearer ${session.access_token}`, 'x-media-endpoint': session['media-endpoint'] @@ -87,13 +86,12 @@ const Proxy = { try { await m.request({ method: 'GET', - url: `${FUNCTIONS}/.netlify/functions/redirect?url=${url}` + url: `/.netlify/functions/redirect?url=${url}` }) return true } catch (err) { console.error(`could not fetch ${url}`) } - return false } } diff --git a/src/js/Editors/BookEditor.js b/src/js/Editors/BookEditor.js deleted file mode 100644 index 370f227..0000000 --- a/src/js/Editors/BookEditor.js +++ /dev/null @@ -1,216 +0,0 @@ -import m from 'mithril' - -import Alert from '../Components/Alert' -import { Box } from '../Components/Box' -import { Modal } from '../Components/Modal' -import Rating from '../Components/Rating' -import Proxy from '../Controllers/Proxy' -import Store from '../Models/Store' -import { dateInRFC3339, ratingToStars } from '../utils' - -const OPENLIBRARY_URL = 'https://openlibrary.org' -const PROGRESS_OPTIONS = [ - { key: 'want', label: 'Want to Read' }, - { key: 'started', label: 'Reading' }, - { key: 'finished', label: 'Read' } -] -const DEFAULT_PROGRESS = 'finished' - -const getStatusForProgress = key => PROGRESS_OPTIONS.find(p => p.key == key).label - -const getOpenLibraryImage = id => id ? `https://covers.openlibrary.org/b/id/${id}-M.jpg` : '' - -const BookEditor = () => { - const postTypes = Store.getSession('post-types') || [] - let state = { - progress: DEFAULT_PROGRESS - } - - const buildEntry = () => { - const rating = ratingToStars() - const status = getStatusForProgress(state.progress) - if (!status) return Alert.error('invalid "status" selected') - const shouldRate = state.progress == DEFAULT_PROGRESS - - const authors = state.book.author_name.join(', ') - const summary = `${status}: ${state.book.title} by ${authors}${shouldRate && rating ? ' - ' + rating : ''}` - - const image = getOpenLibraryImage(state.book.cover_i) - - const properties = { - summary: [ summary ], - featured: [ image ], - ...(shouldRate && { published: [ state.published || dateInRFC3339() ] }), - ...(shouldRate && state.content && { content: [ state.content ] }), - 'read-of': [ - { - 'type': [ 'h-cite' ], - 'properties': { - name: [ state.book.title ], - author: [ authors ], - photo: [ image ], - uid: [ `olid:${state.book.key.replace('/works/', '')}` ], - url: [ `${OPENLIBRARY_URL}${state.book.key}` ], - published: [ state.book.first_publish_year ] - } - } - ], - // custom properties - progress: [ state.progress ], - ...(shouldRate && state.rating > 0 && { rating: [ state.rating ] }), - } - - return { - type: [ 'h-entry' ], - properties: properties - } - } - - const post = async (e) => { - e.preventDefault() - - const entry = buildEntry() - if (!entry) return - state.submitting = true - const res = await Proxy.micropub({ - method: 'POST', - body: entry - }) - - state.submitting = false - if (res && res.status === 201) { - if (res.headers.location) { - m.route.set('/success?url=' + res.headers.location) - } else { - Alert.error('location header missing') - } - } else if (!res || res.status >= 400) { - Alert.error(res) - } - } - - let timeout, search = [] - const submitSearch = async (e, page) => { - e && e.preventDefault() - - timeout && clearTimeout(timeout) - - if (!state.search || state.search.trim().length < 3) return - - state.searching = true - state.searched = false - state.book = null - state.page = page || 1 - - const res = await m.request({ - method: 'GET', - url: `${OPENLIBRARY_URL}/search.json`, - params: { - q: state.search, - limit: 10, - page: state.page - } - }) - - if (res && res.num_found > 0) { - search = res.docs - state.totalResults = res.num_found - } else { - Alert.error(res && res.Error) - search = null - } - - state.searching = false - state.searched = true - } - - const inputChange = async (e) => { - state.search = e.target.value - timeout && clearTimeout(timeout) - timeout = setTimeout(submitSearch, 2000) - } - - const postType = postTypes.find(item => item.type == 'read') - - return { - view: () => - m(Box, { - icon: '.fas.fa-book', - title: postType?.name || 'Book' - }, [ - m('form', { - onsubmit: submitSearch - }, [ - m('input', { - type: 'text', - placeholder: 'Search', - oninput: e => inputChange(e), - value: state.search || '' - }) - ]), - m('div.item-list.text-center', [ - state.searching && m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }), - state.searched && search && search.length > 0 && - search.map(b => - m('div.item-tile', { - onclick: () => state.book = state.book ? null : b, - hidden: state.book && state.book.key != b.key - }, m('div.item' + (state.book && state.book.key == b.key ? '.selected' : ''), [ - m('img', { src: getOpenLibraryImage(b.cover_i) }), - m('div', [ - m('h4', b.title), - m('h5', b.author_name ? b.author_name.join(', ') : '') - ]) - ]))), - state.searched && (!search || search.length === 0) && m('div', 'No results found'), - !state.book && state.totalResults > search.length && m('div.item-pagination', [ - m('button', { disabled: state.page == 1, onclick: e => submitSearch(e, --state.page) }, 'prev'), - m('button', { disabled: state.totalResults < state.page * 10, onclick: e => submitSearch(e, ++state.page) }, 'next') - ]) - ]), - state.book && m('form', { - onsubmit: post - }, [ - m('label', [ - 'Status', - m('select', { - oninput: e => state.progress = e.target.value, - value: state.progress = state.progress || DEFAULT_PROGRESS - }, PROGRESS_OPTIONS.map(({key, label}) => m('option', { value: key }, label))) - ]), - state.progress == DEFAULT_PROGRESS && [ - m('label', [ - 'Read on:', - m('input', { - type: 'date', - onchange: e => state.published = e.target.value, - value: state.published || dateInRFC3339() - }) - ]), - m('div.label', [ - 'Rate', - m(Rating, { - onchange: val => state.rating = val, - value: state.rating - }) - ]), - m('textarea', { - rows: 3, - placeholder: 'Add your review...', - oninput: e => state.content = e.target.value, - value: state.content || '' - }) - ], - m('div.text-center', m('button', { - type: 'submit', - disabled: state.submitting - }, state.submitting ? m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) : 'Post')), - m('div.text-center', m('a', { - onclick: () => Modal(m('pre', JSON.stringify(buildEntry(), null, 4))) - }, 'preview')) - ]) - ]) - } -} - -export default BookEditor diff --git a/src/js/Editors/MediaEditor.js b/src/js/Editors/MediaEditor.js new file mode 100644 index 0000000..ba561e2 --- /dev/null +++ b/src/js/Editors/MediaEditor.js @@ -0,0 +1,327 @@ +import m from 'mithril' + +import Alert from '../Components/Alert' +import { Box } from '../Components/Box' +import { Modal } from '../Components/Modal' +import Rating from '../Components/Rating' +import Proxy from '../Controllers/Proxy' +import Store from '../Models/Store' +import { dateInRFC3339, ratingToStars } from '../utils' + +const getStatusForProgress = (options, key) => { + const progress = options.find(p => p.key == key) + return progress.title || progress.label +} + +const parseQuery = string => { + const params = /year:([0-9]{4})/g.exec(string.replaceAll(' ', '')) + if (params && params.length > 1) { + return { + query: string.slice(0, string.indexOf('year:')).trim(), + year: params[1] + } + } + return { + query: string.trim() + } +} + +const MediaEditor = ({ attrs }) => { + const postTypes = Store.getSession('post-types') || [] + + let state = { + type: attrs.search?.options?.[0] || null, + progress: 'finished' + } + + const buildEntry = () => { + const rating = ratingToStars(state.rating) + const rewatched = state.progress == 'finished' && attrs?.type == 'watch' && state.rewatched + + let summary = rewatched ? 'Rewatched' : getStatusForProgress(attrs?.progress, state.progress) + if (!summary) return Alert.error('invalid "status" selected') + + summary += ` ${state.selected.title}` + if (attrs?.type == 'watch' && state.selected.year) { + summary += `, ${state.selected.year}` + } else if (state.selected.author) { + summary += ` by ${state.selected.author}` + } + if (state.progress == 'finished' && rating) { + summary += ` - ${rating}` + } + const image = state.selected.image || state.image + + return { + type: [ 'h-entry' ], + properties: { + summary: [ summary ], + ...(image && { featured: [ image ] }), + published: [ state.published || dateInRFC3339() ], + [`${attrs.type}-of`]: [ + { + 'type': [ 'h-cite' ], + 'properties': { + name: [ state.selected.title ], + ...(image && { photo: [ image ] }), + ...(state.selected.author && { author: [ state.selected.author ]}), + ...(state.selected.id && { uid: [ state.selected.id ] }), + ...(state.selected.url && { url: [ state.selected.url ] }), + ...(state.selected.year && { published: [ state.selected.year ] }), + } + } + ], + // custom properties + ...(state.progress && { progress: [ state.progress ] }), + ...(state.progress == 'finished' && { + ...(state.content && { content: [ state.content ] }), + ...(rating && state.rating > 0 && { rating: [ state.rating ] }), + ...(rewatched && { rewatch: [ state.rewatched === true ] }) + }) + } + } + } + + const post = async (e) => { + e.preventDefault() + + const entry = buildEntry() + state.submitting = true + const res = await Proxy.micropub({ + method: 'POST', + body: entry + }) + + state.submitting = false + if (res && [201, 202].includes(res.status)) { + if (res.headers.location) { + m.route.set('/success?url=' + res.headers.location) + } else { + Alert.error('location header missing') + } + } else if (!res || res.status >= 400) { + Alert.error(res) + } else { + console.error(res.status, res) + } + } + + let timeout, search = [] + const submitSearch = async (e, page) => { + e && e.preventDefault() + + timeout && clearTimeout(timeout) + + if (!state.search) return + const params = parseQuery(state.search) + if (!params || params.query.trim().length < 3) return + + state.searching = true + state.searched = false + state.selected = null + state.page = page || 1 + + try { + const res = await m.request({ + method: 'GET', + url: '/api/search', + params: { + type: state.type, + page: state.page, + ...params + } + }) + search = res?.results || [] + state.totalResults = res?.totalResults || 0 + } catch ({ response }) { + Alert.error(response && response.error_description) + search = null + } + + state.searching = false + state.searched = true + } + + const inputChange = async (e) => { + state.search = e.target.value + timeout && clearTimeout(timeout) + timeout = setTimeout(submitSearch, 2000) + } + + const postType = postTypes.find(item => item.type == attrs?.type) + + return { + view: () => + m(Box, { + icon: attrs.icon || '', + title: postType?.name || attrs.title || 'Media' + }, [ + m('form.input-group', { + onsubmit: submitSearch + }, [ + ...(attrs.search?.options && attrs.search.options.length > 1 && [ + m('select', { + oninput: e => state.type = e.target.value, + value: state.type + }, + attrs.search.options.map(o => m('option', { value: o }, o))) + ] || []), + m('input', { + type: 'text', + placeholder: attrs.search?.placeholder || 'Search...', + oninput: e => inputChange(e), + value: state.search || '' + }) + ]), + (state.searching || state.searched) && m('div.item-list.text-center', [ + state.searching && m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }), + state.searched && search && search.length > 0 && + search.map(md => + m('div.item-tile', { + onclick: () => state.selected = state.selected ? null : md, + hidden: state.selected && state.selected.url != md.url + }, m('div.item' + (state.selected && state.selected.url == md.url ? '.selected' : ''), [ + (md.image || state.image) && m('img', { src: md.image || state.image }), + m('div', [ + m('h4', md.title), + md.author && m('h5', md.author), + !md.author && md.year && m('h5', md.year) + ]) + ]))), + state.searched && (!search || search.length === 0) && m('div', 'No results found'), + !state.selected && search && state.totalResults > search.length && m('div.item-pagination', [ + m('button', { disabled: state.page == 1, onclick: e => submitSearch(e, --state.page) }, 'prev'), + m('button', { disabled: state.totalResults < state.page * 10, onclick: e => submitSearch(e, ++state.page) }, 'next') + ]) + ]), + state.selected && m('form', { + onsubmit: post + }, [ + !state.selected.image && m('label', [ + 'Thumbnail', + m('input', { + type: 'url', + placeholder: 'https://', + onchange: e => state.image = e.target.value, + value: state.image || '' + }) + ]), + attrs.progress?.length > 1 && m('label', [ + 'Status', + m('select', { + oninput: e => state.progress = e.target.value, + value: state.progress = state.progress + }, attrs.progress?.map(({key, label}) => m('option', { value: key }, label))) + ]), + state.progress == 'finished' && [ + m('label', [ + `${attrs.progress?.find(p => p.key == 'finished').label || ''} on`, + m('input', { + type: 'date', + onchange: e => state.published = e.target.value, + value: state.published || dateInRFC3339() + }) + ]), + attrs.type == 'watch' && m('label', [ + 'Rewatched', + m('input', { + type: 'checkbox', + onchange: e => state.rewatched = e && e.target && e.target.checked, + checked: state.rewatched + }) + ]), + m('div.label', [ + 'Rate', + m(Rating, { + onchange: val => state.rating = val, + value: state.rating + }) + ]), + m('textarea', { + rows: 3, + placeholder: 'Add your review...', + oninput: e => state.content = e.target.value, + value: state.content || '' + }), + ], + m('div.text-center', m('button', { + type: 'submit', + disabled: state.submitting + }, state.submitting ? m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) : 'Post')), + m('div.text-center', m('a', { + onclick: () => Modal(m('pre', JSON.stringify(buildEntry(), null, 4))) + }, 'preview')) + ]) + ]) + } +} + +const EditorTypes = { + Movie: { + title: 'Movie', + icon: '.fas.fa-film', + type: 'watch', // `post-type` + search: { + options: [ 'movie' ], // At least one option required. Must match valid search `type` + placeholder: 'title year:2023' // Optional + }, + progress: [ // experimental property + { + key: 'want', // want, started, finished + label: 'Want to Watch', // Label for dropdown + title: 'Wants to Watch' // Text used in summary. Defaults to label + }, + { key: 'finished', label: 'Watched' } + ] + }, + Book: { + title: 'Book', + icon: '.fas.fa-book', + type: 'read', + search: { + options: [ 'book' ], + placeholder: 'Search by title or ISBN' + }, + progress: [ + { key: 'want', label: 'Want to Read', title: 'Wants to Read' }, + { key: 'started', label: 'Reading', title: 'Reading' }, + { key: 'finished', label: 'Read', title: 'Finished Reading' } + ] + }, + Listen: { + title: 'Listen', + icon: '.fas.fa-music', + type: 'listen', + search: { + options: [ 'artist', 'album', 'track' ] + }, + progress: [ + { key: 'finished', label: 'Listened', title: 'Listened to' } + ] + }, + Game: { + title: 'Game', + icon: '.fas.fa-gamepad', + type: 'play', + search: { + options: [ 'game' ] + }, + progress: [ + { key: 'want', label: 'Want to Play', title: 'Wants to Play' }, + { key: 'started', label: 'Playing', title: 'Playing' }, + { key: 'finished', label: 'Played', title: 'Played' } + ] + } +} + +const MovieEditor = { view: () => m(MediaEditor, EditorTypes.Movie) } +const BookEditor = { view: () => m(MediaEditor, EditorTypes.Book) } +const ListenEditor = { view: () => m(MediaEditor, EditorTypes.Listen) } +const GameEditor = { view: () => m(MediaEditor, EditorTypes.Game) } + +export { + MovieEditor, + BookEditor, + ListenEditor, + GameEditor, +} diff --git a/src/js/Editors/MovieEditor.js b/src/js/Editors/MovieEditor.js deleted file mode 100644 index 4d388cb..0000000 --- a/src/js/Editors/MovieEditor.js +++ /dev/null @@ -1,221 +0,0 @@ -import m from 'mithril' - -import Alert from '../Components/Alert' -import { Box } from '../Components/Box' -import { Modal } from '../Components/Modal' -import Rating from '../Components/Rating' -import Proxy from '../Controllers/Proxy' -import Store from '../Models/Store' -import { dateInRFC3339, ratingToStars } from '../utils' - -const OMDB_API_KEY = import.meta.env.VITE_OMDB_API_KEY -const IMDB_URL = 'https://imdb.com/title/' - -const parseQuery = string => { - const params = /year:([0-9]{4})/g.exec(string.replaceAll(' ', '')) - if (params && params.length > 1) { - return { - search: string.slice(0, string.indexOf('year:')).trim(), - year: params[1] - } - } - return { - search: string.trim() - } -} - -const MovieEditor = () => { - if (!OMDB_API_KEY) { - return { - view: () => - m('p', [ - 'missing environment variable: ', - m('code', 'VITE_OMDB_API_KEY') - ]) - } - } - const postTypes = Store.getSession('post-types') || [] - - let state = {} - - const buildEntry = () => { - const rating = ratingToStars(state.rating) - const summary = `${state.rewatched ? 'Rewatched' : 'Watched'} ${state.movie.Title}, (${state.movie.Year})${rating ? ' - ' + rating : ''}` - const properties = { - summary: [ summary ], - featured: [ state.movie.Poster ], - published: [ state.published || dateInRFC3339() ], - ...(state.content && { content: [ state.content ] }), - 'watch-of': [ - { - 'type': [ 'h-cite' ], - 'properties': { - name: [ state.movie.Title ], - photo: [ state.movie.Poster ], - uid: [ `imdb:${state.movie.imdbID}` ], - url: [ `${IMDB_URL}${state.movie.imdbID}` ], - published: [ state.movie.Year ], - } - } - ], - // custom properties - ...(state.rating > 0 && { rating: [ state.rating ] }), - rewatch: [ state.rewatched === true ] - } - - return { - type: [ 'h-entry' ], - properties: properties - } - } - - const post = async (e) => { - e.preventDefault() - - const entry = buildEntry() - state.submitting = true - const res = await Proxy.micropub({ - method: 'POST', - body: entry - }) - - state.submitting = false - if (res && res.status === 201) { - if (res.headers.location) { - m.route.set('/success?url=' + res.headers.location) - } else { - Alert.error('location header missing') - } - } else if (!res || res.status >= 400) { - Alert.error(res) - } - } - - let timeout, search = [] - const submitSearch = async (e, page) => { - e && e.preventDefault() - - timeout && clearTimeout(timeout) - - if (!state.search) return - const params = parseQuery(state.search) - if (!params || params.search.trim().length < 3) return - - state.searching = true - state.searched = false - state.movie = null - state.page = page || 1 - - const res = await m.request({ - method: 'GET', - url: 'https://www.omdbapi.com', - params: { - apikey: OMDB_API_KEY, - s: params.search, - y: params.year, - page: state.page, - type: 'movie' - } - }) - - if (res && res.Response === 'True') { - search = res.Search - state.totalResults = res.totalResults - } else { - Alert.error(res && res.Error) - search = null - } - - state.searching = false - state.searched = true - } - - const inputChange = async (e) => { - state.search = e.target.value - timeout && clearTimeout(timeout) - timeout = setTimeout(submitSearch, 2000) - } - - const postType = postTypes.find(item => item.type == 'watch') - - return { - view: () => - m(Box, { - icon: '.fas.fa-film', - title: postType?.name || 'Movie' - }, [ - m('form', { - onsubmit: submitSearch - }, [ - m('input', { - type: 'text', - placeholder: 'title year:2023', - oninput: e => inputChange(e), - value: state.search || '' - }) - ]), - m('div.item-list.text-center', [ - state.searching && m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }), - state.searched && search && search.length > 0 && - search.map(mv => - m('div.item-tile', { - onclick: () => state.movie = state.movie ? null : mv, - hidden: state.movie && state.movie.imdbID != mv.imdbID - }, m('div.item' + (state.movie && state.movie.imdbID == mv.imdbID ? '.selected' : ''), [ - m('img', { src: mv.Poster }), - m('div', [ - m('h4', mv.Title), - m('h5', mv.Year) - ]) - ]))), - state.searched && (!search || search.length === 0) && m('div', 'No results found'), - !state.movie && state.totalResults > search.length && m('div.item-pagination', [ - m('button', { disabled: state.page == 1, onclick: e => submitSearch(e, --state.page) }, 'prev'), - m('button', { disabled: state.totalResults < state.page * 10, onclick: e => submitSearch(e, ++state.page) }, 'next') - ]) - ]), - state.movie && m('form', { - onsubmit: post - }, [ - m('label', [ - 'Watched on:', - m('input', { - type: 'date', - onchange: e => state.published = e.target.value, - value: state.published || dateInRFC3339() - }) - ]), - m('label', [ - 'Rewatched', - m('input', { - type: 'checkbox', - onchange: e => state.rewatched = e && e.target && e.target.checked, - checked: state.rewatched - }) - ]), - m('div.label', [ - 'Rate', - m(Rating, { - onchange: val => state.rating = val, - value: state.rating - }) - ]), - m('textarea', { - rows: 3, - placeholder: 'Add your review...', - oninput: e => state.content = e.target.value, - value: state.content || '' - }), - m('div.text-center', m('button', { - type: 'submit', - disabled: state.submitting - }, state.submitting ? m('i.fas.fa-spinner.fa-spin', { 'aria-hidden': 'true' }) : 'Post')), - m('div.text-center', m('a', { - onclick: () => Modal(m('pre', JSON.stringify(buildEntry(), null, 4))) - }, 'preview')) - ]) - ]) - } -} - -export default MovieEditor diff --git a/src/js/Editors/Tiles.js b/src/js/Editors/Tiles.js index cb88618..bca2ee4 100644 --- a/src/js/Editors/Tiles.js +++ b/src/js/Editors/Tiles.js @@ -2,8 +2,6 @@ import m from 'mithril' import Tile from '../Components/Tile' -const OMDB_API_KEY = import.meta.env.VITE_OMDB_API_KEY - const NoteTile = { view: ({ attrs }) => m(Tile, { href: '/new/note', @@ -88,6 +86,22 @@ const BookTile = { }) } +const ListenTile = { + view: ({ attrs }) => m(Tile, { + href: '/new/listen', + icon: '.fas.fa-music', + name: attrs?.name || 'Listen' + }) +} + +const GameTile = { + view: ({ attrs }) => m(Tile, { + href: '/new/game', + icon: '.fas.fa-gamepad', + name: attrs?.name || 'Game' + }) +} + const PostTypes = { note: NoteTile, image: ImageTile, @@ -96,13 +110,15 @@ const PostTypes = { like: LikeTile, article: ArticleTile, rsvp: RSVPTile, - watch: OMDB_API_KEY ? MovieTile : null, - read: BookTile + watch: MovieTile, + read: BookTile, + listen: ListenTile, + game: GameTile } const Tiles = (types, defaultTiles, params) => { if (!defaultTiles || !defaultTiles.length) { - defaultTiles = [ 'note', 'image', 'reply', 'bookmark', 'like', 'article', 'rsvp', 'watch', 'read' ] + defaultTiles = [ 'note', 'image', 'reply', 'bookmark', 'like', 'article', 'rsvp', 'watch', 'read', 'listen', 'game' ] } if (!types || !types.length) { types = defaultTiles.map(t => ({ type: t })) diff --git a/src/js/Editors/index.js b/src/js/Editors/index.js index 3ef129e..279e6bb 100644 --- a/src/js/Editors/index.js +++ b/src/js/Editors/index.js @@ -132,7 +132,7 @@ const Editor = ({ attrs }) => { }) state.submitting = false - if (res && res.status === 201) { + if (res && [201, 202].includes(res.status)) { if (res.headers.location) { m.route.set('/success?url=' + res.headers.location) } else { @@ -140,6 +140,8 @@ const Editor = ({ attrs }) => { } } else if (!res || res.status >= 400) { Alert.error(res) + } else { + console.error(res.status, res) } } @@ -148,8 +150,8 @@ const Editor = ({ attrs }) => { return { view: () => m(Box, { - icon: attrs.icon, //'.far.fa-note-sticky', - title: postType?.name || attrs.title //'Note' + icon: attrs.icon, // '.far.fa-note-sticky', + title: postType?.name || attrs.title // 'Note' }, m('form', { onsubmit: post }, [ diff --git a/src/js/app.js b/src/js/app.js index 382eb69..dc1e713 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -22,8 +22,12 @@ import { RSVPEditor } from './Editors' import ImageEditor from './Editors/ImageEditor' -import MovieEditor from './Editors/MovieEditor' -import BookEditor from './Editors/BookEditor' +import { + MovieEditor, + BookEditor, + ListenEditor, + GameEditor, +} from './Editors/MediaEditor' import Store from './Models/Store' @@ -55,5 +59,7 @@ m.route(document.body, '/', { '/new/like': AuthLayout(LikeEditor), '/new/rsvp': AuthLayout(RSVPEditor), '/new/movie': AuthLayout(MovieEditor), - '/new/book': AuthLayout(BookEditor) + '/new/book': AuthLayout(BookEditor), + '/new/listen': AuthLayout(ListenEditor), + '/new/game': AuthLayout(GameEditor), }) diff --git a/src/scss/_constants.scss b/src/scss/_constants.scss index 4d4dfc9..851d3dc 100644 --- a/src/scss/_constants.scss +++ b/src/scss/_constants.scss @@ -11,6 +11,7 @@ --input-border: var(--sprk-dim-color); --input-border-size: .1rem; --input-border-active: var(--sprk-highlight-color); + --input-border-radius: .3rem; --window-shadow: var(--sprk-fg-color); diff --git a/src/scss/_ui.scss b/src/scss/_ui.scss index b062452..bec33c0 100644 --- a/src/scss/_ui.scss +++ b/src/scss/_ui.scss @@ -72,12 +72,29 @@ form { margin-bottom: 0; } } + &.input-group { + display: flex; + border: var(--input-border-size) solid var(--input-border); + border-radius: var(--input-border-radius); + select, input { + margin: 0; + border: none; + border-radius: 0; + } + select { + width: 25%; + border-right: var(--input-border-size) solid var(--input-border); + } + input { + flex-grow: 1; + } + } } textarea, input, select { background-color: transparent; border: var(--input-border-size) solid var(--input-border); - border-radius: .3rem; + border-radius: var(--input-border-radius); box-shadow: none; padding: .5rem 1rem; @@ -106,6 +123,10 @@ label { max-width: 50%; width: auto; } + input[type="url"] { + min-width: 100px; + max-width: 50%; + } } textarea {