From 7a40d8ef47c91569b24577e11e3cb99e2b6c940b Mon Sep 17 00:00:00 2001 From: Daniel O'Connell Date: Tue, 16 Jul 2024 13:57:15 +0200 Subject: [PATCH 1/2] Search from url --- app/components/SearchInput/Input.tsx | 1 + app/components/search.tsx | 17 +++++++++++++++-- wrangler.toml.template | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/components/SearchInput/Input.tsx b/app/components/SearchInput/Input.tsx index c313eeda..fd7a09e8 100644 --- a/app/components/SearchInput/Input.tsx +++ b/app/components/SearchInput/Input.tsx @@ -19,6 +19,7 @@ interface SearchInputProps { } export const SearchInput = ({onChange, expandable, placeholderText}: SearchInputProps) => { const [search, setSearch] = useState('') + const handleSearch = (search: string) => { setSearch(search) if (onChange) { diff --git a/app/components/search.tsx b/app/components/search.tsx index c65daf31..1e403a6a 100644 --- a/app/components/search.tsx +++ b/app/components/search.tsx @@ -1,4 +1,5 @@ -import {useState} from 'react' +import {useEffect, useState} from 'react' +import {useSearchParams} from '@remix-run/react' import debounce from 'lodash/debounce' import {useSearch} from '~/hooks/useSearch' import {SearchInput} from './SearchInput/Input' @@ -12,10 +13,11 @@ type Props = { } export default function Search({limitFromUrl, className}: Props) { + const [searchParams] = useSearchParams() const [showResults, setShowResults] = useState(false) const [searchPhrase, setSearchPhrase] = useState('') - const {search, isPendingSearch, results, clear} = useSearch(limitFromUrl) + const {search, isPendingSearch, results, clear, loadedQuestions} = useSearch(limitFromUrl) const clickDetectorRef = useOutsideOnClick(() => setShowResults(false)) const searchFn = (rawValue: string) => { @@ -31,6 +33,17 @@ export default function Search({limitFromUrl, className}: Props) { } } + useEffect(() => { + const query = searchParams.get('q') + if (loadedQuestions && query) { + searchFn(query) + setShowResults(true) + } + // Properly adding all dependancies would require transforming `searchFn` into a callback + // or something, which in turn would cause this `useEffect` to be called a lot more often. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadedQuestions, searchParams]) + const handleChange = debounce(searchFn, 100) return ( diff --git a/wrangler.toml.template b/wrangler.toml.template index 2c0d98e5..932f49ce 100644 --- a/wrangler.toml.template +++ b/wrangler.toml.template @@ -25,4 +25,4 @@ MATOMO_DOMAIN = "{MATOMO_DOMAIN}" DISCORD_LOGGING_CHANNEL_ID = "{DISCORD_LOGGING_CHANNEL_ID}" DISCORD_LOGGING_TOKEN = "{DISCORD_LOGGING_TOKEN}" EDITOR_USERNAME = "{EDITOR_USERNAME}" -EDITOR_PASSWORD = "{EDITOR_PASSWORD}" +EDITOR_PASSWORD = "{EDITOR_PASSWORD}" \ No newline at end of file From dcb76f856f5282acd4be1d6b29cf9c948e4315fa Mon Sep 17 00:00:00 2001 From: Daniel O'Connell Date: Tue, 16 Jul 2024 15:25:42 +0200 Subject: [PATCH 2/2] API access for all questions --- app/routes/editors.tsx | 72 ++++++----------- app/routes/questions.allQuestions.ts | 116 +++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 65 deletions(-) diff --git a/app/routes/editors.tsx b/app/routes/editors.tsx index a227a699..b998b03d 100644 --- a/app/routes/editors.tsx +++ b/app/routes/editors.tsx @@ -4,32 +4,20 @@ import Page from '~/components/Page' import Button from '~/components/Button' import '~/components/Chatbot/widgit.css' import {Question, QuestionStatus, loadAllQuestions} from '~/server-utils/stampy' -import {downloadZip} from 'client-zip' import {useLoaderData} from '@remix-run/react' import {isAuthorized} from '~/routesMapper' - -const SINGLE_FILE_HTML = 'singleFileHtml' -const SINGLE_FILE_MARKDOWN = 'singleFileMarkdown' -const MULTI_FILE_HTML = 'multipleFilesMarkdown' -const MULTI_FILE_MARKDOWN = 'multipleFileHtml' -const SINGLE_FILE_JSON = 'singleFileJson' - -const downloadOptions = { - [SINGLE_FILE_HTML]: 'Single HTML file', - [MULTI_FILE_HTML]: 'Multiple HTML files', - [SINGLE_FILE_MARKDOWN]: 'Single markdown file', - [MULTI_FILE_MARKDOWN]: 'Multiple markdown files', - [SINGLE_FILE_JSON]: 'As JSON', -} - -const makeZipFile = async (questions: Question[], extention: string) => - downloadZip( - questions.map((q) => ({ - name: `${q.title}.${extention}`, - lastModified: q.updatedAt, - input: (extention === 'md' ? q.markdown : q.text) || '', - })) - ).blob() +import { + MULTI_FILE_HTML, + MULTI_FILE_MARKDOWN, + SINGLE_FILE_HTML, + SINGLE_FILE_JSON, + SINGLE_FILE_MARKDOWN, + downloadOptions, + htmlFile, + makeZipFile, + toHtmlChunk, + toMarkdownChunk, +} from './questions.allQuestions' const downloadBlob = async (filename: string, blob: Blob) => { const link = document.createElement('a') @@ -55,33 +43,16 @@ const makeFilename = (selectedOption: string) => { } } -const toHtmlChunk = ({title, text}: Question) => - [ - '
', - `

${title}

`, - `
${text}
`, - '
', - ].join('\n') - -const toMarkdownChunk = ({title, markdown}: Question) => `# ${title}\n\n${markdown}` - -const htmlFile = (contents: string) => { - const html = ['', '', ' ', contents, ' ', '/html>'].join( - '\n' - ) - return new Blob([html], {type: 'text/html'}) -} - const getData = async (questions: Question[], selectedOption: string) => { switch (selectedOption) { case SINGLE_FILE_HTML: - return htmlFile(questions.map(toHtmlChunk).join('\n\n\n')) + return new Blob([htmlFile(questions.map(toHtmlChunk).join('\n\n\n'))], {type: 'text/html'}) case MULTI_FILE_HTML: - return makeZipFile(questions, 'html') + return (await makeZipFile(questions, 'html')).blob() case SINGLE_FILE_MARKDOWN: return new Blob([questions.map(toMarkdownChunk).join('\n\n\n')], {type: 'text/markdown'}) case MULTI_FILE_MARKDOWN: - return makeZipFile(questions, 'md') + return (await makeZipFile(questions, 'md')).blob() case SINGLE_FILE_JSON: return new Blob([JSON.stringify(questions, null, 2)], {type: 'application/json'}) } @@ -89,9 +60,10 @@ const getData = async (questions: Question[], selectedOption: string) => { type DownloadQuestionsProps = { title: string + type: string questions: Question[] } -const DownloadQuestions = ({title, questions}: DownloadQuestionsProps) => { +const DownloadQuestions = ({title, type, questions}: DownloadQuestionsProps) => { const [selectedOption, setSelectedOption] = useState(SINGLE_FILE_HTML) const download = async () => { @@ -109,11 +81,13 @@ const DownloadQuestions = ({title, questions}: DownloadQuestionsProps) => { type="radio" id={id} checked={id === selectedOption} - name="download-format" + name={`download-format-${type}`} value={id} onChange={(e) => setSelectedOption(e.target.id)} /> - + ))} @@ -146,13 +120,15 @@ export default function EditorHelpers() {
{(!questions || !questions.length) &&
Fetching questions...
} - + q.status === QuestionStatus.LIVE_ON_SITE)} /> q.status !== QuestionStatus.LIVE_ON_SITE)} />
diff --git a/app/routes/questions.allQuestions.ts b/app/routes/questions.allQuestions.ts index b3c0ae46..63e80abf 100644 --- a/app/routes/questions.allQuestions.ts +++ b/app/routes/questions.allQuestions.ts @@ -1,24 +1,106 @@ -import {LoaderFunctionArgs} from '@remix-run/cloudflare' -import {reloadInBackgroundIfNeeded} from '~/server-utils/kv-cache' -import {loadAllQuestions} from '~/server-utils/stampy' +import {LoaderFunctionArgs, json} from '@remix-run/cloudflare' +import {downloadZip} from 'client-zip' +import '~/components/Chatbot/widgit.css' +import {Question, QuestionStatus, loadAllQuestions} from '~/server-utils/stampy' +import {isAuthorized} from '~/routesMapper' + +export const SINGLE_FILE_HTML = 'singleFileHtml' +export const SINGLE_FILE_MARKDOWN = 'singleFileMarkdown' +export const MULTI_FILE_HTML = 'multipleFilesMarkdown' +export const MULTI_FILE_MARKDOWN = 'multipleFileHtml' +export const SINGLE_FILE_JSON = 'singleFileJson' + +export const downloadOptions = { + [SINGLE_FILE_HTML]: 'Single HTML file', + [MULTI_FILE_HTML]: 'Multiple HTML files', + [SINGLE_FILE_MARKDOWN]: 'Single markdown file', + [MULTI_FILE_MARKDOWN]: 'Multiple markdown files', + [SINGLE_FILE_JSON]: 'As JSON', +} + +export const makeZipFile = async (questions: Question[], extention: string) => + downloadZip( + questions.map((q) => ({ + name: `${q.title}.${extention}`, + lastModified: q.updatedAt, + input: (extention === 'md' ? q.markdown : q.text) || '', + })) + ) + +export const toHtmlChunk = ({title, text}: Question) => + [ + '
', + `

${title}

`, + `
${text}
`, + '
', + ].join('\n') + +export const toMarkdownChunk = ({title, markdown}: Question) => `# ${title}\n\n${markdown}` + +export const htmlFile = (contents: string) => + ['', '', ' ', contents, ' ', '/html>'].join('\n') + +const getData = async (questions: Question[], selectedOption: string) => { + switch (selectedOption) { + case SINGLE_FILE_HTML: + return new Response(htmlFile(questions.map(toHtmlChunk).join('\n\n\n')), { + headers: {'Content-Type': 'text/html'}, + }) + case MULTI_FILE_HTML: + return makeZipFile(questions, 'html') + case SINGLE_FILE_MARKDOWN: + return new Response(questions.map(toMarkdownChunk).join('\n\n\n'), { + headers: {'Content-Type': 'text/markdown'}, + }) + case MULTI_FILE_MARKDOWN: + return makeZipFile(questions, 'md') + case SINGLE_FILE_JSON: + return json(questions) + default: + return json( + { + error: + 'Invalid dataType provided. Must be one of ' + Object.keys(downloadOptions).join(', '), + }, + 400 + ) + } +} + +export const headers = () => ({ + 'WWW-Authenticate': 'Basic', +}) + +type QuestionsFilter = 'all' | 'live' | 'inProgress' +const filteredQuestions = (questions: Question[], status: QuestionsFilter) => { + switch (status) { + case 'all': + return questions + case 'live': + return questions?.filter((q) => q.status === QuestionStatus.LIVE_ON_SITE) + case 'inProgress': + return questions?.filter((q) => q.status !== QuestionStatus.LIVE_ON_SITE) + default: + throw 'Invalid questions filter provided. Must be one of "all", "live" or "inProgress"' + } +} export const loader = async ({request}: LoaderFunctionArgs) => { try { - return await loadAllQuestions(request) + if (!isAuthorized(request)) { + return json({authorized: false, data: [] as Question[]}, {status: 401}) + } + + const url = new URL(request.url) + const params = new URLSearchParams(url.search) + const dataType = params.get('dataType') || SINGLE_FILE_JSON + const {data: allQuestions} = await loadAllQuestions(request) + return getData( + filteredQuestions(allQuestions, (params.get('questions') || 'all') as QuestionsFilter), + dataType + ) } catch (e) { console.error(e) - throw new Response('Could not fetch all articles', {status: 500}) + throw new Response(`Could not fetch all articles: ${e}`, {status: 500}) } } -type Data = ReturnType - -export const fetchAllQuestions = async () => { - const url = `/questions/allQuestions` - const response = await fetch(url) - const {data, timestamp}: Awaited = await response.json() - const backgroundPromiseIfReloaded: Data | Promise = reloadInBackgroundIfNeeded( - url, - timestamp - ) - return {data, backgroundPromiseIfReloaded} -}