From 6ce82c603e5d7e24cdc847aa15465290dec1bbd2 Mon Sep 17 00:00:00 2001 From: Anna Murphy Date: Tue, 19 Mar 2024 17:18:00 -0400 Subject: [PATCH 1/2] upload works, admin login --- .gitignore | 1 + astro.config.mjs | 6 +- firestore.rules | 3 +- functions/rollup.config.js | 2 +- functions/src/api/api.ts | 10 +- functions/src/api/index.ts | 2 +- .../src/api/routes/capesInTheWestMarch.ts | 20 +- functions/src/api/routes/index.ts | 5 +- functions/src/api/routes/ping.ts | 10 +- functions/src/api/routes/types.ts | 8 +- functions/src/rss/rebuildRssFeed.ts | 38 ++-- functions/src/utils/firestore.ts | 26 +-- functions/src/utils/makeRss.ts | 30 +-- package.json | 13 +- scripts/emulators.ts | 26 +++ scripts/execSeed.ts | 96 ++++++++++ scripts/seed.ts | 97 +--------- scripts/set.ts | 8 + .../components/FormInputs/Base.tsx | 19 ++ .../components/FormInputs/CheckboxInput.tsx | 30 +++ .../components/FormInputs/FileInput.tsx | 33 ++++ .../components/FormInputs/NumberInput.tsx | 31 +++ .../components/FormInputs/ParagraphInput.tsx | 29 +++ .../components/FormInputs/SelectInput.tsx | 42 ++++ .../components/FormInputs/TextInput.tsx | 32 ++++ .../components/FormInputs/index.ts | 6 + .../components/FormInputs/utils.ts | 12 ++ src/admin-portal/components/index.ts | 0 src/admin-portal/index.ts | 1 + src/admin-portal/utils/firebase.ts | 25 +++ src/admin-portal/views/Login.tsx | 46 +++++ src/admin-portal/views/Upload.tsx | 181 ++++++++++++++++++ src/admin-portal/views/index.ts | 2 + src/env.d.ts | 88 +++++---- src/firebase/index.ts | 21 -- src/layouts/Layout.astro | 2 +- src/pages/admin.astro | 8 + src/pages/admin/index.astro | 8 - src/pages/index.astro | 4 +- storage.rules | 5 +- tsconfig.json | 9 +- yarn.lock | 109 ++++++++++- 42 files changed, 899 insertions(+), 245 deletions(-) create mode 100644 scripts/emulators.ts create mode 100644 scripts/execSeed.ts create mode 100644 scripts/set.ts create mode 100644 src/admin-portal/components/FormInputs/Base.tsx create mode 100644 src/admin-portal/components/FormInputs/CheckboxInput.tsx create mode 100644 src/admin-portal/components/FormInputs/FileInput.tsx create mode 100644 src/admin-portal/components/FormInputs/NumberInput.tsx create mode 100644 src/admin-portal/components/FormInputs/ParagraphInput.tsx create mode 100644 src/admin-portal/components/FormInputs/SelectInput.tsx create mode 100644 src/admin-portal/components/FormInputs/TextInput.tsx create mode 100644 src/admin-portal/components/FormInputs/index.ts create mode 100644 src/admin-portal/components/FormInputs/utils.ts create mode 100644 src/admin-portal/components/index.ts create mode 100644 src/admin-portal/index.ts create mode 100644 src/admin-portal/utils/firebase.ts create mode 100644 src/admin-portal/views/Login.tsx create mode 100644 src/admin-portal/views/Upload.tsx create mode 100644 src/admin-portal/views/index.ts delete mode 100644 src/firebase/index.ts create mode 100644 src/pages/admin.astro delete mode 100644 src/pages/admin/index.astro diff --git a/.gitignore b/.gitignore index 6d8296e..907b3a1 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,4 @@ dist .pnp.* .firebase +.firebaseEmulators \ No newline at end of file diff --git a/astro.config.mjs b/astro.config.mjs index 4840250..8154bf8 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,4 +1,8 @@ import { defineConfig } from "astro/config"; +import react from "@astrojs/react"; + // https://astro.build/config -export default defineConfig({}); +export default defineConfig({ + integrations: [react()], +}); diff --git a/firestore.rules b/firestore.rules index 14530f1..01cd397 100644 --- a/firestore.rules +++ b/firestore.rules @@ -3,8 +3,7 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { - allow read: if true; - allow write: if false; + allow read, write: if true; } } } \ No newline at end of file diff --git a/functions/rollup.config.js b/functions/rollup.config.js index 983b998..38faa8f 100644 --- a/functions/rollup.config.js +++ b/functions/rollup.config.js @@ -14,7 +14,7 @@ export default { "firebase-admin/storage", "firebase-admin/app", "firebase-admin/firestore", - "express" + "express", ], output: { dir: "lib", diff --git a/functions/src/api/api.ts b/functions/src/api/api.ts index 86841ab..835f3e9 100644 --- a/functions/src/api/api.ts +++ b/functions/src/api/api.ts @@ -6,11 +6,11 @@ import { onRequest } from "firebase-functions/v2/https"; const expressApp = express(); routes.forEach(({ route, method, handler }) => { - if (method === "GET") expressApp.get(route, handler); - if (method === "DELETE") expressApp.delete(route, handler); - if (method === "PUT") expressApp.put(route, handler); - if (method === "PATCH") expressApp.patch(route, handler); - if (method === "POST") expressApp.post(route, handler); + if (method === "GET") expressApp.get(route, handler); + if (method === "DELETE") expressApp.delete(route, handler); + if (method === "PUT") expressApp.put(route, handler); + if (method === "PATCH") expressApp.patch(route, handler); + if (method === "POST") expressApp.post(route, handler); }); // Found at localhost:5001/capes-in-the-dark/us-central1/api/ROUTE diff --git a/functions/src/api/index.ts b/functions/src/api/index.ts index 7de5cd9..d158c57 100644 --- a/functions/src/api/index.ts +++ b/functions/src/api/index.ts @@ -1 +1 @@ -export * from "./api"; \ No newline at end of file +export * from "./api"; diff --git a/functions/src/api/routes/capesInTheWestMarch.ts b/functions/src/api/routes/capesInTheWestMarch.ts index 89c33eb..d4ab8a0 100644 --- a/functions/src/api/routes/capesInTheWestMarch.ts +++ b/functions/src/api/routes/capesInTheWestMarch.ts @@ -3,16 +3,14 @@ import { Route } from "./types"; import { getMostRecentRss } from "../../utils/firestore"; export const citwmFeed: Route = { - method: "GET", - route: "/capes-in-the-west-march/rss.xml", - handler: async function (_req: Request, res: Response) { - try { - const rssFeed = await getMostRecentRss("Capes in the West March"); - res.status(200).send(rssFeed.rss); - } - catch (ex) { - res.sendStatus(404); - } + method: "GET", + route: "/capes-in-the-west-march/rss.xml", + handler: async function (_req: Request, res: Response) { + try { + const rssFeed = await getMostRecentRss("Capes in the West March"); + res.status(200).send(rssFeed.rss); + } catch (ex) { + res.sendStatus(404); } + }, }; - diff --git a/functions/src/api/routes/index.ts b/functions/src/api/routes/index.ts index 757446b..b80c10c 100644 --- a/functions/src/api/routes/index.ts +++ b/functions/src/api/routes/index.ts @@ -2,7 +2,4 @@ import { Route } from "./types"; import { ping } from "./ping"; import { citwmFeed } from "./capesInTheWestMarch"; -export const routes: Route[] = [ - ping, - citwmFeed, -]; +export const routes: Route[] = [ping, citwmFeed]; diff --git a/functions/src/api/routes/ping.ts b/functions/src/api/routes/ping.ts index bf2df90..925d805 100644 --- a/functions/src/api/routes/ping.ts +++ b/functions/src/api/routes/ping.ts @@ -2,9 +2,9 @@ import { Request, Response } from "express"; import { Route } from "./types"; export const ping: Route = { - method: "GET", - route: "/ping", - handler: async function (_req: Request, res: Response) { - res.status(200).send("pong"); - } + method: "GET", + route: "/ping", + handler: async function (_req: Request, res: Response) { + res.status(200).send("pong"); + }, }; diff --git a/functions/src/api/routes/types.ts b/functions/src/api/routes/types.ts index 3515c11..fbef30a 100644 --- a/functions/src/api/routes/types.ts +++ b/functions/src/api/routes/types.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; export type Route = { - method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - route: string; - handler: (req: Request, res: Response) => Promise; -} \ No newline at end of file + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + route: string; + handler: (req: Request, res: Response) => Promise; +}; diff --git a/functions/src/rss/rebuildRssFeed.ts b/functions/src/rss/rebuildRssFeed.ts index 29d4b67..4d4f705 100644 --- a/functions/src/rss/rebuildRssFeed.ts +++ b/functions/src/rss/rebuildRssFeed.ts @@ -1,26 +1,32 @@ import { onDocumentCreated } from "firebase-functions/v2/firestore"; import * as logger from "firebase-functions/logger"; -import { getDataFromEvent, queryEpisodeByFeed, queryFeed, saveFeed } from "../utils/firestore"; -import { PodcastEpisode } from "../utils/types" +import { + getDataFromEvent, + queryEpisodeByFeed, + queryFeed, + saveFeed, +} from "../utils/firestore"; +import { PodcastEpisode } from "../utils/types"; import { makeRss } from "../utils/makeRss"; /** * Update the RSS Feed Document with a new episode once it's created. */ -export const rebuildRssFeed = onDocumentCreated("api/v1/episodes/{episodeId}", async (event) => { +export const rebuildRssFeed = onDocumentCreated( + "api/v1/episodes/{episodeId}", + async (event) => { try { - const episodeData = getDataFromEvent(event); - if (!episodeData) throw new Error("Unable to retrieve data for new episode"); - const feed = await queryFeed(episodeData.feed); - const episodes = await queryEpisodeByFeed(episodeData.feed); - const newRssFeed = makeRss(feed, episodes); - saveFeed(episodeData.feed, newRssFeed); + const episodeData = getDataFromEvent(event); + if (!episodeData) + throw new Error("Unable to retrieve data for new episode"); + const feed = await queryFeed(episodeData.feed); + const episodes = await queryEpisodeByFeed(episodeData.feed); + const newRssFeed = makeRss(feed, episodes); + saveFeed(episodeData.feed, newRssFeed); + } catch (ex) { + logger.error(ex); + logger.error("Aborting..."); } - catch (ex) { - logger.error(ex); - logger.error("Aborting..."); - } -}); - - + }, +); diff --git a/functions/src/utils/firestore.ts b/functions/src/utils/firestore.ts index 17cf6cb..dcd9e68 100644 --- a/functions/src/utils/firestore.ts +++ b/functions/src/utils/firestore.ts @@ -37,25 +37,29 @@ export async function queryFeed(feed: string) { throw new Error(`Unable to find feed "${feed}"`); } - /** * Saves a new document with the whole RSS feed for a given feed. * @param data string form of RSS data */ export async function saveFeed(feed: string, data: string) { - const db = getFirestoreDb(); - return db.collection("api/v1/rss").add({ - feed, - rss: data, - timestamp: Timestamp.now(), - }); + const db = getFirestoreDb(); + return db.collection("api/v1/rss").add({ + feed, + rss: data, + timestamp: Timestamp.now(), + }); } export async function getMostRecentRss(feed: string) { - const db = getFirestoreDb(); - const feedItems = await db.collection("api/v1/rss").where("feed", "==", feed).orderBy("timestamp", "desc").limit(1).get(); - if (feedItems.size === 1) return feedItems.docs[0].data() as RssDocument; - throw new Error(`Unable to find an RSS feed for "${feed}"`); + const db = getFirestoreDb(); + const feedItems = await db + .collection("api/v1/rss") + .where("feed", "==", feed) + .orderBy("timestamp", "desc") + .limit(1) + .get(); + if (feedItems.size === 1) return feedItems.docs[0].data() as RssDocument; + throw new Error(`Unable to find an RSS feed for "${feed}"`); } /** diff --git a/functions/src/utils/makeRss.ts b/functions/src/utils/makeRss.ts index ca1479b..7d2dc86 100644 --- a/functions/src/utils/makeRss.ts +++ b/functions/src/utils/makeRss.ts @@ -2,7 +2,7 @@ import { Timestamp } from "firebase-admin/firestore"; import { PodcastChannel, PodcastEpisode } from "./types"; export function makeRss(feed: PodcastChannel, episodes: PodcastEpisode[]) { - return ` + return ` + return ` ${channel.title} ${channel.contact.site} @@ -40,27 +40,29 @@ function makeChannel(channel: PodcastChannel, items: string) { ${channel.metadata.locked} ${channel.metadata.complete} ${items} - ` + `; } -function formatCategories(categories: PodcastChannel["metadata"]["categories"]) { - return categories - .map(({ category, subCategory }) => { - if (subCategory === undefined) - return ``; - return ` +function formatCategories( + categories: PodcastChannel["metadata"]["categories"], +) { + return categories + .map(({ category, subCategory }) => { + if (subCategory === undefined) + return ``; + return ` `; - }) - .join(""); + }) + .join(""); } function makeItem(episodeData: PodcastEpisode) { - return ` + return ` ${episodeData.title} - + ${episodeData.fileData.url} ${new Timestamp(episodeData.publishDate.seconds, episodeData.publishDate.nanoseconds).toDate().toUTCString()} @@ -114,4 +116,4 @@ const example = ` `; -*/ \ No newline at end of file +*/ diff --git a/package.json b/package.json index 916c7ca..32e5394 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,23 @@ "deploy": "yarn build && firebase deploy", "lint": "eslint src --ext .astro,.js,.ts,.json", "format": "prettier --write .", - "emulators": "firebase emulators:start", - "emulators:seed": "tsx scripts/seed.ts" + "emulators": "firebase emulators:start --import .firebaseEmulators", + "emulators:seed": "tsx scripts/seed.ts", + "emulators:set": "tsx scripts/set.ts", + "emulators:reset": "rm -rf .firebaseEmulators && yarn emulators:set", + "fetch:citwm": "curl https://capes-in-the-dark.web.app/feeds/capes-in-the-west-march/rss.xml", + "fetch:citwm:emulated": "curl localhost:5001/capes-in-the-dark/us-central1/feeds/capes-in-the-west-march/rss.xml" }, "dependencies": { "@astrojs/check": "^0.5.6", + "@astrojs/react": "^3.1.0", "@astrojs/rss": "^4.0.5", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", "astro": "^4.4.15", "firebase": "^10.8.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", "typescript": "5.4" }, "devDependencies": { diff --git a/scripts/emulators.ts b/scripts/emulators.ts new file mode 100644 index 0000000..ffa0bc4 --- /dev/null +++ b/scripts/emulators.ts @@ -0,0 +1,26 @@ +import { spawn } from "child_process"; + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function startEmulators(): Promise<() => boolean> { + const emulatorChildProcess = spawn("firebase", [ + "emulators:start", + "--only", + "firestore", // "auth,firestore", + "--export-on-exit=./.firebaseEmulators/", + ]); + await sleep(10000); + return () => emulatorChildProcess.kill("SIGINT"); +} + +export async function taskWithEmulatorsOn( + asyncTask: () => Promise, +): Promise { + const killEmulatorProc = await startEmulators(); + await asyncTask(); + killEmulatorProc(); +} diff --git a/scripts/execSeed.ts b/scripts/execSeed.ts new file mode 100644 index 0000000..a8e3ede --- /dev/null +++ b/scripts/execSeed.ts @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import { Timestamp } from "firebase-admin/firestore"; + +import { firestore, storage } from "./utils/firebaseEmulators"; + +const FEEDS_COLLECTION = firestore + .collection("api") + .doc("v1") + .collection("feeds"); +const EPISODES_COLLECTION = firestore + .collection("api") + .doc("v1") + .collection("episodes"); + +const CAPES_IN_THE_WEST_MARCH_FEED: PodcastChannel = { + title: "Capes in the West March", + description: + "An Actual Play Archive of RAW Audio recordings of our Capes in the West March game.", + feedUrl: "https://capes-in-the-dark.web.app/capes-in-the-west-march/rss", + image: + "https://capes-in-the-dark.web.app/images/Capes_in_the_West_March_Image.png", + contact: { + site: "https://capes-in-the-dark.web.app", + author: "Anna Murphy", + owner: "Anna Murphy", + email: "curunilauro@gmail.com", + }, + metadata: { + type: "serial", + locked: "no", + complete: "no", + categories: [ + { category: "Fiction" }, + { category: "Leisure", subCategory: "Games" }, + ], + language: "en-us", + explicit: true, + }, +}; + +const CITWM_EPISODE: PodcastEpisode = { + feed: "Capes in the West March", + title: "Example Episode", + description: "A given description", + publishDate: Timestamp.fromDate(new Date()) as unknown as Date, + imageLink: + "https://capes-in-the-dark.web.app/images/Capes_in_the_West_March_Image.png", + metadata: { + season: 0, + episode: 1, + // transcriptUrl: undefined, + type: "full", + explicit: false, + }, + fileData: { + url: "", + size: 0, + duration: 204, + }, +}; + +async function uploadDataToStorage( + bucket: string, + fileName: string, + path: string, +): Promise["file"]>> { + return await new Promise((resolve, reject) => { + const fileRef = storage.bucket(bucket).file(fileName); + fs.createReadStream(path) + .pipe(fileRef.createWriteStream()) + .on("error", (error) => { + reject(error.message); + }) + .on("finish", () => { + resolve(fileRef); + }); + }); +} + +async function seedEpisode(): Promise { + const fileRef = await uploadDataToStorage( + "gs://capes-in-the-dark.appspot.com", + "audio.mp3", + "./scripts/data/audio.mp3", + ); + const metadata = await fileRef.getMetadata(); + const { size, mediaLink } = metadata[0]; + CITWM_EPISODE.fileData.size = Number(size); + CITWM_EPISODE.fileData.url = mediaLink ?? ""; + await EPISODES_COLLECTION.add(CITWM_EPISODE); +} + +export async function seed(): Promise { + await FEEDS_COLLECTION.add(CAPES_IN_THE_WEST_MARCH_FEED); + await seedEpisode(); +} diff --git a/scripts/seed.ts b/scripts/seed.ts index ce906e2..04393bf 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,99 +1,4 @@ -import fs from "node:fs"; -import { Timestamp } from "firebase-admin/firestore"; - -import { firestore, storage } from "./utils/firebaseEmulators"; - -const FEEDS_COLLECTION = firestore - .collection("api") - .doc("v1") - .collection("feeds"); -const EPISODES_COLLECTION = firestore - .collection("api") - .doc("v1") - .collection("episodes"); - -const CAPES_IN_THE_WEST_MARCH_FEED: PodcastChannel = { - title: "Capes in the West March", - description: - "An Actual Play Archive of RAW Audio recordings of our Capes in the West March game.", - feedUrl: "https://capes-in-the-dark.web.app/capes-in-the-west-march/rss", - image: - "https://capes-in-the-dark.web.app/images/Capes_in_the_West_March_Image.png", - contact: { - site: "https://capes-in-the-dark.web.app", - author: "Anna Murphy", - owner: "Anna Murphy", - email: "curunilauro@gmail.com", - }, - metadata: { - type: "serial", - locked: "no", - complete: "no", - categories: [ - { category: "Fiction" }, - { category: "Leisure", subCategory: "Games" }, - ], - language: "en-us", - explicit: true, - }, -}; - -const CITWM_EPISODE: PodcastEpisode = { - feed: "Capes in the West March", - title: "Example Episode", - description: "A given description", - publishDate: Timestamp.fromDate(new Date()) as unknown as Date, - imageLink: - "https://capes-in-the-dark.web.app/images/Capes_in_the_West_March_Image.png", - metadata: { - season: 0, - episode: 1, - // transcriptUrl: undefined, - type: "full", - explicit: false, - }, - fileData: { - url: "", - size: 0, - duration: 204, - }, -}; - -async function uploadDataToStorage( - bucket: string, - fileName: string, - path: string, -): Promise["file"]>> { - return await new Promise((resolve, reject) => { - const fileRef = storage.bucket(bucket).file(fileName); - fs.createReadStream(path) - .pipe(fileRef.createWriteStream()) - .on("error", (error) => { - reject(error.message); - }) - .on("finish", () => { - resolve(fileRef); - }); - }); -} - -async function seedEpisode(): Promise { - const fileRef = await uploadDataToStorage( - "gs://capes-in-the-dark.appspot.com", - "audio.mp3", - "./scripts/data/audio.mp3", - ); - const metadata = await fileRef.getMetadata(); - const { size, mediaLink } = metadata[0]; - CITWM_EPISODE.fileData.size = Number(size); - CITWM_EPISODE.fileData.url = mediaLink ?? ""; - await EPISODES_COLLECTION.add(CITWM_EPISODE); -} - -export async function seed(): Promise { - await FEEDS_COLLECTION.add(CAPES_IN_THE_WEST_MARCH_FEED); - await seedEpisode(); -} +import { seed } from "./execSeed"; seed() .then(() => { diff --git a/scripts/set.ts b/scripts/set.ts new file mode 100644 index 0000000..96bf8ef --- /dev/null +++ b/scripts/set.ts @@ -0,0 +1,8 @@ +import { taskWithEmulatorsOn } from "./emulators"; +import { seed } from "./execSeed"; + +taskWithEmulatorsOn(seed) + .then(() => { + console.log("done"); + }) + .catch(console.error); diff --git a/src/admin-portal/components/FormInputs/Base.tsx b/src/admin-portal/components/FormInputs/Base.tsx new file mode 100644 index 0000000..0ae96bb --- /dev/null +++ b/src/admin-portal/components/FormInputs/Base.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +interface BaseProps { + label: string; + inputId: string; +} + +export function Base({ + label, + inputId, + children, +}: React.PropsWithChildren): JSX.Element { + return ( +
+ + {children} +
+ ); +} diff --git a/src/admin-portal/components/FormInputs/CheckboxInput.tsx b/src/admin-portal/components/FormInputs/CheckboxInput.tsx new file mode 100644 index 0000000..1960048 --- /dev/null +++ b/src/admin-portal/components/FormInputs/CheckboxInput.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import { useInputId } from "./utils"; +import { Base } from "./Base"; + +interface CheckboxProps { + label: string; + value: boolean; + setValue: (newValue: boolean) => void; +} + +export function CheckboxInput({ + label, + value, + setValue, +}: CheckboxProps): JSX.Element { + const inputId = useInputId(label); + return ( + + { + setValue(!value); + }} + /> + + ); +} diff --git a/src/admin-portal/components/FormInputs/FileInput.tsx b/src/admin-portal/components/FormInputs/FileInput.tsx new file mode 100644 index 0000000..2bf9ee8 --- /dev/null +++ b/src/admin-portal/components/FormInputs/FileInput.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { useInputId } from "./utils"; +import { Base } from "./Base"; + +interface FileInputProps { + label: string; + setValue: (newValue: File) => void; + accept: string; +} + +export function FileInput({ + label, + setValue, + accept, +}: FileInputProps): JSX.Element { + const inputId = useInputId(label); + return ( + + { + if (event.target.files !== null && event.target.files.length > 0) { + const file = event.target.files[0]; + setValue(file); + } + }} + accept={accept} + /> + + ); +} diff --git a/src/admin-portal/components/FormInputs/NumberInput.tsx b/src/admin-portal/components/FormInputs/NumberInput.tsx new file mode 100644 index 0000000..f5302a2 --- /dev/null +++ b/src/admin-portal/components/FormInputs/NumberInput.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import { useInputId } from "./utils"; +import { Base } from "./Base"; + +interface NumberInputProps { + label: string; + value: number; + setValue: (newValue: number) => void; +} + +export function NumberInput({ + label, + value, + setValue, +}: NumberInputProps): JSX.Element { + const inputId = useInputId(label); + return ( + + { + const value = Number(event.target.value); + if (!isNaN(value)) setValue(value); + }} + /> + + ); +} diff --git a/src/admin-portal/components/FormInputs/ParagraphInput.tsx b/src/admin-portal/components/FormInputs/ParagraphInput.tsx new file mode 100644 index 0000000..fd9be9d --- /dev/null +++ b/src/admin-portal/components/FormInputs/ParagraphInput.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import { useInputId } from "./utils"; +import { Base } from "./Base"; + +interface ParagraphInputProps { + label: string; + value: string; + setValue: (newValue: string) => void; +} + +export function ParagraphInput({ + label, + value, + setValue, +}: ParagraphInputProps): JSX.Element { + const inputId = useInputId(label); + return ( + +