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..d28de63 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,27 @@ "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": "yarn emulators:start", + "emulators:start": "firebase emulators:start --import .firebaseEmulators", + "emulators:clean": "yarn emulators:clear && firebase emulators:start --export-on-exit --import .firebaseEmulators", + "emulators:save": "firebase emulators:start", + "emulators:seed": "tsx scripts/seed.ts", + "emulators:set": "tsx scripts/set.ts", + "emulators:reset": "yarn emulators:clear && yarn emulators:set", + "emulators:clear": "rm -rf .firebaseEmulators", + "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..bfa15b2 --- /dev/null +++ b/scripts/execSeed.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import { Timestamp } from "firebase-admin/firestore"; + +import { auth, 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); +} + +async function seedUser(): Promise { + await auth.createUser({ + email: "a@a.com", + emailVerified: true, + password: "password", + }); +} + +export async function seed(): Promise { + await FEEDS_COLLECTION.add(CAPES_IN_THE_WEST_MARCH_FEED); + await seedEpisode(); + await seedUser(); +} 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/scripts/utils/firebaseEmulators.ts b/scripts/utils/firebaseEmulators.ts index 82a23a1..17d81b6 100644 --- a/scripts/utils/firebaseEmulators.ts +++ b/scripts/utils/firebaseEmulators.ts @@ -4,7 +4,7 @@ process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080"; process.env.GCLOUD_PROJECT = "capes-in-the-dark"; process.env.FIREBASE_STORAGE_EMULATOR_HOST = "127.0.0.1:9199"; -admin.initializeApp({ projectId: "capes-in-the-dark" }); - -export const firestore = admin.firestore(); -export const storage = admin.storage(); +export const app = admin.initializeApp({ projectId: "capes-in-the-dark" }); +export const firestore = admin.firestore(app); +export const storage = admin.storage(app); +export const auth = admin.auth(app); diff --git a/scripts/utils/firebaseProduction.ts b/scripts/utils/firebaseProduction.ts index 9096bac..cc9759e 100644 --- a/scripts/utils/firebaseProduction.ts +++ b/scripts/utils/firebaseProduction.ts @@ -10,3 +10,4 @@ admin.initializeApp({ projectId: "capes-in-the-dark" }); export const firestore = admin.firestore(); export const storage = admin.storage(); +export const auth = admin.auth(); diff --git a/src/admin-portal/components/EpisodeForm.tsx b/src/admin-portal/components/EpisodeForm.tsx new file mode 100644 index 0000000..ebf4d03 --- /dev/null +++ b/src/admin-portal/components/EpisodeForm.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { + FileInput, + NumberInput, + TextInput, + SelectInput, + CheckboxInput, + ParagraphInput, +} from "./FormInputs"; +import { Timestamp } from "firebase/firestore"; + +interface EpisodeFormPromps { + episodeData?: PodcastEpisode; + parseFile?: (file: File) => Promise<{ downloadUrl: string; size: number }>; + submit: (episode: PodcastEpisode) => void; + submitLabel: string; +} + +interface EditedEpisodeData { + title: string; + duration: number; + season: number; + episode: number; + episodeType: EpisodeType; + explicit: boolean; + description: string; + file: { downloadUrl: string; size: number }; +} + +export function EpisodeForm({ + submit, + submitLabel, + episodeData, + parseFile, +}: EpisodeFormPromps): JSX.Element { + const [editedEpisodeData, setEditedEpisodeData] = + React.useState(startingEpisodeData(episodeData)); + const [file, setFile] = React.useState(); + const [error, setError] = React.useState(""); + + const doParseFile = React.useCallback( + async (uploadedFile: File | undefined) => { + if (parseFile === undefined) return editedEpisodeData.file; + if (uploadedFile === undefined) + throw new Error("Don't forget to upload an episode!"); + return await parseFile(uploadedFile); + }, + [parseFile, editedEpisodeData], + ); + + const doSubmit = React.useCallback< + Exclude["onSubmit"], undefined> + >( + async (event) => { + event.preventDefault(); + try { + const { downloadUrl, size } = await doParseFile(file); + submit( + makeEpisodeData({ + ...editedEpisodeData, + file: { downloadUrl, size }, + }), + ); + } catch (ex) { + setError((ex as { message: string }).message); + } + }, + [doParseFile, editedEpisodeData, file, submit], + ); + + return ( + <> +
+ { + setEditedEpisodeData((existingData) => ({ + ...existingData, + title, + })); + }} + /> + { + setEditedEpisodeData((existingData) => ({ + ...existingData, + season, + })); + }} + /> + { + setEditedEpisodeData((existingData) => ({ + ...existingData, + episode, + })); + }} + /> + {parseFile !== undefined ? ( + + ) : undefined} + { + setEditedEpisodeData((existingData) => ({ + ...existingData, + duration, + })); + }} + /> + { + setEditedEpisodeData((existingData) => ({ + ...existingData, + episodeType: type as EpisodeType, + })); + }} + /> + { + setEditedEpisodeData((existingData) => ({ + ...existingData, + explict, + })); + }} + /> + { + setEditedEpisodeData((existingData) => ({ + ...existingData, + description, + })); + }} + /> + + + {error !== "" ?

{error}

: undefined} + + ); +} + +function startingEpisodeData( + episodeData: PodcastEpisode | undefined, +): EditedEpisodeData { + return { + title: episodeData === undefined ? "" : episodeData.title, + duration: episodeData === undefined ? 0 : episodeData.fileData.duration, + season: episodeData === undefined ? 1 : episodeData.metadata.season, + episode: episodeData === undefined ? 1 : episodeData.metadata.episode, + file: + episodeData === undefined + ? { downloadUrl: "", size: 0 } + : { + downloadUrl: episodeData.fileData.url, + size: episodeData.fileData.size, + }, + episodeType: + episodeData?.metadata.type === undefined + ? "full" + : episodeData.metadata.type, + explicit: episodeData === undefined ? true : episodeData.metadata.explicit, + description: episodeData === undefined ? "" : episodeData.description, + }; +} + +function makeEpisodeData(editedData: EditedEpisodeData): PodcastEpisode { + return { + feed: "Capes in the West March", + title: editedData.title, + description: editedData.description, + imageLink: "", + metadata: { + season: editedData.season, + episode: editedData.episode, + type: editedData.episodeType, + explicit: editedData.explicit, + }, + fileData: { + url: editedData.file.downloadUrl, + size: editedData.file.size, + duration: editedData.duration, + }, + // Save this as a firebase Timestamp to upload to the database. + publishDate: Timestamp.fromDate(new Date()) as unknown as Date, + } satisfies PodcastEpisode; +} 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 ( + +