diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6a154f15 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.user +**/node_modules +**/build +**/compile +**/coverage diff --git a/.gitignore b/.gitignore index a9bbad26..e86a4316 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ pids .clinic/ .vscode/ .wrangler/ +.react-router build/ compile/ dist/ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 00000000..4c70e45c --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,14 @@ +# The build context is the root of the project +FROM node:24-alpine +WORKDIR /dpkit + +COPY pnpm-lock.yaml . +RUN corepack enable pnpm +RUN pnpm fetch + +COPY . . +RUN pnpm install:ci +RUN pnpm build + +EXPOSE 8080 +CMD ["node", "app/rpc.ts"] diff --git a/app/assets/about.png b/app/assets/about.png new file mode 100644 index 00000000..7be21fc3 Binary files /dev/null and b/app/assets/about.png differ diff --git a/app/assets/logo.svg b/app/assets/logo.svg new file mode 100644 index 00000000..93b348ed --- /dev/null +++ b/app/assets/logo.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + diff --git a/app/components/Alert/Alert.tsx b/app/components/Alert/Alert.tsx new file mode 100644 index 00000000..066cc96b --- /dev/null +++ b/app/components/Alert/Alert.tsx @@ -0,0 +1,18 @@ +import { Alert as MantineAlert, Text, Title } from "@mantine/core" + +export function Alert(props: { title: string; description: string }) { + return ( + + {props.title} + + } + > + {props.description} + + ) +} diff --git a/app/components/Dialog/Dialog.module.css b/app/components/Dialog/Dialog.module.css new file mode 100644 index 00000000..dfa31fa7 --- /dev/null +++ b/app/components/Dialog/Dialog.module.css @@ -0,0 +1,38 @@ +.overlay { + position: fixed; + background-color: rgba(0, 0, 0, 0.4); + inset: 0; +} + +.content { + z-index: 100; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 100%; + max-height: calc(100% - 80px); + background-color: light-dark(white, var(--mantine-color-dark-6)); + border-top-left-radius: 10px; + border-top-right-radius: 10px; + padding: var(--mantine-spacing-lg); +} + +.handle { + width: 60px; + height: 6px; + background-color: var(--mantine-color-gray-4); + border-radius: 100px; + margin: 0 auto; +} + +.title { + font-size: 20px; + font-weight: bold; + margin-bottom: 16px; +} + +.description { + margin-bottom: 16px; + height: 100%; +} diff --git a/app/components/Dialog/Dialog.tsx b/app/components/Dialog/Dialog.tsx new file mode 100644 index 00000000..bb160989 --- /dev/null +++ b/app/components/Dialog/Dialog.tsx @@ -0,0 +1,59 @@ +import { Box, Button, Container, Flex, ScrollArea } from "@mantine/core" +import { useEffect, useState } from "react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" +import { Drawer as VaulDrawer } from "vaul" +import classes from "./Dialog.module.css" + +export function Dialog(props: { + open?: boolean + children: ReactNode + fullScreen?: boolean + onOpenChange: (open: boolean) => void +}) { + const { t } = useTranslation() + + const snapPoints = [0.5, 1] as const + const [snap, setSnap] = useState(snapPoints[0]) + + useEffect(() => { + setSnap(props.fullScreen ? snapPoints[1] : snapPoints[0]) + }, [props.fullScreen]) + + return ( + + + + + + + + + {props.children} + + + + + + + + ) +} diff --git a/app/components/Dialog/index.ts b/app/components/Dialog/index.ts new file mode 100644 index 00000000..07af50b9 --- /dev/null +++ b/app/components/Dialog/index.ts @@ -0,0 +1 @@ +export { Dialog } from "./Dialog.tsx" diff --git a/app/components/Form/Form.tsx b/app/components/Form/Form.tsx new file mode 100644 index 00000000..a7cb4d9a --- /dev/null +++ b/app/components/Form/Form.tsx @@ -0,0 +1,218 @@ +import { Box, Button, CloseButton, Stack, Tabs } from "@mantine/core" +import { FileInput, TextInput, Textarea } from "@mantine/core" +import { isJSONString, isNotEmpty, useForm } from "@mantine/form" +import { useState } from "react" +import { useTranslation } from "react-i18next" +import * as icons from "#icons.ts" +import * as settings from "#settings.ts" + +export interface FormProps { + onSubmit: (value: string | File | Record) => void +} + +export function Form(props: FormProps) { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState("url") + + return ( + + + + } + > + {t("URL")} + + + } + > + {t("File")} + + + } + > + {t("Text")} + + + + + + + + + + + + + + + + ) +} + +function UrlForm(props: { onSubmit: (value: string) => void }) { + const { t } = useTranslation() + const form = useForm({ + initialValues: { + url: "", + }, + validate: { + url: value => { + const notEmpty = isNotEmpty(t("URL is required"))(value) + if (notEmpty) return notEmpty + try { + new URL(value) + return null + } catch { + return t("Invalid URL format") + } + }, + }, + }) + + const handleSubmit = form.onSubmit(() => { + props.onSubmit(form.values.url) + }) + + return ( +
+ + form.setFieldValue("url", "")} + disabled={!form.values.url} + /> + } + {...form.getInputProps("url")} + /> + + +
+ ) +} + +function FileForm(props: { onSubmit: (value: File) => void }) { + const { t } = useTranslation() + const form = useForm({ + initialValues: { + file: null as File | null, + }, + validate: { + file: isNotEmpty(t("File is required")), + }, + }) + + const handleSubmit = form.onSubmit(async () => { + const file = form.values.file + if (!file) return + + try { + const text = await file.text() + const json = JSON.parse(text) + props.onSubmit(json) + } catch (err) { + form.setErrors({ file: t("Invalid JSON file") }) + } + }) + + return ( +
+ + form.setFieldValue("file", null)} + disabled={!form.values.file} + /> + } + {...form.getInputProps("file")} + /> + + +
+ ) +} + +function JsonForm(props: { onSubmit: (value: Record) => void }) { + const { t } = useTranslation() + const form = useForm({ + initialValues: { + text: "", + }, + validate: { + text: value => { + const notEmpty = isNotEmpty(t("Text is required"))(value) + if (notEmpty) return notEmpty + return isJSONString(t("Invalid JSON format"))(value) + }, + }, + }) + + const handleSubmit = form.onSubmit(() => { + const parsed = JSON.parse(form.values.text) + props.onSubmit(parsed) + }) + + return ( +
+ +